swarm_sdk 2.0.0.pre.2

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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/lib/swarm_sdk/agent/builder.rb +333 -0
  3. data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
  4. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  5. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
  6. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
  7. data/lib/swarm_sdk/agent/chat.rb +779 -0
  8. data/lib/swarm_sdk/agent/context.rb +108 -0
  9. data/lib/swarm_sdk/agent/definition.rb +335 -0
  10. data/lib/swarm_sdk/configuration.rb +251 -0
  11. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  12. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  13. data/lib/swarm_sdk/context_compactor.rb +340 -0
  14. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  15. data/lib/swarm_sdk/hooks/context.rb +163 -0
  16. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  17. data/lib/swarm_sdk/hooks/error.rb +29 -0
  18. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  19. data/lib/swarm_sdk/hooks/registry.rb +143 -0
  20. data/lib/swarm_sdk/hooks/result.rb +150 -0
  21. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  22. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  23. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  24. data/lib/swarm_sdk/log_collector.rb +83 -0
  25. data/lib/swarm_sdk/log_stream.rb +69 -0
  26. data/lib/swarm_sdk/markdown_parser.rb +46 -0
  27. data/lib/swarm_sdk/permissions/config.rb +239 -0
  28. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  29. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  30. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  31. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  32. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
  33. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  34. data/lib/swarm_sdk/result.rb +97 -0
  35. data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
  36. data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
  37. data/lib/swarm_sdk/swarm/builder.rb +240 -0
  38. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  39. data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
  40. data/lib/swarm_sdk/swarm.rb +837 -0
  41. data/lib/swarm_sdk/tools/bash.rb +274 -0
  42. data/lib/swarm_sdk/tools/delegate.rb +152 -0
  43. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  44. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  45. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  46. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  47. data/lib/swarm_sdk/tools/edit.rb +150 -0
  48. data/lib/swarm_sdk/tools/glob.rb +158 -0
  49. data/lib/swarm_sdk/tools/grep.rb +231 -0
  50. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  51. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  52. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  53. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  54. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  55. data/lib/swarm_sdk/tools/read.rb +251 -0
  56. data/lib/swarm_sdk/tools/registry.rb +73 -0
  57. data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
  58. data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
  59. data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
  60. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  61. data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
  62. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  63. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  64. data/lib/swarm_sdk/tools/write.rb +117 -0
  65. data/lib/swarm_sdk/utils.rb +50 -0
  66. data/lib/swarm_sdk/version.rb +5 -0
  67. data/lib/swarm_sdk.rb +69 -0
  68. metadata +169 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dfc7f8797b72503c6f69cf0a524bf278c503f59379ff063ad720d34eac2e0244
