dex-agents-google-adk
dex-agents-google-adk (import dex_agents_google_adk) runs
Google ADK agents as durable Dex agent
sessions. It implements the AgentFrameworkRunner contract of
dex-agents and registers the shared agent session Flow
for an ADK agent. One Flow execution is one session, and the Flow ID is both
the Dex session ID and the ADK session ID.
The session runs in Step mode. Each RunModel Step lets ADK make one
model call. Every tool call the model asks for, READ included, pauses the ADK
invocation through ADK's own tool confirmation and runs in its own Dex Step;
the tool calls of one response run in parallel. Each call therefore has its
own retry policy, failure route, history entry, and Dex Web node, and a crash
repeats at most the one call that was running. The plan is
agent-framework-adapters.md.
Installation
The package needs Python 3.11 or later. It requires dex-agents at the same
version, dex-python-sdk==1.45.0, and google-adk 2.13 or a later 2.x
release:
pip install dex-agents-google-adk
Develop the package inside this repository with uv 0.12.2. There it resolves
dex-agents from ../core by path through [tool.uv.sources]; the published
wheel requires dex-agents==<same version> from PyPI instead:
cd agents/python/google-adk
uv sync --locked
DEXCLI=/path/to/dexcli ./ci.sh
Running an agent
import asyncio
from dex import AsyncClient, AsyncWorker, BlobCacheConfig, ClientOptions, Registry
from dex import WorkerOptions, open_blob_cache
from dex_agents import AgentSessionClient, ToolEffect, ToolPolicy
from dex_agents_google_adk import adk_agent_session_flow, idempotency_key_for_tool_call
from google.adk.agents import LlmAgent
from google.adk.tools.tool_context import ToolContext
def search_orders(customer_id: str) -> list[dict[str, str]]:
"""List a customer's orders."""
return [{"order_id": "7", "status": "delivered"}]
def refund_order(order_id: str, tool_context: ToolContext) -> str:
"""Refund one order."""
key = idempotency_key_for_tool_call(tool_context)
return f"refund for order {order_id} created with key {key}"
def send_email(to: str, body: str) -> str:
"""Send an email to the customer."""
return f"sent to {to}"
agent = LlmAgent(
name="support",
model="gemini-2.5-flash",
instruction="Help customers with their orders.",
tools=[search_orders, refund_order, send_email],
)
flow = adk_agent_session_flow(
agent,
flow_type="SupportAgent",
tool_policies={
"search_orders": ToolPolicy(ToolEffect.READ),
"refund_order": ToolPolicy(ToolEffect.IDEMPOTENT_WRITE, requires_approval=True),
# send_email has no policy: unclassified, it runs once after approval.
},
)
async def start_support_session(sessions: AgentSessionClient) -> None:
await sessions.start_session("session-42", "Refund my last order, I am customer 9")
async def approve_pending_tool_calls(sessions: AgentSessionClient) -> None:
snapshot = await sessions.get_agent_snapshot("session-42")
for interrupt in snapshot.pending_interrupts:
await sessions.resolve_interrupt("session-42", interrupt.interrupt_id, approved=True)
async def main() -> None:
registry = Registry([flow], allow_async_handlers=True)
cache = open_blob_cache(BlobCacheConfig(".dex-cache", 64 * 1024 * 1024))
worker = AsyncWorker(registry, cache, WorkerOptions(server_address="localhost:8801"))
client = AsyncClient(registry, cache, ClientOptions(server_address="localhost:8801"))
serving = asyncio.create_task(worker.start())
await worker.wait_pull_registered(timeout=30)
sessions = AgentSessionClient(client, flow)
await start_support_session(sessions)
# Later, for example from an approval UI:
await approve_pending_tool_calls(sessions)
await serving
asyncio.run(main())
Build the Flow once per agent, with a Flow type unique within the Registry,
and build the same Flow in every process that starts sessions or calls their
RPCs. The Steps are async, so the Registry needs allow_async_handlers=True
and an AsyncWorker serves it. Tool calls of one model response run as
parallel Steps, so give the Worker enough task slots
(PullWorkerOptions.max_concurrent_tasks) for the largest batch. The model
reads its key from the environment as in any ADK application, such as
GOOGLE_API_KEY.
adk_agent_session_flow(agent, *, flow_type, ...) takes:
| Argument | Default | Meaning |
|---|---|---|
agent |
required | Root BaseAgent, or an ADK App that carries it with plugins and compaction or context cache configuration. |
flow_type |
required | Flow type of the agent's sessions. Keep it stable. |
tool_policies |
none | ToolPolicy by tool name. |
default_tool_effect |
UNCLASSIFIED |
Effect of a tool without a policy or ADK metadata. |
interrupt_deadline |
none | How long an approval may stay pending. |
event_stream_capacity_bytes |
10 MiB | Byte budget of the AgentEvents Stream. |
run_config |
RunConfig(streaming_mode=StreamingMode.SSE) |
ADK run configuration of every run. |
user_id |
"user" |
ADK user ID of every session. |
artifact_service, memory_service, credential_service |
none | ADK services, owned by the application. |
AdkAgentRunner takes the same agent and ADK arguments when you build
AgentSessionFlow yourself.
Step shape
A turn whose model reads once and then answers runs these Steps:
AwaitUserMessage -> RunModel -> CallTool -> ResumeTurn -> RunModel -> AwaitUserMessage
A response with several tool calls starts one Step per call, in parallel. A
call that needs approval waits in AwaitToolApproval first. See the
core README for every Step.
Effects and policies
| Effect | Step | Retry | Crash mid-call | Default approval |
|---|---|---|---|---|
read |
CallTool |
Up to 3 attempts; then failed |
Runs again | No |
idempotent_write |
CallTool |
Up to 3 attempts, key <flowId>/<turnId>/<toolCallId>; then outcome_unknown |
Runs again with the same key | No |
non_idempotent_write |
CallSingleAttemptTool |
One attempt; then outcome_unknown |
Outcome unknown | Yes |
unclassified |
CallSingleAttemptTool |
One attempt; then outcome_unknown |
Outcome unknown | Yes |
A tool's effect comes from, in order:
- Its
ToolPolicyintool_policies. - ADK metadata:
load_artifacts,load_memory, andpreload_memoryareread. An MCP tool withreadOnlyHintisread; withidempotentHintit isidempotent_write. default_tool_effect.
A tool built with ADK's FunctionTool(func, require_confirmation=...) always
waits for approval when its require_confirmation is true for the call, even
when its policy says otherwise.
idempotency_key_for_tool_call(tool_context) returns a call's idempotency key,
"<flow ID>/<turn ID>/<tool call ID>". It is the core's
DeferredToolExecution.idempotency_key, which the adapter passes to the tool
in tool_context.custom_metadata under IDEMPOTENCY_KEY_CUSTOM_METADATA_KEY.
Every attempt of an idempotent_write call gets the same key; pass it to the
external system.
ADK control tools
Some ADK tools steer the invocation itself, so they run inside RunModel and
get no Step: transfer_to_agent, exit_loop, set_model_response,
finish_task, and the tools ADK makes for sub-agents in single_turn or
task mode. The tool calls of those sub-agents get Steps as usual. A model
response that calls a control tool together with other tools fails the turn
before any tool runs, because ADK would apply the control tool before the
other results exist.
Tool sources and connector tools
tool_sources takes the core's tool sources, such as ConnectorToolSource,
which offers reviewed Connector operations (install dex-agents[connectors]).
See Connector tools
for what the manifest decides.
from dex_agents.connectors import ConnectorToolSource, connector_tool
stripe = new_project_connection(project, "billing-stripe")
flow = adk_agent_session_flow(
agent,
flow_type="SupportAgent",
tool_sources=[
ConnectorToolSource([
connector_tool(stripe.operations.create_refund(), stripe.reference),
]),
],
)
- ADK resumes a confirmed call only with one of the agent's own tools, so
the runner adds one toolset to the
toolsof everyLlmAgentof the tree when it is built. EachRunModellists every source, and the toolset offers that listing, with the source's name, description, and JSON Schema. Outside the runner'sRunModelit lists nothing, so the agents behave as before. The agent'sbefore_tool_callbackchain still sees each call first. - Every call of a source tool defers, reads included, and runs once in its own
tool Step through the source's
execute_deferred_tool_call, never inside the framework. A connector call derives its Call ID from the Step execution ID and keeps its fence marker in the Step's heartbeat checkpoint. - The source builds each call, so it decides the effect class, the approval,
and whether the call is an ordered write;
tool_call_step_optionsgives the tool Step the operation's timeout and retry policy. Atool_policiesentry for a connector tool changes only its approval. - Writes of one model response run one at a time in the model's order, as in a
built-in loop agent. Once one does not succeed, the later ones record
failedwithskipped. - Definition fails with
ValueErrorwhen two sources share asource_name, or a source'sdeclared_tool_namesrepeat a tool, an agent name, or an ADK control tool of the tree, or another source's tool. The same names listed at run time fail thatRunModelattempt. - The runner polls no tool tasks. A source call that starts one records
failedfor areadcall andoutcome_unknownotherwise. A source whose listing fails or takes longer thantool_source_listing_timeoutoffers no tools in that model call. A tool the turn already called but the listing lacks is replay-only: the resumed run replays its recorded call from the source'scached_tools(), and a new call of it is refused inside the framework (the tool stays offered so ADK can resume its recorded call, and the plugin answers the refusal: " did not run: its tool source does not offer this tool now."), so it never reaches a tool Step. - A recorded result replays once, for the call that paused, and only under the
tool name it was recorded for; a later call that reuses its tool call ID
fails the
RunModelattempt instead of reading that result. A tool that a toolset, such as an MCP server, lists at run time under a source tool's name fails theRunModelattempt before the model is called.
How a tool call runs
The adapter adds a plugin, after the App's own plugins, to a copy of the App
for every RunModel attempt. For each tool call except a control tool, the
plugin resolves the policy, then runs the calling agent's
before_tool_callback chain, so a guardrail callback can still answer the
call without a Step. Otherwise it calls tool_context.request_confirmation,
and ADK pauses the invocation on an adk_request_confirmation call once the
response's calls are all paused. RunModel returns ToolCallsRequested with
the turn's events so far as the framework run state.
Each tool Step calls execute_deferred_tool_call, which finds the first tool
with the call's name in a depth-first walk of the agent tree and calls its
run_async once. The next RunModel answers each adk_request_confirmation
call, one message per ADK branch, with confirmed set to whether the call was
approved. ADK re-executes the confirmed calls, and the plugin returns the
recorded result instead of running the tool:
| Record status | Function response the model sees |
|---|---|
succeeded |
The tool's response. A bare string result is {"result": "<text>"}. |
failed |
{"error": "The tool call failed: <error>"}, where <error> is the ToolExecutionError message, the failure of a read whose attempts ran out, or TOOL_CALL_NOT_RUN_ERROR_PREFIX, ": ", and the failure for a call whose tool never ran |
rejected |
{"error": "<rejection message>"} |
outcome_unknown |
{"error": "<OUTCOME_UNKNOWN_TOOL_RESULT>"} |
Raise dex_agents.ToolExecutionError from a tool to report a failure the
model should see without a retry; the record becomes failed. Any other
exception, a timeout, or a Worker crash fails the tool Step: a read or
idempotent_write call retries, and once its attempts run out the record
becomes failed for a read and outcome_unknown for a write. A
single-attempt call records outcome_unknown at once. When the recovery
error's attempt_progress is NOT_STARTED, the tool never ran, and the call
records failed with TOOL_CALL_NOT_RUN_ERROR_PREFIX instead, so the model
may call the tool again.
When a turn fails after some of its tool calls finished, the session stores a
turn-failure note through build_user_message: an ADK user event whose text
lists each call and its outcome. The next turn's model sees it as user text,
so it does not repeat those writes.
Multi-agent trees
The adapter runs the App as resumable (ResumabilityConfig(is_resumable=True)),
so a paused SequentialAgent, LoopAgent, ParallelAgent, or sub-agent
continues where it stopped. A task-mode sub-agent that asked the user a
question receives the user's later messages, because the adapter lets ADK
append the turn's user message and replays stored user messages with the
isolation scope ADK gave them.
The next RunModel answers the paused calls of each ADK branch in its own
message, and ADK runs on once the first branch finishes. That is safe only
when every branch is an LlmAgent directly under a root ParallelAgent, so a
model step whose tool calls span several branches anywhere else fails the turn
before any tool runs: for example a ParallelAgent inside a SequentialAgent
or LoopAgent, a root ParallelAgent whose branch is a SequentialAgent or
LoopAgent, or two single_turn sub-agents called in one response. After every resume, each
answered call must have reached its agent with the recorded result; otherwise
the turn fails and the session stores the turn-failure note.
Sub-agents in single_turn or task mode need a root LlmAgent, because ADK
runs them only there; construction fails otherwise.
An LlmAgent makes one model call per RunModel. Agents that continue
without tools make their model calls in the same RunModel: the next agent of
a SequentialAgent, the target of transfer_to_agent, a sub-agent in
single_turn or task mode, and the other branches of a ParallelAgent. A
RunModel retry repeats those calls too.
A tool Step finds its tool by name, so different tools in different agents
must have different names. Construction fails when two agents declare
different tools with one name; a toolset's tools are checked when they are
called. An AgentTool runs its whole agent inside one tool Step, with the
App's plugins but without Steps for its inner calls. A read AgentTool whose
agent has a tool that is not read fails the turn, because a read Step can
retry and repeat those writes; give such an AgentTool a write effect.
History and live events
Each RunModel rebuilds the ADK session from the stored messages: every
message's framework_item is one ADK Event as JSON, in the format ADK's own
database session services store. Session state is the replay of the events'
state deltas. A completed turn stores every event ADK appended, as if ADK had
run each tool call directly: it leaves out the adk_request_confirmation
calls, their answers, the placeholder responses, and content-less
resumability markers. The display fields project each event:
| Event | role |
Tool fields |
|---|---|---|
| User text | user |
none |
| Model text or function calls | assistant |
Set when the event has one function call. |
| Function responses | tool |
Set when the event has one function response. |
With the default RunConfig, model text streams to AgentEvents as
text_delta events, and provider thought summaries as reasoning_delta
events. Control tools write tool_call_started and tool_call_finished; the
session writes them for every tool Step. Every event carries turn_id,
step_execution_id, and attempt; group live text by them as the
core README describes. Usage comes from each response's
usage metadata: input_tokens counts prompt and tool-use prompt tokens,
output_tokens counts candidate and thought tokens, and model_calls counts
model requests.
Images
Since 0.5.0 a session can show images to the model. Pass a media store, such
as S3MediaStore or LocalDirectoryMediaStore, to adk_agent_session_flow
or AdkAgentRunner, the same store in every Worker and every client that
uploads images:
from dex_agents.s3 import S3MediaStore
store = S3MediaStore("agent-media", "sessions")
flow = adk_agent_session_flow(agent, flow_type="SupportAgent", media_store=store)
reference = await store.put_image(Path("chart.png").read_bytes())
await sessions.send_user_message("session-42", "What does this chart show?", images=[reference])
Without a store the runner has no build_user_message_with_images, so
send_user_message refuses images, and tool result images are ignored.
- What the model sees. A user message is one user
Content: the text part, then onetypes.Part(inline_data=types.Blob(...))per image, in order, with the reference's MIME type. A tool result's images go in itsFunctionResponse.parts, asFunctionResponsePartinline data after the JSONresponse, which ADK passes to Gemini and LiteLLM models natively. - What the session stores. Stored events and the paused turn's run state
hold only placeholders: a
file_datapart whosefile_uriis the reference'suri. The runner swaps them for the bytes in each model request, from its own plugin'sbefore_model_callback, after the application's plugins run and before the agents'before_model_callback. A failed store read fails the model call, whichRunModelretries. Otherfile_dataparts, such asgs://URIs, reach the model unchanged. - Tool results. A tool source returns
ToolExecutionResult(images=[...]). An ADK tool returns imagetypes.Partvalues withinline_data, found where ADK finds tool media; the tool Step uploads them withput_image, so the record holds references. An image thatput_imagerefuses, such as one overMAX_IMAGE_BYTES, fails the tool Step. The tool message stores the record's images inimages. Other media a tool returns, such as audio, afile_datapart, or a Part nested deeper than ADK looks, is encoded into the output text as before. - Callbacks. When the next model call replays a result with images, the
result dict that
after_tool_callbacks receive also holds a__dex_images__entry with the placeholder Parts. ADK moves them intoFunctionResponse.parts; a callback that returns a new dict without that entry drops the images from the model input.
Tracing
With tracing on in AgentSessionOptions.tracing, each RunModel returns
ModelCallMetadata for its chat span, taken from the plugin's model
callbacks:
| Field | Source |
|---|---|
request_model |
LlmRequest.model, when every model call of the RunModel requested the same model |
response_model |
LlmResponse.model_version of the complete responses, for a single model call |
finish_reasons |
The last reported LlmResponse.finish_reason, lowercased as ADK's telemetry does, for a single model call |
provider_name |
Never set. ADK names no provider for a call; its telemetry guesses one from the model name, and a custom BaseLlm can serve any model |
response_id |
Never set. LlmResponse does not carry the provider's response ID |
A RunModel whose agents continue without tools makes several model calls,
so its span is INTERNAL and carries only a shared request model. The
responses of parallel branches interleave, so the adapter cannot tell which
call each one answered.
Testing
ci.sh installs the locked dependencies, runs ruff, the format check, and
mypy, and runs the integration tests against private dexcli dev --engine zd
engines. It reads DEXCLI. The tests use real ADK agents, driven by scripted
BaseLlm models in tests/, so no model key is needed. They assert
each turn's Step history with dex_agents.testing.read_step_history. A Worker
child runs in its own process group, so a test can SIGKILL it during
RunModel or a tool Step.
tests/test_connector_tools.py runs connector tools from a copy of the core
tests' fake ledger Connector (tests/fake_ledger): a Query in its tool Step,
an approved Mutation that runs once, a session policy that changes only
approval, a retry: safe Mutation retried with the same Call ID, uncertain
writes recorded outcome_unknown, two writes of one response in the model's
order, an unknown or rejected write that skips the later write, a withdrawn
tool whose new call is refused, a reused call ID, the operation's tool Step
options, an unexposed operation, refused name collisions, and a run-time
toolset name collision.
tests/test_images.py sends a user image and returns tool images through a
LocalDirectoryMediaStore, checks the exact bytes in the recorded model
requests, kills the Worker during a model call and checks that the rerun
request is identical, and checks that stored state holds no image bytes.
Compaction
Since 0.3.0 the runner can compact a session that would outgrow the model's
context window. Pass compaction=CompactionPolicy(context_window_tokens=...) to adk_agent_session_flow, and optionally summary_model; the summary call uses the model of the first LlmAgent of the tree. The summary replaces the summarized events in the rebuilt ADK session. Without a policy nothing changes, and a session
started before the upgrade starts compacting once its Worker has one.
- It compacts only between turns, before the first model call of a turn. A model call that continues a turn after its tool calls does not compact, so one turn's growth must fit in the rest of the window. Lower the trigger ratio when a turn can add more than about 15 percent of the window.
- The summary enters the model input as a user message before the kept turns. It is never stored as a message, and every message stays readable until retention deletes it behind the summary.
- The estimate counts three bytes of each stored message as one token, plus the instructions and tool definitions the runner can see. Set the policy's reserved input tokens for the rest, such as dynamic instructions.
- The summary call gets the earlier turns as plain text, without tools.
- ADK's
EventsCompactionConfigadds compaction events that ADK's own request builder reads. Dex stores them as events and owns the record; use one of the two. - The rebuilt session must not lose what the summarized events set. The
summary therefore ends with a line,
[Dex] ADK session state: {...}, that carries the session state and the agent that replied last. The runner restores both and keeps that line out of the model input. A task-mode sub-agent that waits for the user across turns blocks compaction until it finishes, because ADK finds that task from its events.
See Compaction and the storage bound.
Limits
- The App root must be a
BaseAgent. ADKWorkflowroots are not supported. - A tool runs outside the ADK run, with a fresh
ToolContextwhose session has no events and empty state. Its state and artifact changes are discarded. Pass what it needs as arguments. - A tool that asks for a confirmation or credential inside its Step fails with
ToolExecutionError. - A turn fails when ADK pauses for something other than a tool call, such as a credential request.
- Agents that continue without tools make their model calls in the same
RunModel, as described above. - Agent
before_tool_callbacks run in theRunModelthat requests the call, before its Step, and again if thatRunModelretries. When the turn resumes, the plugin answers first, so they do not run then;after_tool_callbacks see both the placeholder and the recorded response, as with ADK's own confirmations. - Code executors other than
BuiltInCodeExecutorrun model-written code insideRunModel, or inside anAgentTool's Step, where a retry would run it again, so construction fails. Run such code from a tool instead. - A model step whose tool calls span several ADK branches fails the turn,
except for
LlmAgentbranches of a rootParallelAgent, as described above. - An application plugin's
on_user_message_callbackmay rewrite the message ADK appends for the turn; the session still stores and replays the original text. app:anduser:state keys live in the Dex session, not across sessions.- The adapter never closes the agent's toolsets or plugins; the application owns them.
- The core's limits apply: each model call and tool call adds a Step round trip. See Compaction for long sessions.
Metadata
Release files for dex-agents-google-adk 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| dex_agents_google_adk-0.5.0.tar.gz | 48.3 kB | Without provenance | Details for dex_agents_google_adk-0.5.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| dex_agents_google_adk-0.5.0-py3-none-any.whl | Python 3 | none | any | Details for dex_agents_google_adk-0.5.0-py3-none-any.whl |
Total release size: 95.4 kB
Release files / dex_agents_google_adk-0.5.0.tar.gz
| Download URL | dex_agents_google_adk-0.5.0.tar.gz |
|---|---|
| Size | 48.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5279c83faaa158a5409f2c552123c132ba9513c1d50642949041e914f932b2bf
|
|
BLAKE2b-256 checksum How to use checksums |
75e7707b7c7b05e64bf32dc5defd4b8022f6ff2c3bde8b733abea7ea467d928b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / dex_agents_google_adk-0.5.0-py3-none-any.whl
| Download URL | dex_agents_google_adk-0.5.0-py3-none-any.whl |
|---|---|
| Size | 47.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7864e63e8302663c7f2640b6c493ecfa16957ecfc993756535dd7589df4a9922
|
|
BLAKE2b-256 checksum How to use checksums |
16cf3c12a520d0bc4e96ab3ec217f2260ae18521590aaf6adae55274b2bc9fab
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for dex-agents-google-adk 0.5.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.