Skip to content
Merged
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
90 changes: 86 additions & 4 deletions src-tauri/src/llm/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,25 @@ pub async fn run_agent_stream(
let mut loop_detector = LoopDetector::new();
let mut pending_summary: Option<tokio::task::JoinHandle<Option<PrecomputedSummary>>> = None;

let mut iteration: u32 = 0;

loop {
iteration += 1;
let est_tokens: usize = messages.iter().map(|m| m.content.len() / 3).sum();
eprintln!(
"[agent_loop] session={} iter={} msgs={} est_tokens={} tool_calls_so_far={}/{} result_chars={}/{}",
&session_id[..8.min(session_id.len())],
iteration,
messages.len(),
est_tokens,
tool_call_count,
config.max_tool_calls,
total_tool_result_chars,
config.max_tool_result_chars,
);

if cancel.is_cancelled() {
eprintln!("[agent_loop] session={} iter={} cancelled before LLM call", &session_id[..8.min(session_id.len())], iteration);
store_extraction_msgs(&memory_manager, &messages).await;
return String::new();
}
Expand All @@ -163,6 +180,8 @@ pub async fn run_agent_stream(
context_guard.trim_with_summary(&mut messages).await;

// 1. 流式请求(携带 tools 字段;本轮文字会通过 emit_chunk/emit_done 推给前端)
eprintln!("[agent_loop] session={} iter={} calling chat_stream...", &session_id[..8.min(session_id.len())], iteration);
let stream_start = std::time::Instant::now();
let stream_result = match provider
.chat_stream(
&app,
Expand All @@ -173,8 +192,25 @@ pub async fn run_agent_stream(
)
.await
{
Ok(r) => r,
Err(_) => {
Ok(r) => {
eprintln!(
"[agent_loop] session={} iter={} chat_stream OK in {:.1}s content_len={} tool_calls={}",
&session_id[..8.min(session_id.len())],
iteration,
stream_start.elapsed().as_secs_f64(),
r.content.len(),
r.tool_calls.as_ref().map_or(0, |tc| tc.len()),
);
r
}
Err(e) => {
eprintln!(
"[agent_loop] session={} iter={} chat_stream FAILED in {:.1}s error={}",
&session_id[..8.min(session_id.len())],
iteration,
stream_start.elapsed().as_secs_f64(),
e,
);
store_extraction_msgs(&memory_manager, &messages).await;
emit_agent_done(&app, &session_id);
return String::new();
Expand All @@ -183,8 +219,19 @@ pub async fn run_agent_stream(

// 2. 无工具调用 → agent 循环完成;本轮文本即 final answer
let normalized_calls = match stream_result.tool_calls {
Some(calls) if !calls.is_empty() => calls,
Some(calls) if !calls.is_empty() => {
let names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect();
eprintln!(
"[agent_loop] session={} iter={} tool_calls={:?}",
&session_id[..8.min(session_id.len())], iteration, names,
);
calls
}
_ => {
eprintln!(
"[agent_loop] session={} iter={} no tool_calls, agent done. final_content_len={}",
&session_id[..8.min(session_id.len())], iteration, stream_result.content.len(),
);
messages.push(LlmChatMessage {
role: "assistant".to_string(),
content: stream_result.content.clone(),
Expand Down Expand Up @@ -273,6 +320,14 @@ pub async fn run_agent_stream(
Ok(val) => truncate_str(&val.to_string(), 200),
Err(e) => truncate_str(e, 200),
};
let result_len = match result {
Ok(val) => val.to_string().len(),
Err(e) => e.len(),
};
eprintln!(
"[agent_loop] session={} iter={} tool={} ok={} duration={}ms result_len={}",
&session_id[..8.min(session_id.len())], iteration, tc.name, success, duration_ms, result_len,
);
let error_message = result.as_ref().err().map(|e| e.as_str());
emit_tool_call_done(&app, &session_id, &tc.id, success, &result_summary, *duration_ms, error_message);
}
Expand Down Expand Up @@ -368,6 +423,15 @@ pub async fn run_agent_stream(
if tool_call_count >= config.max_tool_calls
|| total_tool_result_chars >= config.max_tool_result_chars
{
let reason = if tool_call_count >= config.max_tool_calls {
format!("tool_calls {}/{}", tool_call_count, config.max_tool_calls)
} else {
format!("result_chars {}/{}", total_tool_result_chars, config.max_tool_result_chars)
};
eprintln!(
"[agent_loop] session={} iter={} BUDGET EXHAUSTED ({}), requesting final summary...",
&session_id[..8.min(session_id.len())], iteration, reason,
);
messages.push(LlmChatMessage {
role: "system".to_string(),
content: "IMPORTANT: Tool call budget exhausted. You MUST now provide \
Expand All @@ -379,19 +443,37 @@ pub async fn run_agent_stream(
// gathered tool results should remain visible to the model.
// ContextGuard trimming is for future iterations that won't happen.
store_extraction_msgs(&memory_manager, &messages).await;
let final_start = std::time::Instant::now();
let est_tokens: usize = messages.iter().map(|m| m.content.len() / 3).sum();
eprintln!(
"[agent_loop] session={} final summary call: msgs={} est_tokens={}",
&session_id[..8.min(session_id.len())], messages.len(), est_tokens,
);
let final_result = provider
.chat_stream(&app, &session_id, messages, None, cancel.clone())
.await;
eprintln!(
"[agent_loop] session={} final summary completed in {:.1}s ok={}",
&session_id[..8.min(session_id.len())],
final_start.elapsed().as_secs_f64(),
final_result.is_ok(),
);
emit_agent_done(&app, &session_id);
return final_result.map(|r| r.content).unwrap_or_default();
}

if cancel.is_cancelled() {
eprintln!("[agent_loop] session={} iter={} cancelled after tool execution", &session_id[..8.min(session_id.len())], iteration);
store_extraction_msgs(&memory_manager, &messages).await;
return String::new();
}

if context_guard.budget_pressure(&messages) > 0.7 {
let pressure = context_guard.budget_pressure(&messages);
if pressure > 0.7 {
eprintln!(
"[agent_loop] session={} iter={} context pressure={:.2}, pre-summarizing",
&session_id[..8.min(session_id.len())], iteration, pressure,
);
let msgs_snapshot = messages.clone();
let guard_clone = context_guard.clone();
pending_summary = Some(tokio::spawn(async move {
Expand Down
160 changes: 155 additions & 5 deletions src-tauri/src/llm/provider_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ pub struct UnifiedProvider {
is_remote: bool,
}

// OpenAI API enforces ^[a-zA-Z0-9_-]+$ for function names — dots are not
// allowed. Internal tool names use dots as namespace separators
// (e.g. "note.read"), so we translate at the API boundary only.
// Hyphens are safe for round-tripping because the internal naming regex
// forbids them, making the mapping bijective.

fn to_api_tool_name(internal: &str) -> String {
internal.replace('.', "-")
}

fn from_api_tool_name(api: &str) -> String {
api.replace('-', ".")
}

impl UnifiedProvider {
pub fn new(
client: Arc<reqwest::Client>,
Expand Down Expand Up @@ -79,8 +93,13 @@ impl UnifiedProvider {
return Value::Object(obj);
}

// OpenAI API: content:null is only valid when tool_calls are present.
if m.content.is_empty() {
obj.insert("content".into(), Value::Null);
if m.tool_calls.as_ref().map_or(true, |tc| tc.is_empty()) {
obj.insert("content".into(), json!(""));
} else {
obj.insert("content".into(), Value::Null);
}
} else {
obj.insert("content".into(), json!(m.content));
}
Expand All @@ -92,7 +111,7 @@ impl UnifiedProvider {
"id": c.id,
"type": "function",
"function": {
"name": c.function.name,
"name": to_api_tool_name(&c.function.name),
"arguments": if c.function.arguments.is_object() {
c.function.arguments.to_string()
} else {
Expand Down Expand Up @@ -218,6 +237,19 @@ impl LlmProvider for UnifiedProvider {
}
}

let msg_count = messages.len();
let est_tokens: usize = messages_json.iter().map(|v| v.to_string().len() / 3).sum();
eprintln!(
"[provider] session={} chat_stream start: model={} msgs={} est_tokens={} timeout={}ms tools={}",
&session_id[..8.min(session_id.len())],
self.model,
msg_count,
est_tokens,
self.timeout_ms,
tools.as_ref().map_or(0, |t| t.len()),
);
let request_start = std::time::Instant::now();

let req = self.build_auth_headers(
self.client
.post(&url)
Expand All @@ -228,11 +260,24 @@ impl LlmProvider for UnifiedProvider {
Ok(r) => r,
Err(e) => {
let msg = format!("OpenAI connection error: {e}");
eprintln!(
"[provider] session={} connection error after {:.1}s: {}",
&session_id[..8.min(session_id.len())],
request_start.elapsed().as_secs_f64(),
e,
);
emit_error(app, session_id, Some("connection_error"), &msg);
return Err(msg);
}
};

eprintln!(
"[provider] session={} HTTP {} after {:.1}s, starting SSE stream",
&session_id[..8.min(session_id.len())],
resp.status(),
request_start.elapsed().as_secs_f64(),
);

if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
Expand All @@ -250,21 +295,51 @@ impl LlmProvider for UnifiedProvider {
let mut accumulated_content = String::new();
let mut pending_tool_calls: Vec<PendingToolCall> = Vec::new();

let mut last_chunk_at = std::time::Instant::now();
let mut chunk_count: u64 = 0;

loop {
tokio::select! {
_ = cancel.cancelled() => {
eprintln!(
"[provider] session={} SSE cancelled after {:.1}s, {} chunks, content_len={}",
&session_id[..8.min(session_id.len())],
request_start.elapsed().as_secs_f64(),
chunk_count,
accumulated_content.len(),
);
emit_error(app, session_id, Some("cancelled"), "Request aborted");
return Err("cancelled".to_string());
}
item = stream.next() => {
match item {
None => break,
None => {
eprintln!(
"[provider] session={} SSE stream ended (None): {:.1}s total, {} chunks, content_len={} pending_tools={}",
&session_id[..8.min(session_id.len())],
request_start.elapsed().as_secs_f64(),
chunk_count,
accumulated_content.len(),
pending_tool_calls.len(),
);
break;
}
Some(Err(e)) => {
let idle_secs = last_chunk_at.elapsed().as_secs_f64();
let msg = format!("OpenAI stream error: {e}");
eprintln!(
"[provider] session={} SSE error after {:.1}s (idle {:.1}s): {}",
&session_id[..8.min(session_id.len())],
request_start.elapsed().as_secs_f64(),
idle_secs,
e,
);
emit_error(app, session_id, Some("stream_error"), &msg);
return Err(msg);
}
Some(Ok(bytes)) => {
chunk_count += 1;
last_chunk_at = std::time::Instant::now();
line_buf.push_str(&String::from_utf8_lossy(&bytes));
if line_buf.len() > MAX_SSE_LINE_BYTES {
let msg = "SSE buffer exceeded 2 MiB; aborting.".to_string();
Expand Down Expand Up @@ -356,7 +431,7 @@ impl LlmProvider for UnifiedProvider {
} else {
p.id
},
name: p.name,
name: from_api_tool_name(&p.name),
arguments,
}
})
Expand Down Expand Up @@ -469,10 +544,16 @@ impl LlmProvider for UnifiedProvider {
manifests
.iter()
.map(|m| {
let api_name = m
.get("name")
.and_then(|v| v.as_str())
.map(to_api_tool_name)
.map(Value::String)
.unwrap_or(Value::Null);
json!({
"type": "function",
"function": {
"name": m.get("name").cloned().unwrap_or(Value::Null),
"name": api_name,
"description": m.get("description").cloned().unwrap_or(Value::Null),
"parameters": m.get("input_schema").cloned().unwrap_or(Value::Null),
}
Expand Down Expand Up @@ -570,5 +651,74 @@ mod tests {
assert_eq!(tcs[0]["id"], "call_xyz");
assert_eq!(tcs[0]["type"], "function");
assert!(tcs[0]["function"]["arguments"].is_string());
assert_eq!(tcs[0]["function"]["name"], "web-search");
}

#[test]
fn to_api_tool_name_replaces_dots() {
assert_eq!(to_api_tool_name("note.read"), "note-read");
assert_eq!(to_api_tool_name("vault.search_keyword"), "vault-search_keyword");
assert_eq!(to_api_tool_name("time.now"), "time-now");
assert_eq!(to_api_tool_name("skill.writing_coach"), "skill-writing_coach");
}

#[test]
fn from_api_tool_name_restores_dots() {
assert_eq!(from_api_tool_name("note-read"), "note.read");
assert_eq!(from_api_tool_name("vault-search_keyword"), "vault.search_keyword");
assert_eq!(from_api_tool_name("time-now"), "time.now");
assert_eq!(from_api_tool_name("skill-writing_coach"), "skill.writing_coach");
}

#[test]
fn tool_name_round_trip() {
let names = [
"note.read", "note.list", "note.write_section", "note.create", "note.append",
"vault.search_keyword", "vault.semantic_search",
"thought.list", "thought.create",
"web.search", "web.read_page", "web.download", "web.read_pdf",
"graph.query_topic_network", "index.status", "link.suggest_related",
"memory.save", "memory.forget", "time.now",
"skill.writing_coach", "skill.web_research",
];
for name in &names {
assert_eq!(&from_api_tool_name(&to_api_tool_name(name)), name);
}
}

#[test]
fn convert_tools_maps_names() {
let provider = UnifiedProvider::new(
test_client(),
"https://api.openai.com/v1".to_string(),
"k".to_string(),
"m".to_string(),
0.7,
None,
30000,
None,
true,
);
let manifests = vec![json!({
"name": "web.search",
"description": "Search the web",
"input_schema": {"type": "object"}
})];
let tools = provider.convert_tools(&manifests);
assert_eq!(tools[0]["function"]["name"], "web-search");
}

#[test]
fn serialize_messages_empty_content_no_tool_calls() {
let messages = vec![LlmChatMessage {
role: "assistant".to_string(),
content: String::new(),
tool_calls: None,
tool_name: None,
tool_call_id: None,
}];
let json = UnifiedProvider::serialize_messages(&messages);
let obj = json[0].as_object().unwrap();
assert_eq!(obj.get("content").unwrap(), "");
}
}
Loading