diff --git a/CHANGELOG.md b/CHANGELOG.md index e8751fd..24c1eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `APIRetryMessage` system message type with `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, and `error` fields (TypeScript SDK v0.2.77 parity) +- `title` and `display_name` fields on `ToolPermissionContext` for richer permission prompt context (TypeScript SDK v0.2.77 parity) +- `allow_read` and `allow_managed_read_paths_only` fields on `SandboxFilesystemConfig` for re-allowing reads within deny regions (TypeScript SDK v0.2.77 parity) + ## [0.7.16] - 2026-03-15 ## [0.7.15] - 2026-03-15 diff --git a/SPEC.md b/SPEC.md index 974d826..405e261 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3,11 +3,11 @@ This document provides a comprehensive specification of the Claude Agent SDK, comparing feature parity across the official TypeScript and Python SDKs with this Ruby implementation. **Reference Versions:** -- TypeScript SDK: v0.2.76 (npm package) -- Python SDK: from GitHub (commit 302ceb6) +- TypeScript SDK: v0.2.77 (npm package) +- Python SDK: from GitHub (commit 971994c) - Ruby SDK: This repository -**Last Updated:** 2026-03-15 +**Last Updated:** 2026-03-17 --- @@ -116,9 +116,20 @@ Messages exchanged between SDK and CLI. | `FilesPersistedEvent` | ✅ | ❌ | ✅ | File persistence confirmation | | `ElicitationCompleteMessage` | ✅ | ❌ | ✅ | MCP elicitation completed | | `LocalCommandOutputMessage` | ✅ | ❌ | ✅ | Local command output | +| `APIRetryMessage` | ✅ | ❌ | ✅ | API retry notification (v0.2.77) | ### Message Fields +#### APIRetryMessage + +| Field | TypeScript | Python | Ruby | Notes | +|-------------------|:----------:|:------:|:----:|------------------------------------------| +| `attempt` | ✅ | ❌ | ✅ | Current retry attempt number | +| `max_retries` | ✅ | ❌ | ✅ | Maximum retry count | +| `retry_delay_ms` | ✅ | ❌ | ✅ | Delay before retry in milliseconds | +| `error_status` | ✅ | ❌ | ✅ | HTTP status code (null for conn errors) | +| `error` | ✅ | ❌ | ✅ | Error type (AssistantMessageError) | + #### ResultMessage | Field | TypeScript | Python | Ruby | Notes | @@ -666,6 +677,8 @@ Permission handling and updates. | `toolUseID` | ✅ | ❌ | ✅ | Tool call ID | | `agentID` | ✅ | ❌ | ✅ | Subagent ID if applicable | | `description` | ✅ | ❌ | ✅ | Human-readable tool description (v0.2.75) | +| `title` | ✅ | ❌ | ✅ | Full permission prompt sentence (v0.2.77) | +| `displayName` | ✅ | ❌ | ✅ | Short noun phrase for tool action (v0.2.77) | --- @@ -799,15 +812,15 @@ Session management and resumption. | Field | TypeScript | Python | Ruby | Notes | |------------------|:----------:|:------:|:----:|------------------------------------------------| -| `dir` | ✅ | ❌ | ❌ | Project directory | -| `upToMessageId` | ✅ | ❌ | ❌ | Slice transcript up to this UUID (inclusive) | -| `title` | ✅ | ❌ | ❌ | Custom title for the fork | +| `dir` | ✅ | ❌ | ✅ | Project directory | +| `upToMessageId` | ✅ | ❌ | ✅ | Slice transcript up to this UUID (inclusive) | +| `title` | ✅ | ❌ | ✅ | Custom title for the fork | #### ForkSessionResult | Field | TypeScript | Python | Ruby | Notes | |-------------|:----------:|:------:|:----:|--------------------------------| -| `sessionId` | ✅ | ❌ | ❌ | New forked session UUID | +| `sessionId` | ✅ | ❌ | ✅ | New forked session UUID | ### V2 Session API (Unstable) @@ -833,9 +846,10 @@ Custom subagent definitions. | `tools` | ✅ | ✅ | ✅ | Allowed tools | | `disallowedTools` | ✅ | ❌ | ✅ | Blocked tools | | `model` | ✅ | ✅ | ✅ | Model override (sonnet/opus/haiku/inherit) | -| `mcpServers` | ✅ | ❌ | ✅ | Agent-specific MCP servers | +| `mcpServers` | ✅ | ✅ | ✅ | Agent-specific MCP servers | | `criticalSystemReminder_EXPERIMENTAL` | ✅ | ❌ | ✅ | Critical reminder (experimental) | -| `skills` | ✅ | ❌ | ✅ | Skills to preload into agent context | +| `skills` | ✅ | ✅ | ✅ | Skills to preload into agent context | +| `memory` | ❌ | ✅ | ❌ | Memory scope for agent (Python-only) | | `maxTurns` | ✅ | ❌ | ✅ | Max agentic turns before stopping | --- @@ -861,11 +875,13 @@ Sandbox configuration for command execution isolation. ### SandboxFilesystemConfig -| Field | TypeScript | Python | Ruby | -|--------------|:----------:|:------:|:----:| -| `allowWrite` | ✅ | ❌ | ✅ | -| `denyWrite` | ✅ | ❌ | ✅ | -| `denyRead` | ✅ | ❌ | ✅ | +| Field | TypeScript | Python | Ruby | +|-----------------------------|:----------:|:------:|:----:| +| `allowWrite` | ✅ | ❌ | ✅ | +| `denyWrite` | ✅ | ❌ | ✅ | +| `denyRead` | ✅ | ❌ | ✅ | +| `allowRead` | ✅ | ❌ | ✅ | +| `allowManagedReadPathsOnly` | ✅ | ❌ | ✅ | ### SandboxNetworkConfig @@ -1017,6 +1033,7 @@ Public API surface for SDK clients. - v0.2.74: Added `renameSession()` for renaming session files - v0.2.75: Added `tag`/`createdAt` fields on `SDKSessionInfo`; `getSessionInfo()` for single-session lookup; `offset` on `listSessions` for pagination; `tagSession()` for tagging sessions; `supportsAutoMode` in `ModelInfo`; `description` on `SDKControlPermissionRequest`; `prompt` on `SDKTaskStartedMessage`; `fast_mode_state` on `SDKControlInitializeResponse`; `queued_to_running` status on `AgentToolOutput` - v0.2.76: Added `forkSession(sessionId, opts?)` for branching conversations from a point; `cancel_async_message` control subtype to drop queued user messages; `PostCompact` hook event with `compact_summary` field; `get_settings` control request for reading effective merged settings; `planFilePath` field on `ExitPlanMode` tool input +- v0.2.77: Added `SDKAPIRetryMessage` (system subtype `api_retry`) exposing attempt count, max retries, delay, and error status for transient API error retries; added `title` and `displayName` fields on `SDKControlPermissionRequest`/`CanUseTool` options; added `allowRead` and `allowManagedReadPathsOnly` on `SandboxFilesystemConfig` - Includes `Elicitation`/`ElicitationResult` hook events, `onElicitation` option, `ElicitationCompleteMessage`, `LocalCommandOutputMessage`, `FastModeState` (undocumented in changelog, present in types) ### Python SDK @@ -1045,10 +1062,11 @@ Public API surface for SDK clients. - v0.1.48: Fixed fine-grained tool streaming regression - Added `RateLimitEvent` message type with `RateLimitInfo` - Added `rename_session()` and `tag_session()` session management functions -- Missing: `onElicitation`, `Elicitation`/`ElicitationResult` hooks, `ElicitationCompleteMessage`, `LocalCommandOutputMessage`, `FastModeState`, `InstructionsLoaded` hook, `agentProgressSummaries`, `getSessionInfo()`, `forkSession()`, `PostCompact` hook, `cancel_async_message`, `get_settings` +- `AgentDefinition` now has `skills`, `mcpServers`, and `memory` (Python-only) fields +- Missing: `onElicitation`, `Elicitation`/`ElicitationResult` hooks, `ElicitationCompleteMessage`, `LocalCommandOutputMessage`, `FastModeState`, `InstructionsLoaded` hook, `agentProgressSummaries`, `getSessionInfo()`, `forkSession()`, `PostCompact` hook, `cancel_async_message`, `get_settings`, `APIRetryMessage` ### Ruby SDK (This Repository) -- Feature parity with TypeScript SDK v0.2.76 +- Feature parity with TypeScript SDK v0.2.77 - Ruby-idiomatic patterns (Data.define, snake_case) - Complete control protocol, hook, and V2 Session API support - Dedicated Client class for multi-turn conversations diff --git a/lib/claude_agent/control_protocol/request_handling.rb b/lib/claude_agent/control_protocol/request_handling.rb index 7c554f1..2237f17 100644 --- a/lib/claude_agent/control_protocol/request_handling.rb +++ b/lib/claude_agent/control_protocol/request_handling.rb @@ -64,6 +64,8 @@ def handle_can_use_tool(request) tool_use_id: request["tool_use_id"], agent_id: request["agent_id"], description: request["description"], + title: request["title"], + display_name: request["display_name"], signal: @abort_signal, request: perm_request ) diff --git a/lib/claude_agent/message_parser.rb b/lib/claude_agent/message_parser.rb index 40a838e..e59c41f 100644 --- a/lib/claude_agent/message_parser.rb +++ b/lib/claude_agent/message_parser.rb @@ -86,6 +86,7 @@ def parse(raw) register "system:task_progress", :parse_task_progress_message register "system:elicitation_complete", :parse_elicitation_complete_message register "system:local_command_output", :parse_local_command_output_message + register "system:api_retry", :parse_api_retry_message private @@ -421,5 +422,17 @@ def parse_prompt_suggestion_message(raw) suggestion: raw[:suggestion] || "" ) end + + def parse_api_retry_message(raw) + APIRetryMessage.new( + uuid: raw[:uuid] || "", + session_id: raw[:session_id] || "", + attempt: raw[:attempt] || 0, + max_retries: raw[:max_retries] || 0, + retry_delay_ms: raw[:retry_delay_ms] || 0, + error_status: raw[:error_status], + error: raw[:error] + ) + end end end diff --git a/lib/claude_agent/messages.rb b/lib/claude_agent/messages.rb index 625687f..76d6442 100644 --- a/lib/claude_agent/messages.rb +++ b/lib/claude_agent/messages.rb @@ -34,6 +34,7 @@ module ClaudeAgent PromptSuggestionMessage, ElicitationCompleteMessage, LocalCommandOutputMessage, + APIRetryMessage, GenericMessage ].freeze end diff --git a/lib/claude_agent/messages/system.rb b/lib/claude_agent/messages/system.rb index 12aaa57..a129af5 100644 --- a/lib/claude_agent/messages/system.rb +++ b/lib/claude_agent/messages/system.rb @@ -55,6 +55,33 @@ def pre_tokens # status: "compacting" # ) # + # API retry message (TypeScript SDK v0.2.77 parity) + # + # Emitted when an API request fails with a retryable error and will be + # retried after a delay. Exposes attempt count, max retries, delay, and + # error status for observability. + # + # @example + # msg = APIRetryMessage.new( + # uuid: "msg-123", + # session_id: "session-abc", + # attempt: 1, + # max_retries: 3, + # retry_delay_ms: 5000, + # error_status: 529, + # error: "rate_limit" + # ) + # + APIRetryMessage = Data.define(:uuid, :session_id, :attempt, :max_retries, :retry_delay_ms, :error_status, :error) do + def initialize(uuid: "", session_id: "", attempt: 0, max_retries: 0, retry_delay_ms: 0, error_status: nil, error: nil) + super + end + + def type + :api_retry + end + end + StatusMessage = Data.define(:uuid, :session_id, :status, :permission_mode) do def initialize(uuid:, session_id:, status:, permission_mode: nil) super diff --git a/lib/claude_agent/permissions.rb b/lib/claude_agent/permissions.rb index 4c4da16..09e0f07 100644 --- a/lib/claude_agent/permissions.rb +++ b/lib/claude_agent/permissions.rb @@ -147,6 +147,8 @@ def to_h # decision_reason: "Path outside allowed directories", # tool_use_id: "tool_123", # agent_id: "agent_456", + # title: "Claude wants to read /etc/passwd", + # display_name: "Read file", # signal: abort_signal # ) # @@ -158,6 +160,8 @@ def to_h :agent_id, :signal, :description, + :title, + :display_name, :request ) do def initialize( @@ -168,6 +172,8 @@ def initialize( agent_id: nil, signal: nil, description: nil, + title: nil, + display_name: nil, request: nil ) super diff --git a/lib/claude_agent/sandbox_settings.rb b/lib/claude_agent/sandbox_settings.rb index 6140f0b..d7c15e4 100644 --- a/lib/claude_agent/sandbox_settings.rb +++ b/lib/claude_agent/sandbox_settings.rb @@ -85,17 +85,19 @@ def to_h end end - # Filesystem-specific configuration for sandbox mode (TypeScript SDK v0.2.49 parity) + # Filesystem-specific configuration for sandbox mode (TypeScript SDK v0.2.77 parity) # # @example # filesystem = SandboxFilesystemConfig.new( # allow_write: ["/tmp/*"], # deny_write: ["/etc/*"], - # deny_read: ["/secrets/*"] + # deny_read: ["/secrets/*"], + # allow_read: ["/secrets/public/*"], + # allow_managed_read_paths_only: false # ) # - SandboxFilesystemConfig = Data.define(:allow_write, :deny_write, :deny_read) do - def initialize(allow_write: [], deny_write: [], deny_read: []) + SandboxFilesystemConfig = Data.define(:allow_write, :deny_write, :deny_read, :allow_read, :allow_managed_read_paths_only) do + def initialize(allow_write: [], deny_write: [], deny_read: [], allow_read: [], allow_managed_read_paths_only: false) super end @@ -104,6 +106,8 @@ def to_h result[:allowWrite] = allow_write unless allow_write.empty? result[:denyWrite] = deny_write unless deny_write.empty? result[:denyRead] = deny_read unless deny_read.empty? + result[:allowRead] = allow_read unless allow_read.empty? + result[:allowManagedReadPathsOnly] = allow_managed_read_paths_only if allow_managed_read_paths_only result end end diff --git a/sig/claude_agent.rbs b/sig/claude_agent.rbs index 258f90b..714e0ff 100644 --- a/sig/claude_agent.rbs +++ b/sig/claude_agent.rbs @@ -314,8 +314,10 @@ module ClaudeAgent attr_reader allow_write: Array[String] attr_reader deny_write: Array[String] attr_reader deny_read: Array[String] + attr_reader allow_read: Array[String] + attr_reader allow_managed_read_paths_only: bool - def initialize: (?allow_write: Array[String], ?deny_write: Array[String], ?deny_read: Array[String]) -> void + def initialize: (?allow_write: Array[String], ?deny_write: Array[String], ?deny_read: Array[String], ?allow_read: Array[String], ?allow_managed_read_paths_only: bool) -> void def to_h: () -> Hash[Symbol, untyped] end @@ -573,6 +575,20 @@ module ClaudeAgent def type: () -> :local_command_output end + # API retry message (TypeScript SDK v0.2.77 parity) + class APIRetryMessage + attr_reader uuid: String + attr_reader session_id: String + attr_reader attempt: Integer + attr_reader max_retries: Integer + attr_reader retry_delay_ms: Integer + attr_reader error_status: Integer? + attr_reader error: String? + + def initialize: (?uuid: String, ?session_id: String, ?attempt: Integer, ?max_retries: Integer, ?retry_delay_ms: Integer, ?error_status: Integer?, ?error: String?) -> void + def type: () -> :api_retry + end + class GenericMessage attr_reader message_type: String? attr_reader raw: Hash[Symbol, untyped] @@ -584,7 +600,7 @@ module ClaudeAgent end # Message types - type message = UserMessage | UserMessageReplay | AssistantMessage | SystemMessage | ResultMessage | StreamEvent | CompactBoundaryMessage | StatusMessage | ToolProgressMessage | HookResponseMessage | AuthStatusMessage | TaskNotificationMessage | HookStartedMessage | HookProgressMessage | ToolUseSummaryMessage | FilesPersistedEvent | TaskStartedMessage | TaskProgressMessage | RateLimitEvent | PromptSuggestionMessage | ElicitationCompleteMessage | LocalCommandOutputMessage | GenericMessage + type message = UserMessage | UserMessageReplay | AssistantMessage | SystemMessage | ResultMessage | StreamEvent | CompactBoundaryMessage | StatusMessage | ToolProgressMessage | HookResponseMessage | AuthStatusMessage | TaskNotificationMessage | HookStartedMessage | HookProgressMessage | ToolUseSummaryMessage | FilesPersistedEvent | TaskStartedMessage | TaskProgressMessage | RateLimitEvent | PromptSuggestionMessage | ElicitationCompleteMessage | LocalCommandOutputMessage | APIRetryMessage | GenericMessage MESSAGE_TYPES: Array[Class] @@ -1161,9 +1177,11 @@ module ClaudeAgent attr_reader agent_id: String? attr_reader signal: AbortSignal? attr_reader description: String? + attr_reader title: String? + attr_reader display_name: String? attr_reader request: PermissionRequest? - def initialize: (?permission_suggestions: untyped, ?blocked_path: String?, ?decision_reason: String?, ?tool_use_id: String?, ?agent_id: String?, ?signal: AbortSignal?, ?description: String?, ?request: PermissionRequest?) -> void + def initialize: (?permission_suggestions: untyped, ?blocked_path: String?, ?decision_reason: String?, ?tool_use_id: String?, ?agent_id: String?, ?signal: AbortSignal?, ?description: String?, ?title: String?, ?display_name: String?, ?request: PermissionRequest?) -> void end # Deferred permission request resolved from any thread diff --git a/test/claude_agent/test_message_parser.rb b/test/claude_agent/test_message_parser.rb index 60d10e4..190ed4d 100644 --- a/test/claude_agent/test_message_parser.rb +++ b/test/claude_agent/test_message_parser.rb @@ -1418,6 +1418,67 @@ class TestClaudeAgentMessageParser < ActiveSupport::TestCase assert_equal "", msg.suggestion end + # --- APIRetryMessage parsing --- + + test "parse_api_retry_message" do + raw = { + "type" => "system", + "subtype" => "api_retry", + "uuid" => "msg-123", + "session_id" => "sess-abc", + "attempt" => 2, + "max_retries" => 5, + "retry_delay_ms" => 3000, + "error_status" => 529, + "error" => "rate_limit" + } + msg = @parser.parse(raw) + + assert_instance_of ClaudeAgent::APIRetryMessage, msg + assert_equal "msg-123", msg.uuid + assert_equal "sess-abc", msg.session_id + assert_equal 2, msg.attempt + assert_equal 5, msg.max_retries + assert_equal 3000, msg.retry_delay_ms + assert_equal 529, msg.error_status + assert_equal "rate_limit", msg.error + assert_equal :api_retry, msg.type + end + + test "parse_api_retry_message_connection_error" do + raw = { + "type" => "system", + "subtype" => "api_retry", + "uuid" => "msg-456", + "session_id" => "sess-xyz", + "attempt" => 1, + "max_retries" => 3, + "retry_delay_ms" => 1000, + "error_status" => nil, + "error" => "unknown" + } + msg = @parser.parse(raw) + + assert_nil msg.error_status + assert_equal "unknown", msg.error + end + + test "parse_api_retry_message_defaults" do + raw = { + "type" => "system", + "subtype" => "api_retry" + } + msg = @parser.parse(raw) + + assert_equal "", msg.uuid + assert_equal "", msg.session_id + assert_equal 0, msg.attempt + assert_equal 0, msg.max_retries + assert_equal 0, msg.retry_delay_ms + assert_nil msg.error_status + assert_nil msg.error + end + # --- Parser Registry --- test "registry contains all top-level message types" do @@ -1434,7 +1495,7 @@ class TestClaudeAgentMessageParser < ActiveSupport::TestCase %w[compact_boundary status hook_response task_notification hook_started hook_progress files_persisted task_started task_progress - elicitation_complete local_command_output].each do |subtype| + elicitation_complete local_command_output api_retry].each do |subtype| assert registry.key?("system:#{subtype}"), "Registry should contain 'system:#{subtype}'" end end diff --git a/test/claude_agent/test_permissions.rb b/test/claude_agent/test_permissions.rb index 5618e69..e803e01 100644 --- a/test/claude_agent/test_permissions.rb +++ b/test/claude_agent/test_permissions.rb @@ -305,4 +305,21 @@ class TestClaudeAgentPermissions < ActiveSupport::TestCase context = ClaudeAgent::ToolPermissionContext.new assert_nil context.request end + + test "tool_permission_context_with_title_and_display_name" do + context = ClaudeAgent::ToolPermissionContext.new( + title: "Claude wants to read /etc/passwd", + display_name: "Read file", + tool_use_id: "tool-123" + ) + assert_equal "Claude wants to read /etc/passwd", context.title + assert_equal "Read file", context.display_name + assert_equal "tool-123", context.tool_use_id + end + + test "tool_permission_context_title_and_display_name_default_nil" do + context = ClaudeAgent::ToolPermissionContext.new + assert_nil context.title + assert_nil context.display_name + end end diff --git a/test/claude_agent/test_sandbox_settings.rb b/test/claude_agent/test_sandbox_settings.rb index 32b5542..8d04df6 100644 --- a/test/claude_agent/test_sandbox_settings.rb +++ b/test/claude_agent/test_sandbox_settings.rb @@ -368,4 +368,48 @@ class TestClaudeAgentSandboxSettings < ActiveSupport::TestCase h = sandbox.to_h refute h.key?(:filesystem) end + + # --- SandboxFilesystemConfig allow_read and allow_managed_read_paths_only --- + + test "sandbox_filesystem_config_allow_read" do + config = ClaudeAgent::SandboxFilesystemConfig.new( + deny_read: [ "/secrets/*" ], + allow_read: [ "/secrets/public/*" ] + ) + assert_equal [ "/secrets/public/*" ], config.allow_read + end + + test "sandbox_filesystem_config_allow_read_default" do + config = ClaudeAgent::SandboxFilesystemConfig.new + assert_equal [], config.allow_read + assert_equal false, config.allow_managed_read_paths_only + end + + test "sandbox_filesystem_config_allow_managed_read_paths_only" do + config = ClaudeAgent::SandboxFilesystemConfig.new( + allow_managed_read_paths_only: true + ) + assert_equal true, config.allow_managed_read_paths_only + end + + test "sandbox_filesystem_config_to_h_with_allow_read" do + config = ClaudeAgent::SandboxFilesystemConfig.new( + deny_read: [ "/secrets/*" ], + allow_read: [ "/secrets/public/*" ], + allow_managed_read_paths_only: true + ) + h = config.to_h + assert_equal [ "/secrets/*" ], h[:denyRead] + assert_equal [ "/secrets/public/*" ], h[:allowRead] + assert_equal true, h[:allowManagedReadPathsOnly] + end + + test "sandbox_filesystem_config_to_h_omits_empty_allow_read" do + config = ClaudeAgent::SandboxFilesystemConfig.new( + allow_write: [ "/tmp/*" ] + ) + h = config.to_h + refute h.key?(:allowRead) + refute h.key?(:allowManagedReadPathsOnly) + end end