4
+ data.tar.gz: b8add20a799625d7c4a067b43eb98bfac926a0ecc56e33c3c5ad0ee02243552b
5
+ SHA512:
6
+ metadata.gz: 5bf6007ca02a43c843f1f15486a7a5fdca8e269aed37d5ae102c1ee99ce055bb6a4dc34a427da72ce0c8fe84f7e8782b4d4d3ad8ed8010e64741fbfeb5ac0269
7
+ data.tar.gz: b89b9cef461bd4546f498b6f0f693cc56c4c8c006b6f2e864df4743e3af18aeaf7976ce440c364c031c2764d1ccdae65b7b38d32b5801739ba1a6346e7773a45
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ # Builder provides fluent API for configuring agents
6
+ #
7
+ # This class offers a Ruby DSL for defining agents with a clean, readable syntax.
8
+ # It collects configuration and then adds the agent to the swarm.
9
+ #
10
+ # @example
11
+ # agent :backend do
12
+ # model "gpt-5"
13
+ # prompt "You build APIs"
14
+ # tools :Read, :Write, :Bash
15
+ #
16
+ # hook :pre_tool_use, matcher: "Bash" do |ctx|
17
+ # SwarmSDK::Hooks::Result.halt("Blocked!") if dangerous?(ctx)
18
+ # end
19
+ # end
20
+ class Builder
21
+ # Expose default_permissions for Swarm::Builder to set from all_agents
22
+ attr_writer :default_permissions
23
+
24
+ # Expose mcp_servers for tests
25
+ attr_reader :mcp_servers
26
+
27
+ def initialize(name)
28
+ @name = name
29
+ @description = nil
30
+ @model = "gpt-5"
31
+ @provider = nil
32
+ @base_url = nil
33
+ @api_version = nil
34
+ @context_window = nil
35
+ @system_prompt = nil
36
+ # Use Set for tools to automatically handle duplicates when tools() is called multiple times.
37
+ # This ensures that if someone does: tools :Read; tools :Write; tools :Read
38
+ # the final set contains only [:Read, :Write] without duplicates.
39
+ # We convert to Array in to_definition for compatibility with Agent::Definition.
40
+ @tools = Set.new
41
+ @delegates_to = []
42
+ @directory = "."
43
+ @parameters = {}
44
+ @headers = {}
45
+ @timeout = nil
46
+ @mcp_servers = []
47
+ @include_default_tools = true
48
+ @bypass_permissions = false
49
+ @skip_base_prompt = false
50
+ @assume_model_exists = nil
51
+ @hooks = []
52
+ @permissions_config = {}
53
+ @default_permissions = {} # Set by SwarmBuilder from all_agents
54
+ end
55
+
56
+ # Set agent model
57
+ def model(model_name)
58
+ @model = model_name
59
+ end
60
+
61
+ # Set provider
62
+ def provider(provider_name)
63
+ @provider = provider_name
64
+ end
65
+
66
+ # Set base URL
67
+ def base_url(url)
68
+ @base_url = url
69
+ end
70
+
71
+ # Set API version (OpenAI-compatible providers only)
72
+ def api_version(version)
73
+ @api_version = version
74
+ end
75
+
76
+ # Set explicit context window override
77
+ def context_window(tokens)
78
+ @context_window = tokens
79
+ end
80
+
81
+ # Set LLM parameters
82
+ def parameters(params)
83
+ @parameters = params
84
+ end
85
+
86
+ # Set custom HTTP headers
87
+ def headers(header_hash)
88
+ @headers = header_hash
89
+ end
90
+
91
+ # Set timeout
92
+ def timeout(seconds)
93
+ @timeout = seconds
94
+ end
95
+
96
+ # Add an MCP server configuration
97
+ #
98
+ # @example stdio transport
99
+ # mcp_server :filesystem, type: :stdio, command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem"]
100
+ #
101
+ # @example SSE transport
102
+ # mcp_server :web, type: :sse, url: "https://example.com/mcp", headers: { authorization: "Bearer token" }
103
+ #
104
+ # @example HTTP/streamable transport
105
+ # mcp_server :api, type: :http, url: "https://api.example.com/mcp", timeout: 60
106
+ def mcp_server(name, **options)
107
+ server_config = { name: name }.merge(options)
108
+ @mcp_servers << server_config
109
+ end
110
+
111
+ # Set include_default_tools flag (deprecated - use tools method with include_default parameter)
112
+ def include_default_tools(enabled)
113
+ @include_default_tools = enabled
114
+ end
115
+
116
+ # Set bypass_permissions flag
117
+ def bypass_permissions(enabled)
118
+ @bypass_permissions = enabled
119
+ end
120
+
121
+ # Set skip_base_prompt flag
122
+ def skip_base_prompt(enabled)
123
+ @skip_base_prompt = enabled
124
+ end
125
+
126
+ # Set assume_model_exists flag
127
+ def assume_model_exists(enabled)
128
+ @assume_model_exists = enabled
129
+ end
130
+
131
+ # Set system prompt (matches YAML key)
132
+ def system_prompt(text)
133
+ @system_prompt = text
134
+ end
135
+
136
+ # Set description
137
+ def description(text)
138
+ @description = text
139
+ end
140
+
141
+ # Add tools
142
+ #
143
+ # Uses Set internally to automatically deduplicate tool names across multiple calls.
144
+ # This allows calling tools() multiple times without worrying about duplicates.
145
+ #
146
+ # @param tool_names [Array<Symbol>] Tool names to add
147
+ # @param include_default [Boolean] Whether to include default tools (Read, Grep, etc.)
148
+ #
149
+ # @example Basic usage with defaults
150
+ # tools :Grep, :Read # include_default: true is implicit
151
+ #
152
+ # @example Explicit tools only, no defaults
153
+ # tools :Grep, :Read, include_default: false
154
+ #
155
+ # @example Multiple calls (cumulative, automatic deduplication)
156
+ # tools :Read
157
+ # tools :Write, :Edit # @tools now contains Set[:Read, :Write, :Edit]
158
+ # tools :Read # Still Set[:Read, :Write, :Edit] - no duplicate
159
+ def tools(*tool_names, include_default: true)
160
+ @tools.merge(tool_names.map(&:to_sym))
161
+ @include_default_tools = include_default
162
+ end
163
+
164
+ # Add tools from all_agents configuration
165
+ #
166
+ # Used by Swarm::Builder to add all_agents tools.
167
+ # Since we use Set, order doesn't matter and duplicates are handled automatically.
168
+ #
169
+ # @param tool_names [Array] Tool names to add
170
+ # @return [void]
171
+ def prepend_tools(*tool_names)
172
+ @tools.merge(tool_names.map(&:to_sym))
173
+ end
174
+
175
+ # Set directory
176
+ def directory(dir)
177
+ @directory = dir
178
+ end
179
+
180
+ # Set delegation targets
181
+ def delegates_to(*agent_names)
182
+ @delegates_to.concat(agent_names)
183
+ end
184
+
185
+ # Add a hook (Ruby block OR shell command)
186
+ #
187
+ # @example Ruby block
188
+ # hook :pre_tool_use, matcher: "Bash" do |ctx|
189
+ # HookResult.halt("Blocked") if dangerous?(ctx)
190
+ # end
191
+ #
192
+ # @example Shell command
193
+ # hook :pre_tool_use, matcher: "Bash", command: "validate.sh"
194
+ def hook(event, matcher: nil, command: nil, timeout: nil, &block)
195
+ @hooks << {
196
+ event: event,
197
+ matcher: matcher,
198
+ command: command,
199
+ timeout: timeout,
200
+ block: block,
201
+ }
202
+ end
203
+
204
+ # Configure permissions for this agent
205
+ #
206
+ # @example
207
+ # permissions do
208
+ # Write.allow_paths "backend/**/*"
209
+ # Write.deny_paths "backend/secrets/**"
210
+ # end
211
+ def permissions(&block)
212
+ @permissions_config = PermissionsBuilder.build(&block)
213
+ end
214
+
215
+ # Build and return an Agent::Definition
216
+ #
217
+ # This method converts the builder's configuration into a validated
218
+ # Agent::Definition object. The caller is responsible for adding it to a swarm.
219
+ #
220
+ # Converts @tools Set to Array here because Agent::Definition expects an array.
221
+ # The Set was only used during building to handle duplicates efficiently.
222
+ #
223
+ # @return [Agent::Definition] Fully configured and validated agent definition
224
+ def to_definition
225
+ agent_config = {
226
+ description: @description || "Agent #{@name}",
227
+ model: @model,
228
+ system_prompt: @system_prompt,
229
+ tools: @tools.to_a, # Convert Set to Array for Agent::Definition compatibility
230
+ delegates_to: @delegates_to,
231
+ directory: @directory,
232
+ }
233
+
234
+ # Add optional fields
235
+ agent_config[:provider] = @provider if @provider
236
+ agent_config[:base_url] = @base_url if @base_url
237
+ agent_config[:api_version] = @api_version if @api_version
238
+ agent_config[:context_window] = @context_window if @context_window
239
+ agent_config[:parameters] = @parameters if @parameters.any?
240
+ agent_config[:headers] = @headers if @headers.any?
241
+ agent_config[:timeout] = @timeout if @timeout
242
+ agent_config[:mcp_servers] = @mcp_servers if @mcp_servers.any?
243
+ agent_config[:include_default_tools] = @include_default_tools
244
+ agent_config[:bypass_permissions] = @bypass_permissions
245
+ agent_config[:skip_base_prompt] = @skip_base_prompt
246
+ agent_config[:assume_model_exists] = @assume_model_exists unless @assume_model_exists.nil?
247
+ agent_config[:permissions] = @permissions_config if @permissions_config.any?
248
+ agent_config[:default_permissions] = @default_permissions if @default_permissions.any?
249
+
250
+ # Convert DSL hooks to HookDefinition format
251
+ agent_config[:hooks] = convert_hooks_to_definitions if @hooks.any?
252
+
253
+ Agent::Definition.new(@name, agent_config)
254
+ end
255
+
256
+ private
257
+
258
+ # Convert DSL hooks to HookDefinition objects for Agent::Definition
259
+ #
260
+ # This converts the builder's hook configuration (Ruby blocks and shell commands)
261
+ # into HookDefinition objects that will be applied during agent initialization.
262
+ #
263
+ # @return [Hash] Hooks grouped by event type { event: [HookDefinition, ...] }
264
+ def convert_hooks_to_definitions
265
+ result = Hash.new { |h, k| h[k] = [] }
266
+
267
+ @hooks.each do |hook_config|
268
+ event = hook_config[:event]
269
+
270
+ # Create HookDefinition with proc or command
271
+ if hook_config[:block]
272
+ # Ruby block hook
273
+ hook_def = Hooks::Definition.new(
274
+ event: event,
275
+ matcher: hook_config[:matcher],
276
+ priority: 0,
277
+ proc: hook_config[:block],
278
+ )
279
+ elsif hook_config[:command]
280
+ # Shell command hook - wrap in a block that calls ShellExecutor
281
+ hook_def = Hooks::Definition.new(
282
+ event: event,
283
+ matcher: hook_config[:matcher],
284
+ priority: 0,
285
+ proc: create_shell_hook_proc(hook_config),
286
+ )
287
+ else
288
+ raise ConfigurationError, "Hook must have either :block or :command"
289
+ end
290
+
291
+ result[event] << hook_def
292
+ end
293
+
294
+ result
295
+ end
296
+
297
+ # Create a proc that executes a shell command hook
298
+ def create_shell_hook_proc(config)
299
+ command = config[:command]
300
+ timeout = config[:timeout] || 60
301
+ agent_name = @name
302
+
303
+ proc do |context|
304
+ input_json = build_hook_input(context, config[:event])
305
+ Hooks::ShellExecutor.execute(
306
+ command: command,
307
+ input_json: input_json,
308
+ timeout: timeout,
309
+ agent_name: agent_name,
310
+ swarm_name: context.swarm&.name,
311
+ event: config[:event],
312
+ )
313
+ end
314
+ end
315
+
316
+ # Build hook input JSON for shell command hooks
317
+ def build_hook_input(context, event)
318
+ base = { event: event.to_s, agent: @name.to_s }
319
+
320
+ case event
321
+ when :pre_tool_use
322
+ base.merge(tool: context.tool_call.name, parameters: context.tool_call.parameters)
323
+ when :post_tool_use
324
+ base.merge(result: context.tool_result.content, success: context.tool_result.success?)
325
+ when :user_prompt
326
+ base.merge(prompt: context.metadata[:prompt])
327
+ else
328
+ base
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmSDK
4
+ module Agent
5
+ class Chat < RubyLLM::Chat
6
+ # Manages context tracking, delegation tracking, and logging callbacks
7
+ #
8
+ # Responsibilities:
9
+ # - Register RubyLLM callbacks for logging
10
+ # - Track tool executions
11
+ # - Track delegations (which tool calls are delegations)
12
+ # - Emit log events via LogStream
13
+ # - Check context warnings
14
+ #
15
+ # This is a stateful helper that's instantiated per Agent::Chat instance.
16
+ class ContextTracker
17
+ include LoggingHelpers
18
+
19
+ attr_reader :agent_context
20
+
21
+ def initialize(chat, agent_context)
22
+ @chat = chat
23
+ @agent_context = agent_context
24
+ @tool_executions = []
25
+ @finish_reason_override = nil
26
+ end
27
+
28
+ # Set a custom finish reason for the next agent_stop event
29
+ #
30
+ # This is used when finish_agent or finish_swarm terminates execution early.
31
+ #
32
+ # @param reason [String] Custom finish reason (e.g., "finish_agent", "finish_swarm")
33
+ attr_writer :finish_reason_override
34
+
35
+ # Setup logging callbacks
36
+ #
37
+ # Registers RubyLLM callbacks to collect data and emit log events.
38
+ # Should only be called when LogStream.emitter is set.
39
+ #
40
+ # @return [void]
41
+ def setup_logging
42
+ register_logging_callbacks
43
+ end
44
+
45
+ # Extract agent name from delegation tool name
46
+ #
47
+ # Converts "DelegateTaskTo[AgentName]" to "agent_name"
48
+ # Example: "DelegateTaskToWorker" -> "worker"
49
+ #
50
+ # @param tool_name [String] Delegation tool name
51
+ # @return [String] Agent name
52
+ def extract_delegate_agent_name(tool_name)
53
+ # Remove "DelegateTaskTo" prefix and lowercase first letter
54
+ agent_name = tool_name.to_s.sub(/^DelegateTaskTo/, "")
55
+ # Convert from PascalCase to lowercase (e.g., "Worker" -> "worker", "BackendDev" -> "backendDev")
56
+ agent_name[0] = agent_name[0].downcase unless agent_name.empty?
57
+ agent_name
58
+ end
59
+
60
+ # Check if context usage has crossed warning thresholds and emit warnings
61
+ #
62
+ # This should be called after each LLM response to check if we've crossed
63
+ # any warning thresholds (80%, 90%, etc.)
64
+ #
65
+ # @return [void]
66
+ def check_context_warnings
67
+ current_percentage = @chat.context_usage_percentage
68
+
69
+ Context::CONTEXT_WARNING_THRESHOLDS.each do |threshold|
70
+ # Only warn once per threshold
71
+ next if @agent_context.warning_threshold_hit?(threshold)
72
+ next if current_percentage < threshold
73
+
74
+ # Mark threshold as hit and emit warning
75
+ @agent_context.hit_warning_threshold?(threshold)
76
+
77
+ LogStream.emit(
78
+ type: "context_limit_warning",
79
+ agent: @agent_context.name,
80
+ model: @chat.model.id,
81
+ threshold: "#{threshold}%",
82
+ current_usage: "#{current_percentage}%",
83
+ tokens_used: @chat.cumulative_total_tokens,
84
+ tokens_remaining: @chat.tokens_remaining,
85
+ context_limit: @chat.context_limit,
86
+ metadata: @agent_context.metadata,
87
+ )
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # Extract usage information from an assistant message
94
+ #
95
+ # @param message [RubyLLM::Message] Assistant message with usage data
96
+ # @return [Hash] Usage information
97
+ def extract_usage_info(message)
98
+ cost_info = calculate_cost(message)
99
+ context_usage = if @chat.respond_to?(:cumulative_input_tokens)
100
+ {
101
+ cumulative_input_tokens: @chat.cumulative_input_tokens,
102
+ cumulative_output_tokens: @chat.cumulative_output_tokens,
103
+ cumulative_total_tokens: @chat.cumulative_total_tokens,
104
+ context_limit: @chat.context_limit,
105
+ tokens_used_percentage: "#{@chat.context_usage_percentage}%",
106
+ tokens_remaining: @chat.tokens_remaining,
107
+ }
108
+ else
109
+ {}
110
+ end
111
+
112
+ {
113
+ input_tokens: message.input_tokens,
114
+ output_tokens: message.output_tokens,
115
+ total_tokens: (message.input_tokens || 0) + (message.output_tokens || 0),
116
+ input_cost: cost_info[:input_cost],
117
+ output_cost: cost_info[:output_cost],
118
+ total_cost: cost_info[:total_cost],
119
+ }.merge(context_usage)
120
+ end
121
+
122
+ # Register RubyLLM chat callbacks to collect data and trigger logging
123
+ #
124
+ # This sets up low-level RubyLLM callbacks for technical plumbing (tracking state,
125
+ # collecting tool results), then emits log events via LogStream.
126
+ #
127
+ # @return [void]
128
+ def register_logging_callbacks
129
+ # Collect tool execution results (technical plumbing)
130
+ @chat.on_tool_result do |result|
131
+ @tool_executions << {
132
+ result: serialize_result(result),
133
+ completed_at: Time.now.utc.iso8601,
134
+ }
135
+ end
136
+
137
+ # Track delegations and emit agent_step/agent_stop events
138
+ @chat.on_end_message do |message|
139
+ next unless message
140
+
141
+ case message.role
142
+ when :assistant
143
+ if message.tool_call?
144
+ # Assistant made tool calls - emit agent_step event
145
+ trigger_agent_step(message, tool_executions: @tool_executions) if @chat.hook_executor
146
+ @tool_executions.clear
147
+ elsif @chat.hook_executor
148
+ # Final response (finish_reason: "stop") - fire agent_stop
149
+ trigger_agent_stop(message, tool_executions: @tool_executions)
150
+ end
151
+ when :tool
152
+ # Handle delegation tracking and logging (technical plumbing)
153
+ if @agent_context.delegation?(call_id: message.tool_call_id)
154
+ delegate_from = @agent_context.delegation_target(call_id: message.tool_call_id)
155
+
156
+ # Emit delegation result log event
157
+ LogStream.emit(
158
+ type: "delegation_result",
159
+ agent: @agent_context.name,
160
+ delegate_from: delegate_from,
161
+ tool_call_id: message.tool_call_id,
162
+ result: serialize_result(message.content),
163
+ metadata: @agent_context.metadata,
164
+ )
165
+
166
+ @agent_context.clear_delegation(call_id: message.tool_call_id)
167
+ end
168
+ end
169
+ end
170
+
171
+ # Track delegations when tool calls are made
172
+ @chat.on_tool_call do |tool_call|
173
+ if @agent_context.delegation_tool?(tool_call.name)
174
+ # Extract agent name from tool name (DelegateTaskTo[AgentName] -> agent_name)
175
+ agent_name = extract_delegate_agent_name(tool_call.name)
176
+
177
+ @agent_context.track_delegation(call_id: tool_call.id, target: agent_name)
178
+
179
+ # Emit delegation log event
180
+ LogStream.emit(
181
+ type: "agent_delegation",
182
+ agent: @agent_context.name,
183
+ tool_call_id: tool_call.id,
184
+ delegate_to: agent_name,
185
+ arguments: tool_call.arguments,
186
+ metadata: @agent_context.metadata,
187
+ )
188
+ end
189
+ end
190
+ end
191
+
192
+ # Trigger agent_step callback
193
+ #
194
+ # This fires when the agent makes an intermediate response with tool calls.
195
+ # The agent hasn't finished yet - it's requesting tools to continue processing.
196
+ #
197
+ # @param message [RubyLLM::Message] Assistant message with tool calls
198
+ # @param tool_executions [Array<Hash>] Tool execution results (should be empty for steps)
199
+ # @return [void]
200
+ def trigger_agent_step(message, tool_executions: [])
201
+ return unless @chat.hook_executor
202
+
203
+ usage_info = extract_usage_info(message)
204
+
205
+ context = Hooks::Context.new(
206
+ event: :agent_step,
207
+ agent_name: @agent_context.name,
208
+ swarm: @chat.hook_swarm,
209
+ metadata: {
210
+ model: message.model_id,
211
+ content: message.content,
212
+ tool_calls: format_tool_calls(message.tool_calls),
213
+ finish_reason: "tool_calls",
214
+ usage: usage_info,
215
+ tool_executions: tool_executions.empty? ? nil : tool_executions,
216
+ timestamp: Time.now.utc.iso8601,
217
+ },
218
+ )
219
+
220
+ agent_hooks = @chat.hook_agent_hooks[:agent_step] || []
221
+
222
+ @chat.hook_executor.execute_safe(
223
+ event: :agent_step,
224
+ context: context,
225
+ callbacks: agent_hooks,
226
+ )
227
+ end
228
+
229
+ # Trigger agent_stop callback
230
+ #
231
+ # This fires when the agent completes with a final response (no more tool calls).
232
+ #
233
+ # @param message [RubyLLM::Message] Assistant message with final content
234
+ # @param tool_executions [Array<Hash>] Tool execution results (if any)
235
+ # @return [void]
236
+ def trigger_agent_stop(message, tool_executions: [])
237
+ return unless @chat.hook_executor
238
+
239
+ usage_info = extract_usage_info(message)
240
+
241
+ # Use override if set (e.g., "finish_agent"), otherwise default to "stop"
242
+ finish_reason = @finish_reason_override || "stop"
243
+ @finish_reason_override = nil # Clear after use
244
+
245
+ context = Hooks::Context.new(
246
+ event: :agent_stop,
247
+ agent_name: @agent_context.name,
248
+ swarm: @chat.hook_swarm,
249
+ metadata: {
250
+ model: message.model_id,
251
+ content: message.content,
252
+ tool_calls: nil, # Final response has no tool calls
253
+ finish_reason: finish_reason,
254
+ usage: usage_info,
255
+ tool_executions: tool_executions.empty? ? nil : tool_executions,
256
+ timestamp: Time.now.utc.iso8601,
257
+ },
258
+ )
259
+
260
+ agent_hooks = @chat.hook_agent_hooks[:agent_stop] || []
261
+
262
+ @chat.hook_executor.execute_safe(
263
+ event: :agent_stop,
264
+ context: context,
265
+ callbacks: agent_hooks,
266
+ )
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end