Skip to content

Commit f157adc

Browse files
SonAIengineclaude
andcommitted
feat: graph-tool-call 연동 — 하드코딩 tool 제거, OpenAPI 동적 검색으로 전환
기존: 9개 tool을 Rust에 하드코딩 변경: graph-tool-call 바이너리로 XGEN OpenAPI에서 동적 tool 검색 구조: - tool_search.rs: graph-tool-call sidecar 호출 (search → graph 파싱 → LLM schema 변환) - 사용자 메시지 → 한국어→영문 키워드 변환 → graph-tool-call search → 관련 tool 5~7개 - LLM에 동적 tool만 전달 → tool use → graph-tool-call call로 실행 - 검색/실행 실패 시 하드코딩 tool로 fallback graph-tool-call 바이너리: - PyInstaller로 단일 실행파일 빌드 (16MB, Python 불필요) - Tauri externalBin으로 앱에 번들 (binaries/) - 시스템 PATH에서도 자동 탐색 E2E 테스트 3/3 통과: - 워크플로우 목록: 동적 7개 tool → list_workflows 호출 → 11개 워크플로우 - LLM 상태: 동적 7개 tool → get_llm_status → 5개 provider - 일반 질문: tool 불필요 판단 → 직접 답변 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c3c6220 commit f157adc

5 files changed

Lines changed: 485 additions & 5 deletions

File tree

src-tauri/src/services/llm_client.rs

Lines changed: 178 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter};
1212
use crate::error::{AppError, Result};
1313
use crate::services::XgenApiClient;
1414
use crate::services::xgen_api::LlmProviderConfig;
15+
use crate::services::tool_search;
1516

1617
const MAX_TOOL_ROUNDS: usize = 5;
1718

@@ -81,10 +82,14 @@ impl LlmClient {
8182
- 워크플로우 목록 조회, 생성, 실행, 삭제
8283
- 스케줄 생성 및 관리
8384
- 노드/도구/LLM 상태 확인
85+
- 문서 검색 및 RAG
8486
- 사용자 질문에 친절하게 답변
8587
8688
tool 호출 결과를 사용자에게 보기 좋게 정리해서 한국어로 답변하세요.
87-
JSON 결과는 핵심 정보만 추려서 읽기 쉽게 정리하세요."#
89+
JSON 결과는 핵심 정보만 추려서 읽기 쉽게 정리하세요.
90+
91+
주어진 tool 중에서 사용자 요청에 가장 적합한 것을 선택하세요.
92+
적합한 tool이 없으면 tool을 호출하지 말고 직접 답변하세요."#
8893
}
8994

9095
// ============================================================
@@ -575,7 +580,148 @@ JSON 결과는 핵심 정보만 추려서 읽기 쉽게 정리하세요."#
575580
}
576581

