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.
- dex_agents_claude_agent_sdk-0.1.0/LICENSE +105 -0
- dex_agents_claude_agent_sdk-0.1.0/PKG-INFO +476 -0
- dex_agents_claude_agent_sdk-0.1.0/README.md +454 -0
- dex_agents_claude_agent_sdk-0.1.0/pyproject.toml +92 -0
- dex_agents_claude_agent_sdk-0.1.0/pyproject.toml.orig +71 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/__init__.py +43 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/custom_tool_server.py +93 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/py.typed +0 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/runner.py +523 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/session_flow.py +133 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/session_store.py +74 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/tool_gate.py +367 -0
- dex_agents_claude_agent_sdk-0.1.0/src/dex_agents_claude_agent_sdk/transcript.py +271 -0
|
@@ -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.
|