research

ZeroClaw — Tool Dispatcher

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 파싱 전에 이 블록을 제거해야 태그 오파싱이 안 생김.


관련