dex-agents-claude-agent-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ # License
2
+
3
+ Copyright (c) 2026 Super Durable, Inc.
4
+
5
+ Portions of this software are licensed as follows:
6
+
7
+ - Legacy Materials remain under their original licenses as described in
8
+ `LEGACY_NOTICES.md`.
9
+ - Third-Party Materials remain under the licenses provided by their owners.
10
+ - The `docs/` and `examples/` directories are not licensed under the
11
+ Sustainable Use License and retain their existing licensing status.
12
+ - All other material owned or licensable by Super Durable, Inc. is available
13
+ under the Sustainable Use License below.
14
+
15
+ See `LEGACY_NOTICES.md` for the applicable legacy and third-party notices.
16
+
17
+ ## Sustainable Use License
18
+
19
+ Version 1.0
20
+
21
+ ### Acceptance
22
+
23
+ By using the software, you agree to all of the terms and conditions below.
24
+
25
+ ### Copyright License
26
+
27
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
28
+ non-sublicensable, non-transferable license to use, copy, distribute, make
29
+ available, and prepare derivative works of the software, in each case subject
30
+ to the limitations below.
31
+
32
+ ### Limitations
33
+
34
+ You may use or modify the software only for your own internal business purposes
35
+ or for non-commercial or personal use. You may distribute the software or
36
+ provide it to others only if you do so free of charge for non-commercial
37
+ purposes. You may not alter, remove, or obscure any licensing, copyright, or
38
+ other notices of the licensor in the software. Any use of the licensor's
39
+ trademarks is subject to applicable law.
40
+
41
+ ### Patents
42
+
43
+ The licensor grants you a license, under any patent claims the licensor can
44
+ license, or becomes able to license, to make, have made, use, sell, offer for
45
+ sale, import and have imported the software, in each case subject to the
46
+ limitations and conditions in this license. This license does not cover any
47
+ patent claims that you cause to be infringed by modifications or additions to
48
+ the software.
49
+
50
+ If you or your company make any written claim that the software infringes or
51
+ contributes to infringement of any patent, your patent license for the software
52
+ granted under these terms ends immediately. If your company makes such a claim,
53
+ your patent license ends immediately for work on behalf of your company.
54
+
55
+ ### Notices
56
+
57
+ You must ensure that anyone who gets a copy of any part of the software from
58
+ you also gets a copy of these terms. If you modify the software, you must
59
+ include in any modified copies of the software a prominent notice stating that
60
+ you have modified the software.
61
+
62
+ ### No Other Rights
63
+
64
+ These terms do not imply any licenses other than those expressly granted in
65
+ these terms.
66
+
67
+ ### Termination
68
+
69
+ If you use the software in violation of these terms, such use is not licensed,
70
+ and your license will automatically terminate. If the licensor provides you
71
+ with a notice of your violation, and you cease all violation of this license no
72
+ later than 30 days after you receive that notice, your license will be
73
+ reinstated retroactively. However, if you violate these terms after such
74
+ reinstatement, any additional violation of these terms will cause your license
75
+ to terminate automatically and permanently.
76
+
77
+ ### No Liability
78
+
79
+ As far as the law allows, the software comes as is, without any warranty or
80
+ condition, and the licensor will not be liable to you for any damages arising
81
+ out of these terms or the use or nature of the software, under any kind of
82
+ legal claim.
83
+
84
+ ### Definitions
85
+
86
+ The "licensor" is the entity offering these terms.
87
+
88
+ The "software" is the software the licensor makes available under these terms,
89
+ including any portion of it.
90
+
91
+ "You" refers to the individual or entity agreeing to these terms.
92
+
93
+ "Your company" is any legal entity, sole proprietorship, or other kind of
94
+ organization that you work for, plus all organizations that have control over,
95
+ are under the control of, or are under common control with that organization.
96
+ Control means ownership of substantially all the assets of an entity, or the
97
+ power to direct its management and policies by vote, contract, or otherwise.
98
+ Control can be direct or indirect.
99
+
100
+ "Your license" is the license granted to you for the software under these
101
+ terms.
102
+
103
+ "Use" means anything you do with the software requiring your license.
104
+
105
+ "Trademark" means trademarks, service marks, and similar rights.
@@ -0,0 +1,476 @@
1
+ Metadata-Version: 2.4
2
+ Name: dex-agents-claude-agent-sdk
3
+ Version: 0.1.0
4
+ Summary: Durable Claude Agent SDK sessions on Dex
5
+ Author: Super Durable
6
+ License-Expression: LicenseRef-Sustainable-Use-1.0
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Typing :: Typed
12
+ Requires-Dist: claude-agent-sdk>=0.2.176,<0.3
13
+ Requires-Dist: dex-agents==0.1.0
14
+ Requires-Dist: dex-python-sdk==1.30.0
15
+ Requires-Dist: jsonschema>=4,<5
16
+ Requires-Dist: mcp>=2.3,<3
17
+ Requires-Python: >=3.11
18
+ Project-URL: Homepage, https://github.com/superdurable/dex/tree/main/agents/python/claude-agent-sdk
19
+ Project-URL: Repository, https://github.com/superdurable/dex
20
+ Project-URL: Changelog, https://github.com/superdurable/dex/blob/main/agents/CHANGELOG.md
21
+ Description-Content-Type: text/markdown
22
+
23
+ # dex-agents-claude-agent-sdk
24
+
25
+ `dex-agents-claude-agent-sdk` (import `dex_agents_claude_agent_sdk`) runs an
26
+ agent built with the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python)
27
+ as a durable Dex agent session. It implements the
28
+ [`dex-agents` runner contract](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md#the-runner-contract) and
29
+ registers the shared agent session Flow, so a session keeps its conversation,
30
+ approvals, and tool results across Worker restarts and waits for approval for
31
+ as long as it takes.
32
+
33
+ The Claude Agent SDK runs its agent loop in a Claude Code CLI subprocess that
34
+ cannot stop after one model call, so this adapter is the plan's **whole-turn
35
+ harness** ([agent framework adapters plan](https://github.com/superdurable/dex/blob/main/docs/design/plan/agent-framework-adapters.md)).
36
+ One `RunModel` Step runs a turn segment: the CLI makes the segment's model
37
+ calls and inline tool calls itself, until it answers or defers a tool call.
38
+ The session stores the CLI's transcript entries in Dex state, and the next
39
+ segment or turn resumes the Claude session from them on any Worker. A deferred
40
+ custom tool call runs in its own `CallTool` or `CallSingleAttemptTool` Step,
41
+ after `AwaitToolApproval` when needed, and `ResumeTurn` returns to `RunModel`.
42
+ Tools that run inside the CLI wait for approval but cannot be recorded.
43
+
44
+ ## Installation
45
+
46
+ The package needs Python 3.11 or later. It requires `dex-agents` at the same
47
+ version, the released `dex-python-sdk==1.30.0`, `claude-agent-sdk>=0.2.176,<0.3`,
48
+ and `mcp>=2.3,<3`. `uv.lock` pins `claude-agent-sdk` 0.2.176, which bundles
49
+ Claude Code CLI 2.1.316:
50
+
51
+ ```bash
52
+ pip install dex-agents-claude-agent-sdk
53
+ ```
54
+
55
+ Develop the package inside this repository with uv 0.12.2. There it resolves
56
+ `dex-agents` from `../core` by path through `[tool.uv.sources]`; the published
57
+ wheel requires `dex-agents==<same version>` from PyPI instead:
58
+
59
+ ```bash
60
+ cd agents/python/claude-agent-sdk
61
+ uv sync --locked
62
+ DEXCLI=/path/to/dexcli ./ci.sh
63
+ ```
64
+
65
+ The Worker needs Claude Code credentials, such as `ANTHROPIC_API_KEY`, exactly
66
+ as a plain Claude Agent SDK program does.
67
+
68
+ ## Running an agent
69
+
70
+ ```python
71
+ import asyncio
72
+ from typing import Any
73
+
74
+ from claude_agent_sdk import ClaudeAgentOptions, ToolAnnotations, tool
75
+ from dex import AsyncClient, AsyncWorker, BlobCacheConfig, ClientOptions, Registry
76
+ from dex import WorkerOptions, open_blob_cache
77
+ from dex_agents import AgentSessionClient, ToolEffect, ToolPolicy
78
+ from dex_agents_claude_agent_sdk import (
79
+ ClaudeAgentOptionsContext,
80
+ claude_agent_session_flow,
81
+ current_deferred_tool_execution,
82
+ )
83
+
84
+
85
+ @tool("lookup_order", "Look up an order", {"order_id": str},
86
+ annotations=ToolAnnotations(readOnlyHint=True))
87
+ async def lookup_order(arguments: dict[str, Any]) -> dict[str, Any]:
88
+ order = await orders.describe(arguments["order_id"])
89
+ return {"content": [{"type": "text", "text": order}]}
90
+
91
+
92
+ @tool("create_refund", "Refund an order", {"order_id": str})
93
+ async def create_refund(arguments: dict[str, Any]) -> dict[str, Any]:
94
+ execution = current_deferred_tool_execution()
95
+ refund = await payments.refund(
96
+ arguments["order_id"],
97
+ idempotency_key=execution.idempotency_key if execution else None,
98
+ )
99
+ return {"content": [{"type": "text", "text": f"refunded {refund.id}"}]}
100
+
101
+
102
+ def build_options(context: ClaudeAgentOptionsContext) -> ClaudeAgentOptions:
103
+ return ClaudeAgentOptions(
104
+ model="claude-sonnet-5",
105
+ system_prompt="You resolve order problems.",
106
+ tools=["Read", "Grep", "Bash"],
107
+ cwd=f"/srv/agents/{context.flow_id}",
108
+ setting_sources=[],
109
+ )
110
+
111
+
112
+ flow = claude_agent_session_flow(
113
+ build_options,
114
+ [lookup_order, create_refund],
115
+ flow_type="SupportAgent",
116
+ tool_policies={
117
+ "create_refund": ToolPolicy(ToolEffect.NON_IDEMPOTENT_WRITE),
118
+ "Read": ToolPolicy(ToolEffect.READ),
119
+ "Grep": ToolPolicy(ToolEffect.READ),
120
+ },
121
+ )
122
+
123
+
124
+ async def main() -> None:
125
+ registry = Registry([flow], allow_async_handlers=True)
126
+ cache = open_blob_cache(BlobCacheConfig(".dex-cache", 64 * 1024 * 1024))
127
+ worker = AsyncWorker(registry, cache, WorkerOptions(server_address="localhost:8801"))
128
+ client = AsyncClient(registry, cache, ClientOptions(server_address="localhost:8801"))
129
+ serving = asyncio.create_task(worker.start())
130
+ await worker.wait_pull_registered(timeout=30)
131
+
132
+ sessions = AgentSessionClient(client, flow)
133
+ await sessions.start_session("session-42", "Refund order 7 if it arrived broken")
134
+ snapshot = await sessions.get_agent_snapshot("session-42")
135
+ for interrupt in snapshot.pending_interrupts:
136
+ await sessions.resolve_interrupt("session-42", interrupt.interrupt_id, approved=True)
137
+
138
+ await worker.stop()
139
+ await serving
140
+ await client.close()
141
+
142
+
143
+ asyncio.run(main())
144
+ ```
145
+
146
+ In this agent `lookup_order`, `Read`, and `Grep` run inside the turn.
147
+ `create_refund` waits for `resolve_interrupt` and then runs once in its own
148
+ Step. `Bash` has no policy, so the default effect, `unclassified`, makes every
149
+ call wait for approval; after approval it runs inside the resumed segment.
150
+
151
+ `claude_agent_session_flow` is a shortcut for
152
+ `AgentSessionFlow(ClaudeAgentRunner(options_factory, custom_tools), flow_type=..., options=AgentSessionOptions(...))`.
153
+ Use `ClaudeAgentRunner` directly to pass your own `AgentSessionOptions`.
154
+ Custom tool names must be unique and must not be a Claude Code tool name
155
+ (`CLAUDE_CODE_BUILT_IN_TOOL_NAMES`, such as `Bash`, `Write`, or `WebFetch`);
156
+ the constructor raises `ValueError` otherwise.
157
+
158
+ ## The options factory
159
+
160
+ The factory receives `ClaudeAgentOptionsContext(flow_id, turn_id, attempt)`
161
+ and returns your `ClaudeAgentOptions`: model, system prompt, `cwd`, built-in
162
+ `tools`, `disallowed_tools`, external `mcp_servers`, `env`, your own `hooks`,
163
+ `permission_mode`, and so on. It runs on the Worker for every `RunModel`
164
+ attempt, so keep it deterministic for a Flow ID. A resumed segment must see
165
+ the same working directory, tools, and model.
166
+
167
+ The adapter adds to the options:
168
+
169
+ | Option | Value |
170
+ | --- | --- |
171
+ | `mcp_servers["tools"]` | An in-process MCP server with the custom tools, built by the adapter so it can read each call's tool use ID. Change the name with `custom_tool_server_name`. |
172
+ | `hooks["PreToolUse"]` | One more matcher for every tool: the adapter's policy decision. |
173
+ | `can_use_tool` | Denies a deferrable call that Claude Code did not defer; see Limits. |
174
+ | `session_store` | Serves the committed transcript and collects the segment's entries. |
175
+ | `resume` | The stored Claude session ID, from the second segment of a session on. |
176
+ | `include_partial_messages` | `True`, for live text. |
177
+ | `extra_args["await-initialize"]` | The CLI waits for the SDK's initialize request, so the hooks exist before a resumed call runs. |
178
+
179
+ A turn attempt fails with `ValueError` when the factory sets an option the
180
+ adapter owns (`can_use_tool`, `permission_prompt_tool_name`, `session_store`,
181
+ `resume`, `session_id`, `continue_conversation`, `fork_session`,
182
+ `resume_session_at`, `resume_drops_turn`, `enable_file_checkpointing`, or
183
+ `extra_args` for `await-initialize`, `mcp-config`, `resume`, or `session-id`), sets
184
+ `permission_mode` to `bypassPermissions`, `acceptEdits`, or `auto`, passes
185
+ `mcp_servers` as a path or JSON string, reuses the custom tool server name, or
186
+ lists a tool in `allowed_tools` whose calls must defer. Those settings would let
187
+ a tool run without its approval or Step. `permission_mode` unset (the CLI's
188
+ `default`), `default`, and `dontAsk` all work; see "What the real CLI does".
189
+
190
+ Pass `setting_sources=[]` unless you need settings files. Allow rules in
191
+ `~/.claude/settings.json` or project settings can approve a tool before the
192
+ adapter is asked, and the adapter cannot see them.
193
+
194
+ ## Effects and policies
195
+
196
+ `ToolPolicy` and `default_tool_effect` work as in the
197
+ [core](https://github.com/superdurable/dex/blob/main/agents/python/core/README.md#effects-and-policies). Policies use a custom tool's
198
+ own name (`create_refund`, not `mcp__tools__create_refund`), a built-in name
199
+ (`Bash`), or `mcp__<server>__<tool>` for a tool of an external MCP server.
200
+ Without a policy, a custom tool's MCP annotations classify it:
201
+ `readOnlyHint=True` is `read` and `idempotentHint=True` is `idempotent_write`.
202
+
203
+ | Effect | Custom tool | Tool inside the CLI | Default approval |
204
+ | --- | --- | --- | --- |
205
+ | `read` | Runs inside `RunModel` | Runs inside `RunModel` | No |
206
+ | `idempotent_write` | `CallTool`, up to 3 attempts, key `<flowId>/<turnId>/<toolCallId>`; then `outcome_unknown` | Runs inside `RunModel`, at least once | No |
207
+ | `non_idempotent_write` | `CallSingleAttemptTool`, one attempt; then `outcome_unknown` | Waits in `AwaitToolApproval`, then runs inside the resumed `RunModel`, at least once | Yes |
208
+ | `unclassified` | Same as `non_idempotent_write` | Same as `non_idempotent_write` | Yes |
209
+
210
+ A policy with `requires_approval` adds `AwaitToolApproval` before the Step; an
211
+ approved custom `read` call then runs in `CallTool`. Custom `read` calls without
212
+ approval run inside the CLI turn, unlike the core's Step mode, because every
213
+ deferral restarts the CLI: one more process, one more transcript load, and one
214
+ more model call. Reads are safe to repeat when `RunModel` retries. A tool inside
215
+ the CLI with an approval-free write policy also runs inside the turn, because
216
+ deferring it would not make it durable.
217
+
218
+ A custom tool's handler receives only its arguments. While it runs in a tool
219
+ Step, `current_deferred_tool_execution()` returns the `DeferredToolExecution`
220
+ with the attempt and the idempotency key; inline READ calls get `None`. The Step
221
+ validates the arguments against the tool's input schema first; a mismatch
222
+ records `failed` with "Input validation error: ..." and the handler never runs.
223
+ A handler result with `is_error=True` records `failed` and the model sees its
224
+ text. An exception fails the Step attempt: an `idempotent_write` call retries,
225
+ and once its attempts run out, or at once for a single-attempt call, the
226
+ session records `outcome_unknown`, because the write may have taken effect.
227
+
228
+ ## How a turn runs
229
+
230
+ 1. `build_user_message` stores the prompt as
231
+ `{"kind": "user_prompt", "text": ...}`.
232
+ 2. `RunModel` opens a `ClaudeSDKClient` with the prompt. From the second turn on
233
+ it sets `resume` to the stored Claude session ID; the SDK loads the stored
234
+ entries from the adapter's `SessionStore`, writes them to a temporary
235
+ `CLAUDE_CONFIG_DIR`, and the CLI resumes from that file. The CLI mirrors
236
+ every transcript line it writes back to the `SessionStore`. When earlier
237
+ turns failed, their stored prompts and turn-failure notes come after the
238
+ last stored transcript, so no CLI session has seen them; the prompt is then
239
+ one text block per stored text, oldest first, ending with this turn's
240
+ prompt.
241
+ 3. The PreToolUse hook decides each call:
242
+ - A custom `read` call without approval, or an approval-free CLI tool call:
243
+ `allow`.
244
+ - The first other custom tool call of the segment, or the first CLI tool
245
+ call that needs approval: `defer`. The CLI ends the segment with
246
+ `stop_reason: "tool_deferred"` and the call in
247
+ `ResultMessage.deferred_tool_use`. The runner returns
248
+ `ToolCallsRequested` with that one call, the session ID, and the turn's
249
+ entries so far as `FrameworkRunState`. The call's arguments are the
250
+ CLI's recorded input, after every PreToolUse hook's `updatedInput`.
251
+ - Every later deferrable call of the same segment: `deny` with
252
+ "<tool> did not run. Only one tool call per reply can run outside this
253
+ turn, and another call already does. Call <tool> again by itself." The
254
+ CLI resumes only one deferred call, so a second one would never get a
255
+ result. The model calls it again in a later reply.
256
+ 4. The session runs the call in `AwaitToolApproval` and `CallTool` or
257
+ `CallSingleAttemptTool`. For a custom tool that runs the handler; for a CLI
258
+ tool it records `APPROVED_CLI_TOOL_OUTPUT`. `ResumeTurn` then starts
259
+ `RunModel` again.
260
+ 5. The resumed `RunModel` opens the session **without a prompt** and keeps
261
+ stdin open. The CLI finds the pending `hook_deferred_tool` entry, runs
262
+ PreToolUse for that call again, and the hook answers from the final record:
263
+ a custom tool is `allow`, and the adapter's MCP server returns the recorded
264
+ output, failure, unknown outcome, or rejection text without running the
265
+ tool; an approved CLI tool is `allow`, and the CLI runs it; a rejected CLI
266
+ tool is `deny` with the rejection text. If the CLI skips the hook and asks
267
+ `can_use_tool` instead, the adapter answers from the record the same way;
268
+ under `dontAsk` the CLI does not ask, so `--await-initialize` must prevent
269
+ the skip. The CLI then adds a hidden "Continue from where you left off."
270
+ message and makes the next model call.
271
+ 6. When the CLI produces the final answer, the runner returns `TurnCompleted`.
272
+ Each assistant API message and each tool result becomes one stored message.
273
+ Its `framework_item` is
274
+ `{"kind": "claude_transcript", "session_id": ..., "entries": [...]}`, and
275
+ other entries, such as the CLI's copy of the prompt, the deferral attachment,
276
+ and the hidden continuation, ride along with the next message. The entries
277
+ of all messages, in sequence order, are the Claude transcript.
278
+
279
+ The adapter's MCP server matches a call to its permission decision by
280
+ `_meta["claudecode/toolUseId"]`, which the CLI sends on every `tools/call`, so
281
+ an application hook that rewrites the input cannot make a recorded write look
282
+ new. A call without a decision for that ID does not run.
283
+
284
+ Usage comes from `ResultMessage.usage` per segment: input tokens include cache
285
+ reads and cache writes, `cached_input_tokens` are cache reads, and
286
+ `model_calls` counts distinct assistant message IDs. Text and thinking deltas
287
+ of the main conversation stream to `AgentEvents` as `text_delta` and
288
+ `reasoning_delta`. Inline tool calls write `tool_call_started` and
289
+ `tool_call_finished`; the session writes them for deferred calls. Group live
290
+ text by `(turn_id, step_execution_id, attempt)` as the core README describes:
291
+ a retried segment has a higher `attempt` under the same `step_execution_id`.
292
+
293
+ ## What the real CLI does
294
+
295
+ `tests/test_real_claude_cli.py` runs the bundled Claude Code CLI 2.1.316
296
+ against a local scripted Messages API and settles the behavior other
297
+ implementations, such as the TypeScript adapter, must match:
298
+
299
+ - **Resume sends no prompt.** With `--resume` of a transcript that ends in a
300
+ pending `hook_deferred_tool` entry, the CLI resumes the deferred call on
301
+ startup without any user message. It reruns PreToolUse, runs or denies the
302
+ tool, and appends an `isMeta` user entry "Continue from where you left off."
303
+ before the next model call. A host must keep stdin open until the result.
304
+ Sending an empty prompt (`content: []`) as well starts a second turn and a
305
+ spurious extra model call; the Python SDK's `query()` with no prompt closes
306
+ stdin too early, so the adapter uses `ClaudeSDKClient`.
307
+ - **`permissionMode` needs no pinning.** Defer and resume work with the mode
308
+ unset (the CLI reports `default`), `default`, and `dontAsk`. A mode that
309
+ differs between the deferral and the resume still resumes; the CLI only logs
310
+ a warning. The adapter passes the application's mode through and rejects the
311
+ modes that skip it.
312
+ - **One reply can defer several calls.** The CLI writes one assistant message
313
+ per content block, so its solo check sees one tool use and honors every
314
+ `defer`. The result names only the last deferred call, and a resume reruns
315
+ only that one. The adapter therefore defers one call per segment.
316
+ - **`updatedInput` applies before the deferral.** The CLI waits for every
317
+ PreToolUse hook, applies `updatedInput`, and records that input in the
318
+ deferral and `deferred_tool_use.input`. Hooks receive the original input.
319
+ - **Tool use IDs reach MCP servers.** Every `tools/call` carries
320
+ `_meta["claudecode/toolUseId"]`.
321
+ - **A hook denial reaches the model with a prefix,**
322
+ `PreToolUse:<tool> hook error: <reason>`. A rejected custom tool call is
323
+ therefore allowed and the adapter's MCP server returns the rejection, so the
324
+ model sees exactly the rejection text. A rejected CLI tool keeps the prefix.
325
+ - **The resumed call can start before the hooks exist.** The TypeScript adapter
326
+ saw the CLI skip PreToolUse for the resumed call and ask `canUseTool` in two
327
+ of three runs without `--await-initialize`. Four Python runs without the flag
328
+ did not skip, but the adapter passes `--await-initialize` and answers
329
+ `can_use_tool` from the record too. That fallback helps only under the
330
+ `default` mode: under `dontAsk` the CLI denies a call itself when no hook
331
+ decides, without asking `can_use_tool`, so `--await-initialize` is what
332
+ prevents the skip there. A real-CLI test resumes four times in one session.
333
+ - **The in-process MCP server must be declared at startup.** The Python SDK
334
+ passes it in `--mcp-config` as `{"type": "sdk", "name": ...}` and serves it
335
+ over the control protocol, so its tools exist when the resumed call runs.
336
+
337
+ ## When a turn fails
338
+
339
+ `RunModel` retries a failed segment up to three times. After the last attempt
340
+ the session records `last_turn_error`. When the turn already has final tool
341
+ call records, for example a write that ran before the resumed segment failed,
342
+ the session stores a turn-failure note built with `build_user_message`:
343
+
344
+ ```text
345
+ [Dex] The previous turn failed after these tool calls ran:
346
+ - publish {"message": "draft"} -> succeeded: published draft
347
+ Do not repeat these tool calls unless the user asks again.
348
+ ```
349
+
350
+ The failed turn's transcript is not stored, so the next turn starts from the
351
+ last completed turn's Claude session and sends the failed turn's prompt, the
352
+ note, and its own prompt as three text blocks. The CLI records them, and the
353
+ model sees what already ran.
354
+
355
+ ## What is and is not recorded
356
+
357
+ Recorded in Dex state, never repeated after a crash:
358
+
359
+ - The transcript of every completed turn, and the entries of a suspended turn.
360
+ - Every deferred custom tool call and its outcome: output text, failure,
361
+ rejection, or unknown outcome.
362
+ - Approvals and rejections of every deferred call, including CLI tools.
363
+
364
+ Not recorded, and repeated when `RunModel` reruns a segment after a crash:
365
+
366
+ - Model calls and inline `read` tool calls.
367
+ - Tools that run inside the CLI, such as `Bash`, `Write`, `Edit`, and `Task`,
368
+ including approved ones. They are at least once.
369
+ - Files the CLI changes in `cwd`. Point `cwd` at storage that survives the
370
+ Worker, such as a sandbox owned by the session, for file-editing agents.
371
+ - Subagent transcripts. Resume needs only the main transcript.
372
+
373
+ ## Limits
374
+
375
+ - One tool call per segment leaves the turn. Later deferrable calls of the same
376
+ segment are denied and the model calls them again, which costs one more
377
+ segment and model call each. Deferred calls of one reply therefore never run
378
+ as parallel Steps.
379
+ - If Claude Code ever ignores a defer and asks `can_use_tool` instead, the
380
+ adapter denies the call with the same "call it again by itself" message.
381
+ Claude Code allows some read-only built-in tools, such as `Read`, `Glob`, and
382
+ `Grep`, without asking, so such a tool would then run without approval. Give
383
+ those tools `read` policies, or remove them with `disallowed_tools` if they
384
+ must wait for approval.
385
+ - A deferrable call inside a subagent is denied with a message that asks the
386
+ model to call it from the main conversation.
387
+ - A turn segment must finish within `RunModel`'s 15 minute timeout.
388
+ - Recorded custom tool results keep text only; other content becomes a
389
+ placeholder such as `[image content omitted]`.
390
+ - The adapter's PreToolUse matcher runs beside your own PreToolUse hooks. Your
391
+ hooks may rewrite input with `updatedInput`, but must not return permission
392
+ decisions for tools the adapter defers.
393
+ - `CLAUDE_CODE_BUILT_IN_TOOL_NAMES` lists the CLI 2.1 tool names. If a newer
394
+ CLI adds a tool whose name a custom tool uses, the hook denies that call and
395
+ the segment fails with an error naming the collision.
396
+ - On the first turn of a session Claude Code also writes its usual local
397
+ transcript under `CLAUDE_CONFIG_DIR` (`~/.claude` by default). The adapter
398
+ never reads it; set `CLAUDE_CONFIG_DIR` in `env` to choose where it goes.
399
+ Later segments run in a temporary directory that the SDK removes.
400
+ - The stored transcript grows with every turn and is never compacted by the
401
+ adapter; Claude Code's own compaction entries are kept.
402
+ - The real-CLI tests use a scripted Messages API, not a real model, and the
403
+ bundled CLI version in `uv.lock`. A different CLI version may change the
404
+ behavior in "What the real CLI does"; rerun those tests when upgrading.
405
+
406
+ ## Testing
407
+
408
+ `ci.sh` installs the locked dependencies, checks lint, formatting, and types,
409
+ and runs the integration tests against a private `dexcli dev --engine zd`
410
+ engine started by `dex_agents.testing`. It reads `DEXCLI`. No test calls a
411
+ model or the Anthropic API.
412
+
413
+ `tests/test_real_claude_cli.py` runs the bundled Claude Code CLI against
414
+ `tests/mock_messages_api.py`, a scripted Messages API on 127.0.0.1 that streams
415
+ SSE replies. Each CLI gets `ANTHROPIC_BASE_URL` set to the mock, a dummy
416
+ `ANTHROPIC_API_KEY`, a temporary `HOME` and `CLAUDE_CONFIG_DIR`,
417
+ `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, and `HTTPS_PROXY` pointing at a
418
+ refused local port, so nothing reads the user's `~/.claude` or reaches the
419
+ network. The test removes inherited `CLAUDE*` and `ANTHROPIC*` variables first
420
+ and asserts that every request reached `/v1/messages` with the dummy key.
421
+
422
+ The other tests use `tests/fake_claude_cli.py`, a scripted CLI that the SDK
423
+ starts through `cli_path`. It copies the real CLI's observed behavior and
424
+ speaks this subset of the SDK's stream-json protocol:
425
+
426
+ | Direction | Message |
427
+ | --- | --- |
428
+ | SDK to CLI | `cli -v`, the version check. |
429
+ | SDK to CLI | Arguments `--output-format stream-json --input-format stream-json`, `--resume=<id>`, `--mcp-config <json>`, `--permission-prompt-tool stdio`, `--permission-mode`, `--session-mirror`, `--include-partial-messages`; `CLAUDE_CONFIG_DIR` in the environment. |
430
+ | SDK to CLI | `control_request` `initialize` with `hooks.PreToolUse[].hookCallbackIds`; the CLI answers `control_response` `success`. |
431
+ | SDK to CLI | One `user` prompt for a new segment. None for a resumed deferred call: the CLI resumes it on startup. Stdin closes after the result. |
432
+ | CLI to SDK | `control_request` `mcp_message` with JSON-RPC `initialize`, `notifications/initialized`, `tools/list`, and `tools/call` carrying `_meta["claudecode/toolUseId"]`; the SDK answers `{"mcp_response": ...}`. |
433
+ | CLI to SDK | `control_request` `hook_callback` for every matching PreToolUse callback at once (`tool_name`, `tool_input`, `tool_use_id`); `updatedInput` applies before the decision, and `deny` beats `defer`, `ask`, and `allow`. |
434
+ | CLI to SDK | `control_request` `can_use_tool` when no hook allowed, denied, or deferred the call, or for a resumed call when `FAKE_CLAUDE_RESUME_SKIPS_HOOKS` is set; the SDK answers `behavior` `allow` or `deny`. A hook denial becomes the tool result `PreToolUse:<tool> hook error: <reason>`. |
435
+ | CLI to SDK | `system` `init`, `stream_event` `content_block_delta` text deltas, one `assistant` message per content block, `user` tool results, and `result` with `usage`, or with `stop_reason: "tool_deferred"` and `deferred_tool_use` naming the last deferred call. |
436
+ | CLI to SDK | `transcript_mirror` frames `{filePath, entries}` for every transcript line. |
437
+ | Transcript | `user`, `assistant`, and `attachment` `hook_deferred_tool` entries with `uuid` and `parentUuid`, and an `isMeta` "Continue from where you left off." entry after a resumed call. A resumed CLI reruns PreToolUse for the last deferral that has no tool result. |
438
+
439
+ The scripted models read commands from the prompt (`say`, `recall`, `call`,
440
+ `stall`, `fail`); see the module docstrings. The suite covers the plan's
441
+ matrix: a READ tool and history replay through a resumed session, an approved
442
+ write that survives a Worker restart and runs once, a rejected write, a
443
+ repeated `resolve_interrupt`, SIGKILL during a single-attempt write, SIGKILL
444
+ during `RunModel`, and the snapshot after a turn. It also covers the Step
445
+ sequence of a deferred write, idempotent retries with one key, an idempotent
446
+ write whose attempts run out, tool error results, input validation in the
447
+ Step, approved and rejected `Bash`, a READ and a write in one reply, two writes
448
+ in one reply, an input-rewriting application hook, a hook that rewrites every
449
+ call including the resumed one, custom tools named like
450
+ Claude Code tools, a resumed call that skips the hook, SIGKILL during a resumed
451
+ segment, a CLI error result, and a resumed turn that fails after a write and
452
+ leaves a note for the next prompt. The real-CLI tests cover a resume across a
453
+ Worker restart, four resumes in one session including a plain-text rejection,
454
+ and two writes in one reply under `dontAsk`.
455
+
456
+ ## Notes for other languages
457
+
458
+ The stored formats are a wire contract, so a session can move between Workers
459
+ written in different languages:
460
+
461
+ - User message and turn-failure note `framework_item`: `{"kind": "user_prompt", "text": "..."}`.
462
+ - A first segment sends every `user_prompt` text after the last
463
+ `claude_transcript` message, oldest first: a plain string for one text, one
464
+ text block per text otherwise. A resumed segment sends no prompt.
465
+ - Turn message `framework_item`: `{"kind": "claude_transcript", "session_id": "<uuid>", "entries": [...]}`
466
+ with the CLI's transcript entries unchanged.
467
+ - `FrameworkRunState`: `{"version": 1, "session_id": "<uuid>", "turn_entries": [...]}`.
468
+ - Records name custom tools by their own name and CLI tools by their CLI name.
469
+ Custom tool names must not be Claude Code tool names.
470
+ - An approved CLI tool's record output is `APPROVED_CLI_TOOL_OUTPUT`.
471
+ - A deferred call's `arguments` are `deferred_tool_use.input`, the CLI's input
472
+ after every hook's `updatedInput`.
473
+ - Match MCP calls to decisions by `_meta["claudecode/toolUseId"]`, defer at most
474
+ one call per segment, and deny later deferrable calls with the same message.
475
+ - Pass `--await-initialize`, answer `can_use_tool` from the record for a
476
+ resumed call, and return a custom tool's rejection through the tool.