577582
// ============================================================
578-
// Unified entry point
583+
// Non-streaming API call (for tests, no AppHandle needed)
584+
// ============================================================
585+
586+
async fn call_anthropic_nostream(
587+
&self,
588+
messages: &[ChatMessage],
589+
tools: &[Value],
590+
) -> Result<Value> {
591+
let body = serde_json::json!({
592+
"model": self.config.model,
593+
"max_tokens": 4096,
594+
"system": Self::system_prompt(),
595+
"messages": messages,
596+
"tools": tools,
597+
});
598+
599+
let resp = self.client
600+
.post("https://api.anthropic.com/v1/messages")
601+
.header("x-api-key", &self.config.api_key)
602+
.header("anthropic-version", "2023-06-01")
603+
.header("content-type", "application/json")
604+
.json(&body)
605+
.send()
606+
.await
607+
.map_err(|e| AppError::LlmApi(format!("Request failed: {}", e)))?;
608+
609+
let status = resp.status();
610+
let text = resp.text().await.unwrap_or_default();
611+
if !status.is_success() {
612+
return Err(AppError::LlmApi(format!("HTTP {} - {}", status.as_u16(), text)));
613+
}
614+
615+
let response: Value = serde_json::from_str(&text)
616+
.map_err(|e| AppError::LlmApi(format!("Parse error: {}", e)))?;
617+
618+
let stop_reason = response["stop_reason"].as_str().unwrap_or("end_turn");
619+
let content = response["content"].clone();
620+
621+
Ok(serde_json::json!({"role":"assistant","content":content,"stop_reason":stop_reason}))
622+
}
623+
624+
/// Non-streaming tool use loop (for testing without AppHandle)
625+
pub async fn send_with_tools_nostream(
626+
&self,
627+
messages: &mut Vec<ChatMessage>,
628+
xgen_api: &XgenApiClient,
629+
) -> Result<String> {
630+
// 사용자 메시지에서 쿼리 추출하여 관련 tool 동적 검색
631+
let user_query = messages.last()
632+
.and_then(|m| m.content.as_str())
633+
.unwrap_or("help");
634+
635+
let openapi_source = format!("{}/api/openapi", xgen_api.base_url());
636+
let tools = match tool_search::search_tools_for_llm(user_query, &openapi_source, Some(7)).await {
637+
Ok(t) if !t.is_empty() => {
638+
println!(" [tools] Found {} dynamic tools for '{}'", t.len(), user_query);
639+
t
640+
}
641+
Ok(_) | Err(_) => {
642+
println!(" [tools] Fallback to hardcoded tools");
643+
XgenApiClient::tool_definitions()
644+
}
645+
};
646+
let mut final_text = String::new();
647+
648+
for round in 0..MAX_TOOL_ROUNDS {
649+
println!("[CLI test] round {}/{} ({})", round + 1, MAX_TOOL_ROUNDS, self.config.provider);
650+
651+
let response = self.call_anthropic_nostream(messages, &tools).await?;
652+
653+
let stop_reason = response["stop_reason"].as_str().unwrap_or("end_turn");
654+
let content = response["content"].as_array()
655+
.ok_or_else(|| AppError::LlmApi("Invalid response content".into()))?;
656+
657+
messages.push(ChatMessage {
658+
role: "assistant".into(),
659+
content: Value::Array(content.clone()),
660+
});
661+
662+
if stop_reason == "tool_use" {
663+
let mut tool_results: Vec<Value> = Vec::new();
664+
665+
for block in content {
666+
if block["type"].as_str() == Some("tool_use") {
667+
let tool_id = block["id"].as_str().unwrap_or("");
668+
let tool_name = block["name"].as_str().unwrap_or("");
669+
let tool_input = block["input"].clone();
670+
671+
println!(" [tool] {} → {:?}", tool_name, tool_input);
672+
673+
// graph-tool-call call로 실행 (OpenAPI 기반 동적 실행)
674+
let result = match tool_search::execute_tool_call(
675+
tool_name,
676+
&tool_input,
677+
&openapi_source,
678+
xgen_api.base_url(),
679+
xgen_api.auth_token(),
680+
).await {
681+
Ok(v) => {
682+
let s = serde_json::to_string_pretty(&v).unwrap_or_default();
683+
println!(" [result] {}...", s.chars().take(200).collect::<String>());
684+
s
685+
},
686+
Err(e) => {
687+
// fallback: xgen_api.execute_tool
688+
println!(" [graph-tool-call fallback] {}", e);
689+
match xgen_api.execute_tool(tool_name, tool_input.clone()).await {
690+
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
691+
Err(e2) => format!("Error: {}", e2),
692+
}
693+
},
694+
};
695+
696+
tool_results.push(serde_json::json!({
697+
"type": "tool_result",
698+
"tool_use_id": tool_id,
699+
"content": result,
700+
}));
701+
}
702+
}
703+
704+
messages.push(ChatMessage {
705+
role: "user".into(),
706+
content: Value::Array(tool_results),
707+
});
708+
} else {
709+
for block in content {
710+
if block["type"].as_str() == Some("text") {
711+
if let Some(text) = block["text"].as_str() {
712+
final_text.push_str(text);
713+
}
714+
}
715+
}
716+
break;
717+
}
718+
}
719+
720+
Ok(final_text)
721+
}
722+
723+
// ============================================================
724+
// Unified streaming entry point
579725
// ============================================================
580726

581727
async fn call_stream(
@@ -600,7 +746,22 @@ JSON 결과는 핵심 정보만 추려서 읽기 쉽게 정리하세요."#
600746
session_id: &str,
601747
app: &AppHandle,
602748
) -> Result<String> {
603-
let tools = XgenApiClient::tool_definitions();
749+
// 동적 tool 검색
750+
let user_query = messages.last()
751+
.and_then(|m| m.content.as_str())
752+
.unwrap_or("help");
753+
754+
let openapi_source = format!("{}/api/openapi", xgen_api.base_url());
755+
let tools = match tool_search::search_tools_for_llm(user_query, &openapi_source, Some(7)).await {
756+
Ok(t) if !t.is_empty() => {
757+
log::info!("Found {} dynamic tools for '{}'", t.len(), user_query);
758+
t
759+
}
760+
Ok(_) | Err(_) => {
761+
log::warn!("Fallback to hardcoded tools");
762+
XgenApiClient::tool_definitions()
763+
}
764+
};
604765
let mut final_text = String::new();
605766

606767
for round in 0..MAX_TOOL_ROUNDS {
@@ -634,9 +795,21 @@ JSON 결과는 핵심 정보만 추려서 읽기 쉽게 정리하세요."#
634795
data: serde_json::json!({"id":tool_id,"name":tool_name,"input":tool_input}),
635796
});
636797

637-
let result = match xgen_api.execute_tool(tool_name, tool_input).await {
798+
let result = match tool_search::execute_tool_call(
799+
tool_name,
800+
&tool_input,
801+
&openapi_source,
802+
xgen_api.base_url(),
803+
xgen_api.auth_token(),
804+
).await {
638805
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
639-
Err(e) => format!("Error: {}", e),
806+
Err(e) => {
807+
log::warn!("graph-tool-call fallback: {}", e);
808+
match xgen_api.execute_tool(tool_name, tool_input).await {
809+
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
810+
Err(e2) => format!("Error: {}", e2),
811+
}
812+
}
640813
};
641814

642815
let _ = app.emit("cli:event", CliStreamEvent {

src-tauri/src/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod model_manager;
66
pub mod sidecar_manager;
77
pub mod xgen_api;
88
pub mod llm_client;
9+
pub mod tool_search;
910

1011
pub use model_manager::{ModelInfo, ModelManager, ModelType};
1112
pub use sidecar_manager::{SidecarConfig, SidecarManager, SidecarStatus};

0 commit comments

Comments
 (0)