claude-agent-sdk 0.30.0 → 0.32.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +107 -223
- data/docs/cli-installer.md +57 -0
- data/docs/configuration.md +39 -0
- data/docs/errors.md +53 -0
- data/docs/sessions.md +42 -0
- data/docs/types.md +74 -4
- data/lib/claude_agent_sdk/command_builder.rb +42 -0
- data/lib/claude_agent_sdk/errors.rb +147 -0
- data/lib/claude_agent_sdk/message_parser.rb +38 -3
- data/lib/claude_agent_sdk/query.rb +73 -29
- data/lib/claude_agent_sdk/session_resume.rb +223 -33
- data/lib/claude_agent_sdk/sessions.rb +112 -19
- data/lib/claude_agent_sdk/types.rb +215 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +57 -29
- metadata +3 -2
data/docs/configuration.md
CHANGED
|
@@ -78,6 +78,28 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
78
78
|
|
|
79
79
|
When set, the CLI strips per-user dynamic sections (working directory, auto-memory, git status) from the system prompt and re-injects them into the first user message instead. Older CLIs silently ignore this option.
|
|
80
80
|
|
|
81
|
+
### System Prompt Snapshot
|
|
82
|
+
|
|
83
|
+
By default, Claude Code builds the system prompt on a session's first request, records it, and reuses it on every later request, including after you resume the session. A changed custom prompt, or changed `append` text on the `claude_code` preset, then has no effect until the session is compacted or you start a new session. To rebuild the prompt on every request instead, for example while you iterate on its wording, set `snapshot: false` on a `SystemPromptPreset` or on `SystemPromptCustom` (the object form of a String prompt, which exists so `snapshot` can be set alongside it):
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
87
|
+
system_prompt: ClaudeAgentSDK::SystemPromptCustom.new(
|
|
88
|
+
prompt: 'You are a release bot.',
|
|
89
|
+
snapshot: false
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Hash forms work too:
|
|
94
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
95
|
+
system_prompt: { type: 'preset', preset: 'claude_code', append: '...', snapshot: false }
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`snapshot` is sent on the control-protocol `initialize` request (never as a CLI flag), so it applies to both `query()` and `Client`. When omitted it acts as `true`, except in bare mode (`bare: true`), where it acts as `false`. A `SystemPromptFile` has no `snapshot`.
|
|
100
|
+
|
|
101
|
+
Requires Claude Code CLI 2.1.257 or later. Before 2.1.265, a session with an `append` or custom prompt recorded it only when `snapshot` was `true`. See [Modifying system prompts](https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts#change-the-prompt-of-an-existing-session) for details.
|
|
102
|
+
|
|
81
103
|
## Budget Control
|
|
82
104
|
|
|
83
105
|
```ruby
|
|
@@ -223,6 +245,23 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
223
245
|
|
|
224
246
|
See [examples/bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb).
|
|
225
247
|
|
|
248
|
+
## Forwarding Subagent Text
|
|
249
|
+
|
|
250
|
+
By default only `tool_use` / `tool_result` blocks from subagents (spawned via
|
|
251
|
+
the Agent tool) reach the message stream, as `AssistantMessage` /
|
|
252
|
+
`UserMessage` objects whose `parent_tool_use_id` is the spawning Agent
|
|
253
|
+
`tool_use` id — enough for a progress heartbeat. Set `forward_subagent_text`
|
|
254
|
+
to forward the subagent's **text and thinking** blocks the same way, so you
|
|
255
|
+
can render the full nested transcript:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(forward_subagent_text: true)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Matches the TypeScript SDK's `forwardSubagentText`. The capability is
|
|
262
|
+
negotiated on the control-protocol handshake, so it applies to both
|
|
263
|
+
`ClaudeAgentSDK.query` and `Client`.
|
|
264
|
+
|
|
226
265
|
## File Checkpointing & Rewind
|
|
227
266
|
|
|
228
267
|
Enable file checkpointing to revert file changes to a previous state:
|
data/docs/errors.md
CHANGED
|
@@ -31,6 +31,12 @@ rescue ClaudeAgentSDK::ControlRequestTimeoutError
|
|
|
31
31
|
puts "Control protocol timed out — consider increasing the timeout"
|
|
32
32
|
rescue ClaudeAgentSDK::CLINotFoundError
|
|
33
33
|
puts "Please install Claude Code"
|
|
34
|
+
rescue ClaudeAgentSDK::ResultError => e
|
|
35
|
+
# More specific than ProcessError — must be rescued first.
|
|
36
|
+
case e.terminal_reason
|
|
37
|
+
when 'api_error' then puts "API failed (HTTP #{e.api_error_status}): #{e.result}"
|
|
38
|
+
else puts "Run failed (#{e.subtype}): #{e.errors.join('; ')}"
|
|
39
|
+
end
|
|
34
40
|
rescue ClaudeAgentSDK::ProcessError => e
|
|
35
41
|
puts "Process failed with exit code: #{e.exit_code}"
|
|
36
42
|
rescue ClaudeAgentSDK::CLIJSONDecodeError => e
|
|
@@ -38,6 +44,40 @@ rescue ClaudeAgentSDK::CLIJSONDecodeError => e
|
|
|
38
44
|
end
|
|
39
45
|
```
|
|
40
46
|
|
|
47
|
+
## Terminal Error Results
|
|
48
|
+
|
|
49
|
+
When a run fails, the CLI emits a `result` message with `is_error: true` (which
|
|
50
|
+
you still receive as a `ResultMessage`) and *then* exits non-zero on purpose,
|
|
51
|
+
for shell-script consumers. That trailing process failure carries nothing
|
|
52
|
+
beyond "exit code 1", so the SDK replaces it with a `ResultError` carrying the
|
|
53
|
+
payload the CLI already reported — you can branch on *why* the run failed
|
|
54
|
+
without matching on strings:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
begin
|
|
58
|
+
ClaudeAgentSDK.query(prompt: "...") { |m| handle(m) }
|
|
59
|
+
rescue ClaudeAgentSDK::ResultError => e
|
|
60
|
+
retry_later if e.terminal_reason == 'api_error' # overloaded / timeout
|
|
61
|
+
widen_budget if e.subtype == 'error_max_turns'
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`ResultError` subclasses `ProcessError`, so existing `rescue ProcessError`
|
|
67
|
+
handlers keep working unchanged — but rescue `ResultError` **first** if you
|
|
68
|
+
want the structured fields.
|
|
69
|
+
|
|
70
|
+
The exception message prefers, in order: the CLI's `errors[]`, then `result`,
|
|
71
|
+
then a non-`success` `subtype`, then the HTTP status. A run that ends on an API
|
|
72
|
+
failure arrives as `subtype: "success"` with `is_error: true` and the prose in
|
|
73
|
+
`result`, which is why `subtype` alone is not used as the message.
|
|
74
|
+
|
|
75
|
+
A refused resume (a nonexistent session, or a `resume_drops_turn` guard
|
|
76
|
+
failure) reaches you the same way — including on a control request such as the
|
|
77
|
+
initial handshake that was still in flight when the CLI exited. Match on
|
|
78
|
+
`Resume rejected by --resume-drops-turn:` in the message and treat it as
|
|
79
|
+
deterministic: clear the fork target and resume plainly rather than retrying.
|
|
80
|
+
|
|
41
81
|
## Configuring Timeout
|
|
42
82
|
|
|
43
83
|
The control request timeout defaults to **1200 seconds** (20 minutes) to accommodate long-running agent sessions. Override it via environment variable:
|
|
@@ -70,6 +110,19 @@ class ProcessError < ClaudeSDKError
|
|
|
70
110
|
:stderr # String | nil
|
|
71
111
|
end
|
|
72
112
|
|
|
113
|
+
# Raised when the CLI exits after reporting a terminal error result.
|
|
114
|
+
# Subclasses ProcessError, so existing `rescue ProcessError` keeps working.
|
|
115
|
+
class ResultError < ProcessError
|
|
116
|
+
attr_reader :subtype, # String | nil ('error_max_turns', 'error_during_execution', ...)
|
|
117
|
+
:errors, # Array<String> - error strings from the CLI (may be empty)
|
|
118
|
+
:result, # String | nil - result text; holds the "API Error: ..." prose
|
|
119
|
+
:api_error_status, # Integer | nil - HTTP status of the failing API call
|
|
120
|
+
:terminal_reason, # String | nil - why the run ended ('api_error', 'max_turns', ...)
|
|
121
|
+
:session_id, # String | nil
|
|
122
|
+
:data, # Hash - raw `result` payload as emitted by the CLI
|
|
123
|
+
:original_error # ProcessError | nil - the bare exit error this replaced
|
|
124
|
+
end
|
|
125
|
+
|
|
73
126
|
# Raised when JSON parsing fails
|
|
74
127
|
class CLIJSONDecodeError < ClaudeSDKError
|
|
75
128
|
attr_reader :line, # String - The line that failed to parse
|
data/docs/sessions.md
CHANGED
|
@@ -49,6 +49,8 @@ messages = ClaudeAgentSDK.get_subagent_messages(session_id: "uuid-here", agent_i
|
|
|
49
49
|
|
|
50
50
|
With `directory:` given, only that project and its git worktrees are searched (no global fallback). Store-backed counterparts: `list_subagents_from_store` / `get_subagent_messages_from_store`.
|
|
51
51
|
|
|
52
|
+
> Each returned `SessionMessage` carries `parent_tool_use_id` — the id of the Agent `tool_use` block in the parent session that spawned this subagent — and `parent_agent_id`, the spawning subagent's id for nested subagents. Both are read from the `agent-<id>.meta.json` sidecar beside the transcript (or the `agent_metadata` entry in a `SessionStore`), and are `nil` when it is missing or unusable.
|
|
53
|
+
|
|
52
54
|
## Renaming a Session
|
|
53
55
|
|
|
54
56
|
```ruby
|
|
@@ -113,6 +115,46 @@ ClaudeAgentSDK.query(
|
|
|
113
115
|
|
|
114
116
|
`resume_session_at` requires `resume`; the SDK raises `ArgumentError` from `CommandBuilder` when this constraint is violated, matching the underlying CLI's validation but surfacing it synchronously in the caller's stack.
|
|
115
117
|
|
|
118
|
+
### Validating what the truncation discards
|
|
119
|
+
|
|
120
|
+
A bare `resume_session_at` silently drops everything after the fork point —
|
|
121
|
+
including a queued user message or a task notification the session absorbed
|
|
122
|
+
mid-turn that you never observed. `resume_drops_turn` names the user prompt
|
|
123
|
+
whose turn you *intend* to discard, and the CLI refuses the resume if anything
|
|
124
|
+
past the fork point is not attributable to that turn:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
ClaudeAgentSDK.query(
|
|
128
|
+
prompt: 'try a different approach',
|
|
129
|
+
options: ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
130
|
+
resume: session_id,
|
|
131
|
+
resume_session_at: last_kept_entry_uuid,
|
|
132
|
+
resume_drops_turn: discarded_prompt_uuid
|
|
133
|
+
)
|
|
134
|
+
) { |m| handle(m) }
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Rule of thumb: set `resume_session_at` to the **last transcript entry of the
|
|
138
|
+
turn you are keeping** (whatever its type), and `resume_drops_turn` to the
|
|
139
|
+
prompt UUID of the turn immediately after it — the next `SessionMessage` with
|
|
140
|
+
`type == 'user'` from `get_session_messages`, or the `uuid` you supplied on a
|
|
141
|
+
streamed user message.
|
|
142
|
+
|
|
143
|
+
With structured output (`output_format`) or end-turn MCP tools, a kept turn
|
|
144
|
+
ends on entries *after* its last assistant message, so forking at the assistant
|
|
145
|
+
UUID is refused by design.
|
|
146
|
+
|
|
147
|
+
A refusal surfaces as a `ResultError` whose message contains
|
|
148
|
+
`Resume rejected by --resume-drops-turn:`. Treat it as **deterministic** —
|
|
149
|
+
clear the pending fork target and resume plainly; do not retry the same
|
|
150
|
+
request. Leave `resume_drops_turn` unset to keep the unvalidated behavior.
|
|
151
|
+
|
|
152
|
+
Unlike `resume_session_at`, the SDK applies no combination validation to
|
|
153
|
+
`resume_drops_turn` and defers entirely to the CLI. An empty string is
|
|
154
|
+
forwarded rather than dropped, so the CLI rejects it as a malformed
|
|
155
|
+
declaration instead of the SDK silently disarming a guard you believe is
|
|
156
|
+
armed.
|
|
157
|
+
|
|
116
158
|
## Mirroring to a `SessionStore`
|
|
117
159
|
|
|
118
160
|
By default Claude Code writes session transcripts to local disk under
|
data/docs/types.md
CHANGED
|
@@ -6,7 +6,8 @@ See [lib/claude_agent_sdk/types.rb](https://github.com/ya-luotao/claude-agent-sd
|
|
|
6
6
|
|
|
7
7
|
```ruby
|
|
8
8
|
# Union type of all possible messages
|
|
9
|
-
Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage
|
|
9
|
+
Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage |
|
|
10
|
+
StreamEvent | RateLimitEvent | ConversationResetMessage
|
|
10
11
|
```
|
|
11
12
|
|
|
12
13
|
### UserMessage
|
|
@@ -18,7 +19,8 @@ class UserMessage
|
|
|
18
19
|
attr_accessor :content, # String | Array<ContentBlock>
|
|
19
20
|
:uuid, # String | nil - Unique ID for rewind support
|
|
20
21
|
:parent_tool_use_id, # String | nil
|
|
21
|
-
:tool_use_result
|
|
22
|
+
:tool_use_result, # Hash | nil - Tool result data when message is a tool response
|
|
23
|
+
:origin # Hash | nil - message provenance (see Message Origin below)
|
|
22
24
|
end
|
|
23
25
|
```
|
|
24
26
|
|
|
@@ -91,7 +93,8 @@ class ResultMessage
|
|
|
91
93
|
:uuid, # String | nil
|
|
92
94
|
:fast_mode_state, # String | nil ('off', 'cooldown', 'on')
|
|
93
95
|
:api_error_status, # Integer | nil (HTTP status on api_error subtype)
|
|
94
|
-
:terminal_reason
|
|
96
|
+
:terminal_reason, # String | nil (see below)
|
|
97
|
+
:origin # Hash | nil - origin of the triggering user message (see below)
|
|
95
98
|
end
|
|
96
99
|
```
|
|
97
100
|
|
|
@@ -109,6 +112,71 @@ optional `canonicalModel` (canonical id used for the pricing lookup, which can
|
|
|
109
112
|
differ from the raw model-string key for provider-specific ids/aliases) and
|
|
110
113
|
`provider` (`'firstParty'`, `'bedrock'`, `'vertex'`, ...).
|
|
111
114
|
|
|
115
|
+
## Message Origin
|
|
116
|
+
|
|
117
|
+
`UserMessage#origin` and `ResultMessage#origin` carry the provenance of a
|
|
118
|
+
user-role turn. In streaming/`Client` mode one connection interleaves the turns
|
|
119
|
+
your application sends with turns the session injects on its own — background
|
|
120
|
+
task notifications, fired scheduled-task prompts, MCP channel messages,
|
|
121
|
+
messages relayed from peer sessions. `origin` tells them apart:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
if result.origin.nil? || result.origin[:kind] == 'human'
|
|
125
|
+
# a turn this application submitted
|
|
126
|
+
elsif result.origin[:kind] == 'task-notification'
|
|
127
|
+
# follow-up turn driven by a background task
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The Hash is passed through from the CLI **verbatim**, so:
|
|
132
|
+
|
|
133
|
+
- **Keys are Symbols**, and non-`kind` keys keep the CLI's camelCase spelling —
|
|
134
|
+
`origin[:fromSession]`, `origin[:senderTaskId]`, `origin[:verifiedPeerPid]`.
|
|
135
|
+
(The Python SDK's equivalent is string-keyed; do not port `origin["kind"]`
|
|
136
|
+
literally.)
|
|
137
|
+
- Keys this SDK version does not model still reach you, so newer CLI origin
|
|
138
|
+
kinds stay visible.
|
|
139
|
+
- Anything that is not an object with a String `kind` reads as `nil`.
|
|
140
|
+
|
|
141
|
+
Only `kind` is always present. Known kinds — treat anything unrecognized as
|
|
142
|
+
"not human":
|
|
143
|
+
|
|
144
|
+
`human`, `channel`, `peer`, `task-notification`, `coordinator`,
|
|
145
|
+
`unclassified`, `observer`, `auto-continuation`, `observer-activity`
|
|
146
|
+
|
|
147
|
+
For `kind == 'task-notification'`, `origin[:subkind]` may be
|
|
148
|
+
`scheduled-trigger` (a scheduled task's prompt fired) or `peer-send-message`
|
|
149
|
+
(a message from another of your sessions); it is absent for ordinary
|
|
150
|
+
background-task notifications.
|
|
151
|
+
|
|
152
|
+
`nil` means the CLI did not attribute the message. Prompts you send through
|
|
153
|
+
`ClaudeAgentSDK.query` or `Client#query` arrive that way unless you stamp
|
|
154
|
+
`origin: { kind: 'human' }` on the message Hash yourself — only the `human`
|
|
155
|
+
kind is honored from an SDK host. Tool-result messages never carry an origin.
|
|
156
|
+
|
|
157
|
+
### ConversationResetMessage
|
|
158
|
+
|
|
159
|
+
Emitted when the session's conversation is replaced without ending the
|
|
160
|
+
connection — after `/clear`, or any other flow that discards the transcript
|
|
161
|
+
mid-session.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class ConversationResetMessage
|
|
165
|
+
attr_accessor :new_conversation_id, # String - id of the fresh conversation
|
|
166
|
+
:uuid, # String - unique ID of this message
|
|
167
|
+
:session_id # String - the session that was reset
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
A reset clears the conversation history **and zeroes the running totals**
|
|
172
|
+
reported on subsequent `ResultMessage` objects (`total_cost_usd`, and the
|
|
173
|
+
rest). If you accumulate those across a long-lived session, snapshot them when
|
|
174
|
+
this message arrives.
|
|
175
|
+
|
|
176
|
+
`new_conversation_id` is **not** the `session_id` of subsequent messages — it
|
|
177
|
+
is an opaque id for keying an empty transcript in a UI (and for discarding a
|
|
178
|
+
cached session title). Read the new session id from the next message.
|
|
179
|
+
|
|
112
180
|
## Content Block Types
|
|
113
181
|
|
|
114
182
|
```ruby
|
|
@@ -199,7 +267,9 @@ end
|
|
|
199
267
|
| `SandboxSettings` | Sandbox settings for isolated command execution |
|
|
200
268
|
| `SandboxNetworkConfig` | Network configuration for sandbox |
|
|
201
269
|
| `SandboxIgnoreViolations` | Configure which sandbox violations to ignore |
|
|
202
|
-
| `SystemPromptPreset` | System prompt preset configuration |
|
|
270
|
+
| `SystemPromptPreset` | System prompt preset configuration (`preset`, `append`, `exclude_dynamic_sections`, `snapshot`) |
|
|
271
|
+
| `SystemPromptCustom` | Custom system prompt configuration — the object form of a String prompt, so `snapshot` can be set alongside it |
|
|
272
|
+
| `SystemPromptFile` | System prompt loaded from a file path |
|
|
203
273
|
| `ToolsPreset` | Tools preset configuration for base tools selection |
|
|
204
274
|
|
|
205
275
|
## Constants
|
|
@@ -72,6 +72,10 @@ module ClaudeAgentSDK
|
|
|
72
72
|
cmd.push("--system-prompt", @options.system_prompt)
|
|
73
73
|
when SystemPromptFile
|
|
74
74
|
cmd.push("--system-prompt-file", @options.system_prompt.path)
|
|
75
|
+
when SystemPromptCustom
|
|
76
|
+
# The object form of a String prompt; snapshot travels on the
|
|
77
|
+
# initialize request, not as a CLI flag.
|
|
78
|
+
cmd.push("--system-prompt", custom_prompt_text(@options.system_prompt.prompt))
|
|
75
79
|
when SystemPromptPreset
|
|
76
80
|
# Preset activates the default Claude Code system prompt by not passing --system-prompt ""
|
|
77
81
|
# Only --append-system-prompt is passed if append text is provided
|
|
@@ -87,6 +91,9 @@ module ClaudeAgentSDK
|
|
|
87
91
|
when "file"
|
|
88
92
|
prompt_path = prompt_hash[:path] || prompt_hash["path"]
|
|
89
93
|
cmd.push("--system-prompt-file", prompt_path) if prompt_path
|
|
94
|
+
when "custom"
|
|
95
|
+
prompt = prompt_hash.fetch(:prompt) { prompt_hash["prompt"] }
|
|
96
|
+
cmd.push("--system-prompt", custom_prompt_text(prompt))
|
|
90
97
|
when "preset"
|
|
91
98
|
append = prompt_hash[:append] || prompt_hash["append"]
|
|
92
99
|
# Preset activates the default Claude Code system prompt by not passing --system-prompt ""
|
|
@@ -94,6 +101,17 @@ module ClaudeAgentSDK
|
|
|
94
101
|
end
|
|
95
102
|
end
|
|
96
103
|
|
|
104
|
+
# A custom prompt is always forwarded, even when empty (an empty String
|
|
105
|
+
# suppresses the default Claude Code prompt, exactly like a nil
|
|
106
|
+
# system_prompt). A missing prompt is rejected loudly rather than
|
|
107
|
+
# falling through and silently activating the default prompt — the
|
|
108
|
+
# Python SDK raises KeyError on the same input.
|
|
109
|
+
def custom_prompt_text(prompt)
|
|
110
|
+
raise ArgumentError, "system_prompt of type 'custom' requires a :prompt String" unless prompt.is_a?(String)
|
|
111
|
+
|
|
112
|
+
prompt
|
|
113
|
+
end
|
|
114
|
+
|
|
97
115
|
def append_allowed_tools(cmd, allowed_tools)
|
|
98
116
|
cmd.push("--allowedTools", allowed_tools.join(",")) unless allowed_tools.empty?
|
|
99
117
|
end
|
|
@@ -222,6 +240,7 @@ module ClaudeAgentSDK
|
|
|
222
240
|
# flags. The equals form always binds the value to its flag.
|
|
223
241
|
cmd.push("--resume=#{@options.resume}") if @options.resume
|
|
224
242
|
append_resume_session_at(cmd)
|
|
243
|
+
append_resume_drops_turn(cmd)
|
|
225
244
|
cmd.push("--session-id=#{@options.session_id}") if @options.session_id
|
|
226
245
|
end
|
|
227
246
|
|
|
@@ -240,6 +259,29 @@ module ClaudeAgentSDK
|
|
|
240
259
|
cmd.push("--resume-session-at=#{@options.resume_session_at}")
|
|
241
260
|
end
|
|
242
261
|
|
|
262
|
+
# `--resume-drops-turn=<prompt-uuid>` declares, alongside
|
|
263
|
+
# `--resume-session-at`, which user prompt's turn this truncating resume
|
|
264
|
+
# intends to discard. The CLI validates at load time that every transcript
|
|
265
|
+
# entry after the fork point is attributable to that turn and refuses the
|
|
266
|
+
# resume otherwise — so a caller can rewind to "before my last prompt"
|
|
267
|
+
# without silently dropping a queued message or task notification the
|
|
268
|
+
# session absorbed mid-turn that the caller never observed. A refusal
|
|
269
|
+
# surfaces as an `error_during_execution` result whose message starts with
|
|
270
|
+
# `Resume rejected by --resume-drops-turn:`.
|
|
271
|
+
#
|
|
272
|
+
# No SDK-side validation of the option combination (resume /
|
|
273
|
+
# resume_session_at): like the TypeScript and Python SDKs this defers to
|
|
274
|
+
# the CLI.
|
|
275
|
+
def append_resume_drops_turn(cmd)
|
|
276
|
+
# `.nil?`, not truthiness: an empty string is forwarded so the CLI
|
|
277
|
+
# rejects it as a malformed declaration. Dropping it here would silently
|
|
278
|
+
# disarm the guard the caller believes is armed.
|
|
279
|
+
return if @options.resume_drops_turn.nil?
|
|
280
|
+
|
|
281
|
+
# Equals form for the same reason as --resume above.
|
|
282
|
+
cmd.push("--resume-drops-turn=#{@options.resume_drops_turn}")
|
|
283
|
+
end
|
|
284
|
+
|
|
243
285
|
# Sandbox gating is `!nil?` throughout — Python's `sandbox is not None`.
|
|
244
286
|
# Booleans and {} are forwarded verbatim: an explicit `sandbox: false`
|
|
245
287
|
# must reach the CLI so it can override a sandbox enabled in the
|
|
@@ -38,6 +38,153 @@ module ClaudeAgentSDK
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Raised when the CLI exits after reporting a terminal error result.
|
|
42
|
+
#
|
|
43
|
+
# The CLI ends a failed run by emitting a +result+ message with
|
|
44
|
+
# +is_error: true+ (yielded to you as a ResultMessage) and *then* exiting
|
|
45
|
+
# non-zero, on purpose, for shell-script consumers. This exception replaces
|
|
46
|
+
# the bare "exit code 1" ProcessError for that case and carries the
|
|
47
|
+
# result's payload, so callers can branch on *why* the run failed without
|
|
48
|
+
# string matching:
|
|
49
|
+
#
|
|
50
|
+
# begin
|
|
51
|
+
# ClaudeAgentSDK.query(prompt: '...') { |message| ... }
|
|
52
|
+
# rescue ClaudeAgentSDK::ResultError => e
|
|
53
|
+
# if e.terminal_reason == 'api_error' # e.g. overloaded / timeout
|
|
54
|
+
# retry_later
|
|
55
|
+
# elsif e.subtype == 'error_max_turns'
|
|
56
|
+
# ...
|
|
57
|
+
# end
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# It subclasses ProcessError, so existing +rescue ProcessError+ handlers
|
|
61
|
+
# keep working.
|
|
62
|
+
#
|
|
63
|
+
# Every structured field is type-narrowed: a payload whose +subtype+ is not
|
|
64
|
+
# a String (or whose +api_error_status+ is not an Integer, ...) reads back
|
|
65
|
+
# as nil rather than leaking the raw value, so callers can branch on these
|
|
66
|
+
# without re-validating. #data always holds the payload as the CLI sent it.
|
|
67
|
+
class ResultError < ProcessError
|
|
68
|
+
# The result subtype ("error_max_turns", "error_during_execution", ... —
|
|
69
|
+
# or "success" when the agent loop itself completed but the last turn was
|
|
70
|
+
# an API error).
|
|
71
|
+
attr_reader :subtype
|
|
72
|
+
|
|
73
|
+
# Error strings reported by the CLI (may be empty). Normalized the same
|
|
74
|
+
# way the exception text is built, so the two never disagree.
|
|
75
|
+
attr_reader :errors
|
|
76
|
+
|
|
77
|
+
# The result text, if any. For API failures this holds the
|
|
78
|
+
# "API Error: ..." prose.
|
|
79
|
+
attr_reader :result
|
|
80
|
+
|
|
81
|
+
# HTTP status of the failing API call, if any.
|
|
82
|
+
attr_reader :api_error_status
|
|
83
|
+
|
|
84
|
+
# Why the run ended (e.g. "api_error", "max_turns"), if reported.
|
|
85
|
+
attr_reader :terminal_reason
|
|
86
|
+
|
|
87
|
+
# Session the result belongs to, if reported.
|
|
88
|
+
attr_reader :session_id
|
|
89
|
+
|
|
90
|
+
# The raw +result+ message payload as emitted by the CLI.
|
|
91
|
+
attr_reader :data
|
|
92
|
+
|
|
93
|
+
# The ProcessError this replaced (the bare "exit code 1" exit).
|
|
94
|
+
#
|
|
95
|
+
# Ruby only populates #cause for an exception raised inside a rescue
|
|
96
|
+
# block; the read loop hands this one to the message queue instead of
|
|
97
|
+
# raising it there, so #cause is nil and the original exit error would be
|
|
98
|
+
# lost without an explicit accessor. Mirrors Python's __cause__ chaining.
|
|
99
|
+
attr_reader :original_error
|
|
100
|
+
|
|
101
|
+
# Reading a `result` payload: shared by the structured attributes below
|
|
102
|
+
# and by .error_text, which is the whole point — the exception's fields
|
|
103
|
+
# and its message are derived from the same normalization, so they can
|
|
104
|
+
# never disagree. Private to ResultError (Python keeps the equivalent
|
|
105
|
+
# helpers module-private as _normalize_result_errors); callers outside
|
|
106
|
+
# go through .error_text.
|
|
107
|
+
module Payload
|
|
108
|
+
module_function
|
|
109
|
+
|
|
110
|
+
# Normalize the +errors+ field of a +result+ frame to clean strings.
|
|
111
|
+
#
|
|
112
|
+
# The CLI emits an Array of Strings; tolerate a bare String (older or
|
|
113
|
+
# buggy emitters), treat anything else as empty, and drop non-String or
|
|
114
|
+
# blank entries.
|
|
115
|
+
def normalize_errors(raw)
|
|
116
|
+
raw = [raw] if raw.is_a?(String)
|
|
117
|
+
return [] unless raw.is_a?(Array)
|
|
118
|
+
|
|
119
|
+
raw.filter_map { |e| e.strip if e.is_a?(String) && !e.strip.empty? }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Read a payload field, tolerating both key forms.
|
|
123
|
+
#
|
|
124
|
+
# Wire messages reach the SDK with symbolized keys, but a payload
|
|
125
|
+
# reconstructed by a caller (or replayed from JSON.parse without
|
|
126
|
+
# symbolize_names) uses Strings.
|
|
127
|
+
def field(data, key)
|
|
128
|
+
return nil unless data.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
data.key?(key) ? data[key] : data[key.to_s]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_constant :Payload
|
|
134
|
+
|
|
135
|
+
# Pick the most informative text from a `result` frame with is_error.
|
|
136
|
+
#
|
|
137
|
+
# Terminal errors the CLI raises itself (error_max_turns,
|
|
138
|
+
# error_during_execution, ...) carry their prose in errors[]. A run that
|
|
139
|
+
# ends on an API failure instead arrives as subtype "success" with
|
|
140
|
+
# is_error true, an empty errors[] and the "API Error: ..." prose in
|
|
141
|
+
# `result` — falling back to the subtype there produced the self-
|
|
142
|
+
# contradictory "Claude Code returned an error result: success". Prefer
|
|
143
|
+
# errors[], then `result`, then a non-success subtype, then the HTTP
|
|
144
|
+
# status, mirroring the TypeScript SDK's choice of `result` for the
|
|
145
|
+
# `success` subtype.
|
|
146
|
+
#
|
|
147
|
+
# Public because the read loop builds the exception message from it, and
|
|
148
|
+
# because it is the documented way to get the same one-line summary out
|
|
149
|
+
# of a raw error result you already hold (an is_error ResultMessage the
|
|
150
|
+
# CLI emitted before exiting). Mirrors Python's _error_result_text.
|
|
151
|
+
def self.error_text(data)
|
|
152
|
+
errors = Payload.normalize_errors(Payload.field(data, :errors))
|
|
153
|
+
return errors.join('; ') unless errors.empty?
|
|
154
|
+
|
|
155
|
+
result = Payload.field(data, :result)
|
|
156
|
+
return result.strip if result.is_a?(String) && !result.strip.empty?
|
|
157
|
+
|
|
158
|
+
subtype = Payload.field(data, :subtype)
|
|
159
|
+
return subtype if subtype.is_a?(String) && !subtype.empty? && subtype != 'success'
|
|
160
|
+
|
|
161
|
+
status = Payload.field(data, :api_error_status)
|
|
162
|
+
return "API error (HTTP #{status})" unless status.nil?
|
|
163
|
+
|
|
164
|
+
'unknown error'
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def initialize(message, data: nil, exit_code: nil, stderr: nil, original_error: nil)
|
|
168
|
+
data = {} unless data.is_a?(Hash)
|
|
169
|
+
@data = data
|
|
170
|
+
@original_error = original_error
|
|
171
|
+
|
|
172
|
+
subtype = Payload.field(data, :subtype)
|
|
173
|
+
@subtype = subtype.is_a?(String) ? subtype : nil
|
|
174
|
+
@errors = Payload.normalize_errors(Payload.field(data, :errors))
|
|
175
|
+
result = Payload.field(data, :result)
|
|
176
|
+
@result = result.is_a?(String) ? result : nil
|
|
177
|
+
status = Payload.field(data, :api_error_status)
|
|
178
|
+
@api_error_status = status.is_a?(Integer) ? status : nil
|
|
179
|
+
reason = Payload.field(data, :terminal_reason)
|
|
180
|
+
@terminal_reason = reason.is_a?(String) ? reason : nil
|
|
181
|
+
session_id = Payload.field(data, :session_id)
|
|
182
|
+
@session_id = session_id.is_a?(String) ? session_id : nil
|
|
183
|
+
|
|
184
|
+
super(message, exit_code: exit_code, stderr: stderr)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
41
188
|
# Raised when unable to decode JSON from CLI output
|
|
42
189
|
class CLIJSONDecodeError < ClaudeSDKError
|
|
43
190
|
attr_reader :line, :original_error
|
|
@@ -25,6 +25,8 @@ module ClaudeAgentSDK
|
|
|
25
25
|
parse_stream_event(data)
|
|
26
26
|
when 'rate_limit_event'
|
|
27
27
|
parse_rate_limit_event(data)
|
|
28
|
+
when 'conversation_reset'
|
|
29
|
+
parse_conversation_reset_message(data)
|
|
28
30
|
when 'tool_progress'
|
|
29
31
|
parse_tool_progress_message(data)
|
|
30
32
|
when 'auth_status'
|
|
@@ -53,16 +55,32 @@ module ClaudeAgentSDK
|
|
|
53
55
|
content = message_data[:content]
|
|
54
56
|
raise MessageParseError.new("Missing content in user message", data: data) unless content
|
|
55
57
|
|
|
58
|
+
origin = parse_origin(data)
|
|
59
|
+
|
|
56
60
|
if content.is_a?(Array)
|
|
57
61
|
content_blocks = parse_content_blocks(content, data)
|
|
58
62
|
UserMessage.new(content: content_blocks, uuid: uuid, parent_tool_use_id: parent_tool_use_id,
|
|
59
|
-
tool_use_result: tool_use_result)
|
|
63
|
+
tool_use_result: tool_use_result, origin: origin)
|
|
60
64
|
else
|
|
61
65
|
UserMessage.new(content: content, uuid: uuid, parent_tool_use_id: parent_tool_use_id,
|
|
62
|
-
tool_use_result: tool_use_result)
|
|
66
|
+
tool_use_result: tool_use_result, origin: origin)
|
|
63
67
|
end
|
|
64
68
|
end
|
|
65
69
|
|
|
70
|
+
# Returns `data[:origin]` when it is a well-formed origin object.
|
|
71
|
+
#
|
|
72
|
+
# Passed through as-is — including keys and kinds this SDK version doesn't
|
|
73
|
+
# model, and with the wire key spelling untouched (`:fromSession` stays
|
|
74
|
+
# camelCase) — so newer CLI origin kinds/fields stay visible to callers.
|
|
75
|
+
# Anything that is not a Hash with a String `:kind` is treated as absent.
|
|
76
|
+
def self.parse_origin(data)
|
|
77
|
+
origin = data[:origin]
|
|
78
|
+
return origin if origin.is_a?(Hash) && origin[:kind].is_a?(String)
|
|
79
|
+
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
private_class_method :parse_origin
|
|
83
|
+
|
|
66
84
|
def self.parse_assistant_message(data)
|
|
67
85
|
message_data = data[:message]
|
|
68
86
|
# A non-Hash message (malformed CLI output) raised a raw TypeError from
|
|
@@ -124,7 +142,10 @@ module ClaudeAgentSDK
|
|
|
124
142
|
end
|
|
125
143
|
|
|
126
144
|
def self.parse_result_message(data)
|
|
127
|
-
|
|
145
|
+
# `origin` is overwritten with the validated value (or nil) rather than
|
|
146
|
+
# letting the base class assign the raw field: a malformed origin must
|
|
147
|
+
# read as absent, not be surfaced verbatim.
|
|
148
|
+
ResultMessage.new(data.merge(origin: parse_origin(data)))
|
|
128
149
|
end
|
|
129
150
|
|
|
130
151
|
def self.parse_stream_event(data)
|
|
@@ -135,6 +156,20 @@ module ClaudeAgentSDK
|
|
|
135
156
|
RateLimitEvent.new(data.merge(raw_data: data))
|
|
136
157
|
end
|
|
137
158
|
|
|
159
|
+
# `/clear` (or any other mid-session transcript discard) resets the
|
|
160
|
+
# conversation without ending the connection. Every field is required —
|
|
161
|
+
# a frame missing one is malformed CLI output, not a forward-compatible
|
|
162
|
+
# variant, so it raises rather than yielding a half-built message.
|
|
163
|
+
def self.parse_conversation_reset_message(data)
|
|
164
|
+
ConversationResetMessage.new(
|
|
165
|
+
new_conversation_id: data.fetch(:new_conversation_id),
|
|
166
|
+
uuid: data.fetch(:uuid),
|
|
167
|
+
session_id: data.fetch(:session_id)
|
|
168
|
+
)
|
|
169
|
+
rescue KeyError => e
|
|
170
|
+
raise MessageParseError.new("Missing required field in conversation_reset message: #{e.key}", data: data)
|
|
171
|
+
end
|
|
172
|
+
|
|
138
173
|
def self.parse_tool_progress_message(data)
|
|
139
174
|
ToolProgressMessage.new(data)
|
|
140
175
|
end
|