add tool calling, SQLite persistence, group chat, image vision, voice transcription

Major features:
- OpenAI function calling with tool call loop (streaming SSE parsing)
- Built-in tools: spawn_agent (async claude -p), agent_status, kill_agent,
  update_scratch, send_file
- Script-based tool discovery: tools/ dir with --schema convention
- Feishu todo management script (tools/manage_todo)
- SQLite persistence: conversations, messages, config, scratch_area tables
- Sliding window context (100 msgs, slide 50, auto-summarize)
- Conversation summary generation via LLM on window slide
- Group chat support with independent session contexts
- Image understanding: multimodal vision input (base64 to API)
- Voice transcription via faster-whisper Docker service
- Configurable persona stored in DB
- diag command for session diagnostics
- System prompt restructured: persona + tool instructions separated
- RUST_BACKTRACE=1 in service, clippy in deploy pipeline
- .gitignore for config/state/db files
This commit is contained in:
Fam Zheng
2026-04-09 16:38:28 +01:00
parent 84ba209b3f
commit 128f2481c0
9 changed files with 1840 additions and 51 deletions

361
tests/tool_call.rs Normal file
View File

@@ -0,0 +1,361 @@
//! Integration test: verify tool call round-trip with Ollama's OpenAI-compatible API.
//! Requires Ollama running at OLLAMA_URL (default: http://100.84.7.49:11434).
use serde_json::json;
const OLLAMA_URL: &str = "http://100.84.7.49:11434/v1";
const MODEL: &str = "gemma4:31b";
fn tools() -> serde_json::Value {
json!([{
"type": "function",
"function": {
"name": "calculator",
"description": "Calculate a math expression",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression to evaluate"}
},
"required": ["expression"]
}
}
}])
}
/// Test non-streaming tool call round-trip
#[tokio::test]
async fn test_tool_call_roundtrip_non_streaming() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// Round 1: ask the model to use the calculator
let body = json!({
"model": MODEL,
"messages": [
{"role": "user", "content": "What is 2+2? Use the calculator tool."}
],
"tools": tools(),
});
let resp = client.post(&url).json(&body).send().await.unwrap();
assert!(resp.status().is_success(), "Round 1 failed: {}", resp.status());
let result: serde_json::Value = resp.json().await.unwrap();
let choice = &result["choices"][0];
assert_eq!(
choice["finish_reason"].as_str().unwrap(),
"tool_calls",
"Expected tool_calls finish_reason, got: {choice}"
);
let tool_calls = choice["message"]["tool_calls"].as_array().unwrap();
assert!(!tool_calls.is_empty(), "No tool calls returned");
let tc = &tool_calls[0];
let call_id = tc["id"].as_str().unwrap();
let func_name = tc["function"]["name"].as_str().unwrap();
assert_eq!(func_name, "calculator");
// Round 2: send tool result back
let body2 = json!({
"model": MODEL,
"messages": [
{"role": "user", "content": "What is 2+2? Use the calculator tool."},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": func_name,
"arguments": tc["function"]["arguments"].as_str().unwrap()
}
}]
},
{
"role": "tool",
"tool_call_id": call_id,
"content": "4"
}
],
"tools": tools(),
});
let resp2 = client.post(&url).json(&body2).send().await.unwrap();
let status2 = resp2.status();
let body2_text = resp2.text().await.unwrap();
assert!(
status2.is_success(),
"Round 2 failed ({status2}): {body2_text}"
);
let result2: serde_json::Value = serde_json::from_str(&body2_text).unwrap();
let content = result2["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
assert!(!content.is_empty(), "Expected content in round 2 response");
println!("Round 2 response: {content}");
}
/// Test tool call with conversation history (simulates real scenario)
#[tokio::test]
async fn test_tool_call_with_history() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// Simulate real message history with system prompt
let body = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "system", "content": "你是一个AI助手。你可以使用提供的工具来完成任务。当需要执行命令、运行代码或启动复杂子任务时直接调用对应的工具不要只是描述你会怎么做。"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "What is 3+4? Use the calculator."}
],
"tools": tools(),
});
// Round 1: expect tool call
let mut resp = client.post(&url).json(&body).send().await.unwrap();
assert!(resp.status().is_success(), "Round 1 failed: {}", resp.status());
let mut buffer = String::new();
let mut tc_id = String::new();
let mut tc_name = String::new();
let mut tc_args = String::new();
let mut has_tc = false;
while let Some(chunk) = resp.chunk().await.unwrap() {
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 1..].to_string();
if let Some(data) = line.trim().strip_prefix("data: ") {
if data.trim() == "[DONE]" { break; }
if let Ok(j) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(arr) = j["choices"][0]["delta"]["tool_calls"].as_array() {
has_tc = true;
for tc in arr {
if let Some(id) = tc["id"].as_str() { tc_id = id.into(); }
if let Some(n) = tc["function"]["name"].as_str() { tc_name = n.into(); }
if let Some(a) = tc["function"]["arguments"].as_str() { tc_args.push_str(a); }
}
}
}
}
}
}
assert!(has_tc, "Expected tool call, got content only");
println!("Tool: {tc_name}({tc_args}) id={tc_id}");
// Round 2: tool result → expect content
let body2 = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "system", "content": "你是一个AI助手。"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "What is 3+4? Use the calculator."},
{"role": "assistant", "content": "", "tool_calls": [{"id": tc_id, "type": "function", "function": {"name": tc_name, "arguments": tc_args}}]},
{"role": "tool", "tool_call_id": tc_id, "content": "7"}
],
"tools": tools(),
});
let resp2 = client.post(&url).json(&body2).send().await.unwrap();
let status = resp2.status();
if !status.is_success() {
let err = resp2.text().await.unwrap();
panic!("Round 2 failed ({status}): {err}");
}
let mut resp2 = client.post(&url).json(&body2).send().await.unwrap();
let mut content = String::new();
let mut buf2 = String::new();
while let Some(chunk) = resp2.chunk().await.unwrap() {
buf2.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buf2.find('\n') {
let line = buf2[..pos].to_string();
buf2 = buf2[pos + 1..].to_string();
if let Some(data) = line.trim().strip_prefix("data: ") {
if data.trim() == "[DONE]" { break; }
if let Ok(j) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(c) = j["choices"][0]["delta"]["content"].as_str() {
content.push_str(c);
}
}
}
}
}
println!("Final response: {content}");
assert!(!content.is_empty(), "Expected non-empty content in round 2");
}
/// Test multimodal image input
#[tokio::test]
async fn test_image_multimodal() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// 2x2 red PNG generated by PIL
let b64 = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg==";
let body = json!({
"model": MODEL,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What color is this image? Reply with just the color name."},
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{b64}")}}
]
}],
});
let resp = client.post(&url).json(&body).send().await.unwrap();
let status = resp.status();
let text = resp.text().await.unwrap();
assert!(status.is_success(), "Multimodal request failed ({status}): {text}");
let result: serde_json::Value = serde_json::from_str(&text).unwrap();
let content = result["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
println!("Image description: {content}");
assert!(!content.is_empty(), "Expected non-empty response for image");
}
/// Test streaming tool call round-trip (matches our actual code path)
#[tokio::test]
async fn test_tool_call_roundtrip_streaming() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// Round 1: streaming, get tool calls
let body = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "user", "content": "What is 7*6? Use the calculator tool."}
],
"tools": tools(),
});
let mut resp = client.post(&url).json(&body).send().await.unwrap();
assert!(resp.status().is_success(), "Round 1 streaming failed");
// Parse SSE to extract tool calls
let mut buffer = String::new();
let mut tool_call_id = String::new();
let mut tool_call_name = String::new();
let mut tool_call_args = String::new();
let mut has_tool_calls = false;
while let Some(chunk) = resp.chunk().await.unwrap() {
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 1..].to_string();
let trimmed = line.trim();
if let Some(data) = trimmed.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
break;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
let delta = &json["choices"][0]["delta"];
if let Some(tc_arr) = delta["tool_calls"].as_array() {
has_tool_calls = true;
for tc in tc_arr {
if let Some(id) = tc["id"].as_str() {
tool_call_id = id.to_string();
}
if let Some(name) = tc["function"]["name"].as_str() {
tool_call_name = name.to_string();
}
if let Some(args) = tc["function"]["arguments"].as_str() {
tool_call_args.push_str(args);
}
}
}
}
}
}
}
assert!(has_tool_calls, "No tool calls in streaming response");
assert_eq!(tool_call_name, "calculator");
println!("Tool call: {tool_call_name}({tool_call_args}) id={tool_call_id}");
// Round 2: send tool result, streaming
let body2 = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "user", "content": "What is 7*6? Use the calculator tool."},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": tool_call_id,
"type": "function",
"function": {
"name": tool_call_name,
"arguments": tool_call_args
}
}]
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": "42"
}
],
"tools": tools(),
});
let resp2 = client.post(&url).json(&body2).send().await.unwrap();
let status2 = resp2.status();
if !status2.is_success() {
let err = resp2.text().await.unwrap();
panic!("Round 2 streaming failed ({status2}): {err}");
}
// Collect content from streaming response
let mut resp2 = client
.post(&url)
.json(&body2)
.send()
.await
.unwrap();
let mut content = String::new();
let mut buffer2 = String::new();
while let Some(chunk) = resp2.chunk().await.unwrap() {
buffer2.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer2.find('\n') {
let line = buffer2[..pos].to_string();
buffer2 = buffer2[pos + 1..].to_string();
let trimmed = line.trim();
if let Some(data) = trimmed.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
break;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(c) = json["choices"][0]["delta"]["content"].as_str() {
content.push_str(c);
}
}
}
}
}
assert!(!content.is_empty(), "Expected content in round 2 streaming");
println!("Round 2 streaming content: {content}");
}