diff --git a/src-tauri/src/llm/agent_loop.rs b/src-tauri/src/llm/agent_loop.rs index a29fde7..4b70dc2 100644 --- a/src-tauri/src/llm/agent_loop.rs +++ b/src-tauri/src/llm/agent_loop.rs @@ -139,8 +139,25 @@ pub async fn run_agent_stream( let mut loop_detector = LoopDetector::new(); let mut pending_summary: Option>> = 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(); } @@ -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, @@ -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(); @@ -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(), @@ -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); } @@ -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 \ @@ -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 { diff --git a/src-tauri/src/llm/provider_impl.rs b/src-tauri/src/llm/provider_impl.rs index 00a2584..21a647c 100644 --- a/src-tauri/src/llm/provider_impl.rs +++ b/src-tauri/src/llm/provider_impl.rs @@ -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, @@ -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)); } @@ -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 { @@ -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) @@ -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(); @@ -250,21 +295,51 @@ impl LlmProvider for UnifiedProvider { let mut accumulated_content = String::new(); let mut pending_tool_calls: Vec = 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(); @@ -356,7 +431,7 @@ impl LlmProvider for UnifiedProvider { } else { p.id }, - name: p.name, + name: from_api_tool_name(&p.name), arguments, } }) @@ -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), } @@ -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(), ""); } } diff --git a/src-tauri/src/skills/mod.rs b/src-tauri/src/skills/mod.rs index 127bb41..69bad91 100644 --- a/src-tauri/src/skills/mod.rs +++ b/src-tauri/src/skills/mod.rs @@ -47,28 +47,71 @@ Hard constraints: - Do not judge answers as "good" or "bad"; use descriptive phrases like "this covers..." or "this could extend to..." instead. - If no related thoughts or notes are found by the tools, say so honestly; do not fabricate content."#; -const WEB_RESEARCH_PROMPT: &str = r#"You are a research assistant helping the user conduct web research in their knowledge base {{workspace_name}}. - -Your workflow: -1. Analyze the user's research request and break it into 1-3 search keywords (prefer English keywords for broader results). -2. Call web.search to execute the search and obtain a result list. -3. Select 2-4 of the most relevant pages from the results and call web.read_page to read their content in detail. -4. Optionally call vault.search_keyword to check whether related notes already exist in the vault, avoiding duplicates and establishing connections. -5. Synthesize all information into a structured research report. -6. Call note.create to save the report to the vault. Save path format: research/{topic-keyword}.md - -Report format requirements: -- Frontmatter tags must include "research". -- Include these sections: Overview, Key Findings (bulleted), Sources. -- Annotate each finding with its source URL. -- End with a `## Sources` section listing all referenced URLs with titles. -- If related notes exist in the vault, link them using [[wikilink]] syntax. +const WEB_RESEARCH_PROMPT: &str = r#"You are a research analyst helping the user conduct solution research in their knowledge base {{workspace_name}}. + +Your workflow has four phases: + +Phase 1 — Decompose & Search +- Analyze the user's topic and break it into 2-4 search angles (e.g. concept overview, mainstream solutions, comparison/benchmark, best practices). +- For each angle, call web.search with targeted keywords (prefer English keywords for broader coverage). +- If the topic is narrow, 2 angles suffice; if broad, use up to 4. + +Phase 2 — Deep Read +- From all search results, select 3-6 of the most relevant and authoritative pages and call web.read_page to read each in detail. +- When you encounter PDF links (papers, whitepapers, technical reports), call web.read_pdf to extract the full text. +- Prioritize: official documentation > technical blogs with benchmarks > general articles. + +Phase 3 — Knowledge Base Cross-reference +- Call vault.search_keyword to check whether related notes already exist in the vault. +- If related notes are found, reference them using [[wikilink]] syntax in the report. + +Phase 4 — Synthesize & Save +- Synthesize all gathered information into a structured research report. +- Call note.create to save the report. Save path format: research/{topic-keyword}.md + +Report format: +``` +--- +tags: [research, ] +--- +# Research Report + +## Background & Objectives +Briefly state the research question, scope, and why it matters. + +## Solution Overview +List and briefly introduce each identified solution/approach. + +## Detailed Analysis +### Solution A: +Core mechanism, strengths, weaknesses, typical use cases. +### Solution B: +(same structure) +(repeat for each solution) + +## Comparison +| Dimension | Solution A | Solution B | ... | +|-----------|-----------|-----------|-----| +| Maturity | | | | +| Performance | | | | +| Ease of use | | | | +| Cost | | | | +| Community/Ecosystem | | | | +(adapt dimensions to the topic) + +## Recommendations +State which solution fits which scenario, with reasoning. + +## References +- [Title](URL) — one-line annotation +``` Hard constraints: - All information must come from actual tool results; never fabricate URLs or facts. -- If a search returns no results or a page read fails, report it honestly; do not fake information. -- Every key finding must have at least one supporting source. -- Write the report in the same language the user used."#; +- If a search returns no results or a page fails to load, report it honestly. +- Every claim must cite at least one source. +- Write the report in the same language the user used. +- Adapt section depth to the topic: skip the comparison table if only one solution exists; add sub-dimensions if the topic warrants it."#; fn writing_coach_manifest() -> SkillManifest { SkillManifest { @@ -119,24 +162,25 @@ fn challenge_review_manifest() -> SkillManifest { fn web_research_manifest() -> SkillManifest { SkillManifest { id: "web_research".to_string(), - name: "网络调研".to_string(), - version: "0.1.0".to_string(), - description: "搜索网络信息,精读关键页面,生成调研报告并归档到知识库。".to_string(), + name: "方案调研".to_string(), + version: "0.2.0".to_string(), + description: "围绕指定主题搜索网络信息,对比可选方案,生成结构化调研报告并归档到知识库。".to_string(), system_prompt_template: WEB_RESEARCH_PROMPT.to_string(), allowed_tools: vec![ "web.search".to_string(), "web.read_page".to_string(), + "web.read_pdf".to_string(), "note.create".to_string(), "note.append".to_string(), "vault.search_keyword".to_string(), ], - max_tool_calls: 15, - timeout_secs: 120, + max_tool_calls: 25, + timeout_secs: 180, ui_entry: SkillUiEntry::ConversationMode, tags: vec!["research".to_string(), "web".to_string()], auto_invocable: true, when_to_use: Some( - "用户提出调研任务、要求搜索网络信息、或要求就某个主题生成调研报告时".to_string(), + "用户提出调研任务、技术选型、方案对比,或要求就某个主题搜索网络信息并生成调研报告时".to_string(), ), max_tool_result_chars: 20000, } @@ -275,9 +319,10 @@ mod mod_tests { assert!(m.system_prompt_template.contains("{{workspace_name}}")); assert!(m.allowed_tools.contains(&"web.search".to_string())); assert!(m.allowed_tools.contains(&"web.read_page".to_string())); + assert!(m.allowed_tools.contains(&"web.read_pdf".to_string())); assert!(m.allowed_tools.contains(&"note.create".to_string())); - assert_eq!(m.max_tool_calls, 15); - assert_eq!(m.timeout_secs, 120); + assert_eq!(m.max_tool_calls, 25); + assert_eq!(m.timeout_secs, 180); assert_eq!(m.max_tool_result_chars, 20000); assert!(m.auto_invocable); } diff --git a/src-tauri/src/skills/skill_tool.rs b/src-tauri/src/skills/skill_tool.rs index 5314949..92f4655 100644 --- a/src-tauri/src/skills/skill_tool.rs +++ b/src-tauri/src/skills/skill_tool.rs @@ -242,6 +242,12 @@ impl Tool for SkillAsTool { let overall_timeout = Duration::from_secs( (manifest.timeout_secs as u64).saturating_mul(2), ); + eprintln!( + "[skill_tool] skill={} session={} starting, timeout={}s max_tool_calls={} max_result_chars={}", + self.skill_id, &session_id[..8.min(session_id.len())], + overall_timeout.as_secs(), manifest.max_tool_calls, manifest.max_tool_result_chars, + ); + let skill_start = std::time::Instant::now(); let skill_future = runtime::run_skill_with_depth( self.app.clone(), session_id.clone(), @@ -261,8 +267,22 @@ impl Tool for SkillAsTool { ai.request.max_context_tokens, ); let summary = match tokio::time::timeout(overall_timeout, skill_future).await { - Ok(s) => s, - Err(_) => String::new(), + Ok(s) => { + eprintln!( + "[skill_tool] skill={} session={} completed in {:.1}s summary_len={}", + self.skill_id, &session_id[..8.min(session_id.len())], + skill_start.elapsed().as_secs_f64(), s.len(), + ); + s + } + Err(_) => { + eprintln!( + "[skill_tool] skill={} session={} TIMED OUT after {:.1}s (limit={}s)", + self.skill_id, &session_id[..8.min(session_id.len())], + skill_start.elapsed().as_secs_f64(), overall_timeout.as_secs(), + ); + String::new() + } }; sessions.remove_session(&session_id);