실시간 세션의 도구

실시간 에이전트의 결정적인 특징(음성 봇과 달리)은 대화 도중에 실제 작업을 수행할 수 있다는 점입니다: 무언가를 찾아보거나, 환불을 처리하거나, 사람에게 넘긴 다음 결과를 말할 수 있습니다. 도구는 서버 측에서 실행되므로, 비즈니스 로직과 자격 증명은 클라이언트에 전혀 닿지 않습니다.

도구 턴의 흐름

  1. 모델이 도구가 필요하다고 판단하고 FunctionCallDone { name, arguments, call_id }를 내보냅니다.
  2. RealtimeRunnername의 핸들러를 찾아 실행합니다.
  3. 핸들러의 JSON 결과가 도구 출력으로 모델에 다시 전송됩니다.
  4. 러너가 한 번의 후속 응답을 트리거합니다. 모델은 결과를 바탕으로 답을 말합니다.

이때 create_response()를 직접 호출할 필요는 없습니다. auto_respond_tools가 켜져 있을 때(기본값) 러너가 왕복 처리를 담당합니다.

네이티브 도구: ToolDefinition + FnToolHandler

가벼운 방식입니다. ToolDefinition는 모델이 보는 JSON 스키마이고, FnToolHandler는 호출될 때 실행되는 동기 클로저입니다.

use adk_realtime::config::ToolDefinition;
use adk_realtime::events::ToolCall;
use adk_realtime::runner::FnToolHandler;
use serde_json::json;

fn process_refund_def() -> ToolDefinition {
    ToolDefinition {
        name: "process_refund".into(),
        description: Some("Issue a refund for an order. Only when clearly warranted.".into()),
        parameters: Some(json!({
            "type": "object",
            "properties": {
                "order_id": { "type": "string", "description": "e.g. 'A-10293'" },
                "reason":   { "type": "string", "description": "Short reason" }
            },
            "required": ["order_id", "reason"]
        })),
    }
}

fn process_refund_tool()
-> FnToolHandler<impl Fn(&ToolCall) -> adk_realtime::error::Result<serde_json::Value> + Send + Sync> {
    FnToolHandler::new(|call: &ToolCall| {
        let order = call.arguments.get("order_id").and_then(|v| v.as_str()).unwrap_or("unknown");
        // …do the work…
        Ok(json!({ "status": "approved", "order_id": order,
                   "message": format!("Refund approved for {order}.") }))
    })
}

빌더에 .tool(definition, handler)로 등록합니다:

let runner = IntegratedRealtimeRunner::builder()
    .model(model)
    .config(config)
    .identity("support", "customer", &session_id)
    .session_service(sessions)
    .tool(process_refund_def(), process_refund_tool())
    .tool(connect_to_human_def(), connect_to_human_tool())
    .build()?;

핸들러는 serde_json::Value를 반환합니다. 반환하는 값이 그대로 모델에 보이므로, 에이전트가 바꿔 말할 수 있도록 사람이 읽을 수 있는 message를 포함하세요.

핸들러는 이벤트 루프 안에서 서버 측에서 동기적으로 실행됩니다. 빠르게 끝내세요. 느린 작업은 "started" 상태를 반환하고, 별도의 경로로 후속 처리하세요.

브리지 도구: 어떤 adk_core::Tool

이미 adk-core 도구(자체 FunctionTool, 또는 지식 그래프 remember/relate 같은 adk-tool 내장 기능)가 있다면, .adk_tool(...)로 그대로 연결하세요. 다시 작성할 필요가 없습니다. 통합 계층이 각 도구를 ToolHandler로 감싸고, 세션의 (app_name, user_id, session_id)에 범위가 지정된 ToolContext를 생성합니다:

use adk_tool::{RememberTool, RelateTool};

let runner = IntegratedRealtimeRunner::builder()
    .model(model).config(config).identity("app", "user", &sid)
    .memory_service(kg.clone())
    .adk_tool(Arc::new(RememberTool::new(kg.clone())))   // adk_core::Tool
    .adk_tool(Arc::new(RelateTool::new(kg)))
    .tool(get_weather_def(), get_weather())              // native handler — mix freely
    .build()?;

