실시간 세션의 도구
실시간 에이전트의 결정적인 특징(음성 봇과 달리)은 대화 도중에 실제 작업을 수행할 수 있다는 점입니다: 무언가를 찾아보거나, 환불을 처리하거나, 사람에게 넘긴 다음 결과를 말할 수 있습니다. 도구는 서버 측에서 실행되므로, 비즈니스 로직과 자격 증명은 클라이언트에 전혀 닿지 않습니다.
도구 턴의 흐름
- 모델이 도구가 필요하다고 판단하고
FunctionCallDone { name, arguments, call_id }를 내보냅니다. RealtimeRunner가name의 핸들러를 찾아 실행합니다.- 핸들러의 JSON 결과가 도구 출력으로 모델에 다시 전송됩니다.
- 러너가 한 번의 후속 응답을 트리거합니다. 모델은 결과를 바탕으로 답을 말합니다.
이때 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_refund과 connect_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(())를 반환합니다.