ZeroClaw — Tool Dispatcher
Layer 2 학습. 실제 소스 코드 기반. 파일:
src/agent/dispatcher.rs
왜 ToolDispatcher가 필요한가
LLM마다 tool call 방식이 달라.
Claude / OpenAI → API 레벨 tool calling
요청: tools 배열 전달
응답: response.tool_calls 배열 반환
로컬 LLM → 텍스트 생성만 가능
(llama.cpp, Qwen) tool calling 개념 없음
해결: system prompt에 도구 설명 주입
응답: <tool_call>...</tool_call> 텍스트 파싱
ToolDispatcher trait이 이 차이를 추상화한다. agent loop는 dispatcher 종류를 몰라도 된다.
ToolDispatcher trait
pub trait ToolDispatcher: Send + Sync {
// 1. LLM 응답에서 tool call 파싱
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>);
// 2. tool 실행 결과를 history에 넣을 형식으로 변환
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage;
// 3. history를 provider API 형식으로 변환
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage>;
// 4. tools 배열을 API 요청에 포함할지 여부
fn should_send_tool_specs(&self) -> bool;
// 5. system prompt에 추가할 tool 사용법 지침
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String;
}
핵심 타입
pub struct ParsedToolCall {
pub name: String,
pub arguments: serde_json::Value,
pub tool_call_id: Option<String>, // Native만 있음, XML은 None
}
pub struct ToolExecutionResult {
pub name: String,
pub output: String,
pub success: bool,
pub tool_call_id: Option<String>,
}
XmlToolDispatcher — 로컬 LLM용
동작 방식
system prompt에 XML 사용법 주입:
## Tool Use Protocol
<tool_call>{"name": "tool_name", "arguments": {...}}</tool_call>
LLM이 텍스트로 응답:
"파일을 읽어보겠습니다.
<tool_call>{"name":"file_read","arguments":{"path":"a.txt"}}</tool_call>"
parse_response() 가 텍스트에서 태그를 찾아 파싱
parse_response() 로직
fn parse_xml_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
// 1. <think>...</think> 태그 제거 (Qwen reasoning 모델용)
let cleaned = Self::strip_think_tags(response);
// 2. <tool_call>...</tool_call> 태그 찾기
while let Some(start) = remaining.find("<tool_call>") {
// 태그 밖 텍스트는 text_parts에
let before = &remaining[..start];
if !before.trim().is_empty() { text_parts.push(before); }
// 태그 안 JSON 파싱
let inner = &remaining[start + 11..start + end];
let parsed = serde_json::from_str::<Value>(inner.trim())?;
calls.push(ParsedToolCall {
name: parsed["name"].as_str()...,
arguments: parsed["arguments"]...,
tool_call_id: None, // XML 방식엔 id 없음
});
}
(text_parts.join("\n"), calls) // (일반 텍스트, tool calls)
}
format_results() — 결과를 user 메시지로
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
// XML 형식으로 결과 포맷팅
let content = results.iter().map(|r| {
format!(
"<tool_result name=\"{}\" status=\"{}\">\n{}\n</tool_result>",
r.name,
if r.success { "ok" } else { "error" },
r.output
)
}).collect::<String>();
// role: user 메시지로 전달 (LLM이 결과를 "사용자 응답"으로 받음)
ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}")))
}
to_provider_messages() — history 직렬화
// AssistantToolCalls → 일반 텍스트 assistant 메시지
ConversationMessage::AssistantToolCalls { text, .. } =>
vec![ChatMessage::assistant(text.unwrap_or_default())]
// ToolResults → user 메시지 (XML 형식)
ConversationMessage::ToolResults(results) =>
vec![ChatMessage::user("[Tool results]\n<tool_result id=\"{id}\">..")]
should_send_tool_specs() → false
도구 명세를 API에 보내지 않는다. 이미 system prompt 텍스트에 있으니까.
NativeToolDispatcher — Claude/OpenAI용
동작 방식
API 요청에 tools 배열 포함:
{
"messages": [...],
"tools": [{"name":"file_read","description":"...","parameters":{...}}]
}
LLM이 구조화된 JSON으로 응답:
response.tool_calls = [
{"id":"tc1","name":"file_read","arguments":"{\"path\":\"a.txt\"}"}
]
parse_response() 가 tool_calls 배열을 직접 읽음
parse_response() 로직
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
let text = response.text.clone().unwrap_or_default();
let calls = response.tool_calls.iter().map(|tc| {
ParsedToolCall {
name: tc.name.clone(),
arguments: serde_json::from_str(&tc.arguments).unwrap_or_default(),
tool_call_id: Some(tc.id.clone()), // id 있음 (결과 매핑용)
}
}).collect();
(text, calls)
}
format_results() — 결과를 ToolResults로
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
let messages = results.iter().map(|r| ToolResultMessage {
tool_call_id: r.tool_call_id.clone().unwrap_or("unknown"),
content: r.output.clone(),
}).collect();
// role: tool 메시지로 전달 (API 레벨 tool result)
ConversationMessage::ToolResults(messages)
}
to_provider_messages() — history 직렬화
// AssistantToolCalls → JSON payload (tool_calls + reasoning_content 포함)
ConversationMessage::AssistantToolCalls { text, tool_calls, reasoning_content } => {
let payload = json!({
"content": text,
"tool_calls": tool_calls,
// thinking 모델의 reasoning_content도 보존
"reasoning_content": reasoning_content,
});
vec![ChatMessage::assistant(payload.to_string())]
}
// ToolResults → role: tool 메시지 (각각 별도 메시지)
ConversationMessage::ToolResults(results) =>
results.iter().map(|r| {
ChatMessage::tool(json!({
"tool_call_id": r.tool_call_id,
"content": r.content,
}).to_string())
}).collect()
should_send_tool_specs() → true
tool_specs 배열을 API 요청의 tools 필드에 포함한다.
두 dispatcher 비교
| | XmlToolDispatcher | NativeToolDispatcher |
|--|---|---|
| 대상 | 로컬 LLM (llama.cpp, Qwen) | Claude, OpenAI |
| tool 노출 방식 | system prompt 텍스트 | API tools 배열 |
| LLM 응답 형식 | <tool_call> 텍스트 | tool_calls 배열 |
| tool_call_id | None | Some("tc1") |
| 결과 메시지 | user 메시지 (XML) | tool 메시지 (JSON) |
| reasoning_content | 무시 (텍스트만) | 보존 (pass-through) |
| should_send_tool_specs | false | true |
선택 로직 (agent.rs)
let tool_dispatcher: Box<dyn ToolDispatcher> = match config.agent.tool_dispatcher.as_str() {
"native" => Box::new(NativeToolDispatcher),
"xml" => Box::new(XmlToolDispatcher),
// 명시 안 하면 provider 능력 기반 자동 선택
_ => if provider.supports_native_tools() {
Box::new(NativeToolDispatcher)
} else {
Box::new(XmlToolDispatcher)
}
};
config.toml에서 [agent] tool_dispatcher = "xml" 로 강제 지정도 가능.
agent loop에서의 실제 흐름
turn() 내부:
1. history → provider messages 변환
dispatcher.to_provider_messages(&self.history)
2. LLM 호출
provider.chat(ChatRequest {
messages,
tools: if dispatcher.should_send_tool_specs() {
Some(&tool_specs) // Native: 전달 / XML: None
} else { None }
})
3. 응답 파싱
let (text, calls) = dispatcher.parse_response(&response);
4-a. calls 없음 → 최종 응답 반환
4-b. calls 있음 → 도구 실행
let results = execute_tools(&calls).await;
self.history.push(ConversationMessage::AssistantToolCalls { tool_calls, ... });
5. 결과 history에 추가
let formatted = dispatcher.format_results(&results);
self.history.push(formatted);
→ loop 계속
XML 방식의 trade-off
장점:
- native tool calling을 지원 안 하는 모든 LLM에서 동작
- API 형식 맞출 필요 없음
단점:
- LLM이
<tool_call>태그 형식을 정확히 따라야 함 - JSON이 잘못 생성되면 파싱 실패 (malformed JSON 경고 후 스킵)
- tool_call_id 없어서 병렬 실행 결과 매핑이 애매
Qwen 특이사항:
<think>...</think> 태그 자동 제거. Qwen3 같은 reasoning 모델이 chain-of-thought를 인라인으로 출력하는데, tool call 파싱 전에 이 블록을 제거해야 태그 오파싱이 안 생김.
관련
- agent-loop — turn() 내부에서 dispatcher 사용
- tool-system — Tool trait + 레지스트리
- provider-implementations — supports_native_tools() 플래그
- overview — ZeroClaw 학습 지도