이것이 에이전트가 자신의 메모리를 관리하는 방식입니다. 브리지는 로컬에서 실행되고 문맥에 의존하지 않는 도구에 잘 맞습니다. 풍부한 에이전트 상태가 필요한 도구는 네이티브 FnToolHandler로 작성하는 편이 더 좋습니다.

병렬 도구 호출

모델은 한 번의 응답에서 여러 도구를 요청할 수 있습니다(예: "런던의 날씨와 시간은?"). ADK-Rust는 이를 올바르게 처리합니다. 각 도구의 출력이 완료되는 대로 전송한 다음, 디스패치 응답이 끝나면 정확히 한 번 response.create를 발행합니다.

이 점이 중요한 이유는, 도구마다 응답을 하나씩 보내는 순진한 방식은 OpenAI의 "conversation already has an active response in progress" 오류에 걸려 세션이 멈추기 때문입니다. 러너는 "도구 출력 전송"(send_tool_output)과 "응답 트리거"(respond_after_tools, 디스패치 ResponseDone에서 한 번만 호출)를 분리해 이 문제를 피합니다. 이 부분은 자동으로 처리되므로, 이벤트를 읽을 때 도구 턴이 두 개의 응답에 걸쳐 있다는 점만 알아두면 됩니다 (see Architecture).

UI에서 도구 이벤트 읽기

도구 활동을 표시하려면(예: "Processing refund…" 칩), FunctionCallDone를 확인하세요:

ServerEvent::FunctionCallDone { name, arguments, .. } => {
    // `arguments` is a JSON string of the call args
    ui_show_tool_activity(&name, &arguments);
}

음성 확인은 도구 결과가 후속 응답에 반영된 뒤 TranscriptDelta로 나중에 도착합니다.

동작 확인하기

customer_service 예제는 process_refundconnect_to_human를 연결합니다. realtime_tools 예제는 헤드리스 프로브로, 두 제공자 모두에서 단일 도구, 병렬 도구, 계산기 턴을 실행합니다.

다음: 멀티모달 →

어떤 도구가 관리되는가

IntegratedRealtimeRunner는 도구가 어떻게 등록되었는지에 따라 도구 호출을 라우팅합니다:

등록됨전달적용된 정책
adk_tool(...) — an ADK Tool통합 정책 파이프라인구성된 플러그인, 대화 기록 저장, 도구 이벤트 지속성
네이티브 실시간 핸들러RealtimeRunner 전달없음 — 핸들러는 구성상 신뢰됨

ADK 도구는 이전에 ToolBridgeAdapter를 통해 제공자에 도달했는데, 이는 컨텍스트를 만들고 플러그인, 콜백, 확인 없이 Tool::execute를 호출합니다. 따라서 표준 agent 루프에서 관리되는 도구가 realtime에서는 관리되지 않은 채 실행되었습니다. 이제 native-handler 우회는 모든 것에 대한 기본값이 아니라 명시적인 예외입니다.

Plugin failures fail closed

before_tool_call pipeline이 오류를 반환하면 도구는 거부됩니다:

{ "error": "tool guarded was refused: its before-tool plugin pipeline failed (...). Execution is refused rather than proceeding without policy." }

Important: 이 경로는 이전에는 plugin 오류를 치명적이지 않은 것으로 기록한 뒤 도구를 실행했습니다. Authorization, redaction, 그리고 policy는 before-tool plugin에 있으므로, 망가진 guard는 곧 guard가 없는 상태가 되었습니다.

After-tool plugin 오류는 도구가 이미 실행되었기 때문에 도구 자체의 결과를 그대로 유지합니다.

Direct agent의 Tool callbacks

RealtimeAgent는 standard agent loop와 동일한 contract로 before-tool 및 after-tool callbacks를 적용합니다:

콜백 반환값효과
Ok(None)도구가 실행됨
Ok(Some(content)) from a before callback내용이 결과가 됨; 도구는 실행되지 않음
Err(e) before 콜백에서 발생한 경우오류가 결과가 되고, 도구는 실행되지 않으며, after 콜백은 건너뜁니다
Ok(Some(content)) after 콜백에서 발생한 경우콘텐츠가 도구의 결과를 대체합니다
Err(e) after 콜백에서 발생한 경우오류가 도구의 결과를 대체합니다

콜백의 Content는 제공자가 기대하는 JSON 결과로 변환됩니다: FunctionResponse 부분은 해당 페이로드를 제공하고, 그 외의 모든 것은 result 키 아래에 텍스트를 제공합니다.

중요: 이 계약이 준수되기 전에는 before-callback의 결정이 계산되었지만 버려졌기 때문에, 도구는 거부를 보고하더라도 강제로 실행되었습니다. 즉, 거부를 보고만 하고 실제로는 강제하지 않는 게이트였습니다. after-callback 결과, 오류를 포함해, 는 버려졌습니다.

실시간 도구 컨텍스트

RealtimeAgent에서 호출된 도구는 Runner 아래에서 보게 되는 것과 동일한 기능을 봅니다:

기능출처
user_scopes()상위 호출 컨텍스트
get_secret(name)상위 호출 컨텍스트
shared_state()부모 호출 컨텍스트
search_memory(query)부모의 메모리 서비스
아이덴티티 (app_name, user_id, session_id, branch)부모 호출 컨텍스트

참고: 이들은 이전에 trait 기본값으로 떨어졌습니다 — 빈 scope 목록, None 는 secrets용, 그리고 None 는 shared state용이었습니다 — 그래서 scope 또는 secret을 확인하는 tool은 realtime에서와 Runner 아래에서 다르게 동작했고, 인증되지 않은 호출자와 단순히 scopes를 전달하지 못한 context를 구분할 수 없었습니다.

Tool 동시성

RunnerConfig::max_concurrent_tools (기본값 4)은 한 번에 몇 개의 tool handler가 실행될 수 있는지를 제한합니다. response가 여러 호출을 dispatch하면, runner는 각각을 event loop에 큐에 넣고 permit이 해제되는 대로 실행을 허용합니다:

use adk_realtime::{RealtimeRunner, RunnerConfig};

let runner = RealtimeRunner::builder()
    .model(model)
    .runner_config(RunnerConfig {
        auto_execute_tools: true,
        auto_respond_tools: true,
        max_concurrent_tools: 3,
    })
    .build()?;

두 가지 속성이 뒤따르며, 둘 다 테스트로 검증됩니다:

  • Tool 실행 중에도 event intake는 계속됩니다. Audio delta, transcript, 그리고 interruption은 tool이 실행되는 동안에도 처리됩니다. 세션의 나중 시점에 도착하는 어떤 것을 기다리는 handler는 더 이상 session을 deadlock시키지 않습니다.
  • 마지막 output 이후, follow-up response는 한 번만. tool output이 자동으로 전송될 때, model은 하나의 create_response를 받아야 합니다. 이는 dispatch를 수행한 response가 닫히고 모든 dispatched tool이 보고를 마친 뒤에 한 번 발행되며 — 순서는 상관없습니다. 이제 response가 tools가 아직 실행 중인 동안에도 닫힐 수 있기 때문입니다.

중요: 이 제한은 parallelism이 아니라 concurrency를 다룹니다. handler는 runner의 task를 공유하므로, thread를 block하는 handler — synchronous file 또는 network I/O, heavy computation — 는 여전히 loop를 멈춥니다. 이런 경우에는 tokio::task::spawn_blocking를 사용하십시오.

Disconnect 정책

runner는 자동으로 reconnect하지 않습니다. transport loss가 발생하면 dispatch된 tools가 끝나도록 두고, EventHandler::on_disconnect를 호출한 다음, run에서 반환합니다:

use adk_realtime::{EventHandler, Result};

struct Reconnecting;

#[async_trait::async_trait]
impl EventHandler for Reconnecting {
    async fn on_disconnect(&self) -> Result<()> {
        tracing::warn!("realtime transport ended");
        Ok(())
    }
}

reconnection은 어떤 context를 replay할지 결정해야 하고, Gemini에서는 저장된 resumption token이 여전히 유효한지도 판단해야 하므로 호출자에게 맡겨집니다. on_disconnect hook은 transport loss와 정상적인 close를 구분할 수 있게 하기 위해 존재합니다 — run는 둘 다에 대해 Ok(())를 반환합니다.