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.
- checksums.yaml +7 -0
- data/lib/swarm_sdk/agent/builder.rb +333 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +271 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +99 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +114 -0
- data/lib/swarm_sdk/agent/chat.rb +779 -0
- data/lib/swarm_sdk/agent/context.rb +108 -0
- data/lib/swarm_sdk/agent/definition.rb +335 -0
- data/lib/swarm_sdk/configuration.rb +251 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +163 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +143 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +83 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +46 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +237 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +224 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +62 -0
- data/lib/swarm_sdk/swarm/builder.rb +240 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +267 -0
- data/lib/swarm_sdk/swarm.rb +837 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/delegate.rb +152 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +231 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +73 -0
- data/lib/swarm_sdk/tools/scratchpad_list.rb +88 -0
- data/lib/swarm_sdk/tools/scratchpad_read.rb +59 -0
- data/lib/swarm_sdk/tools/scratchpad_write.rb +88 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad.rb +153 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +69 -0
- 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
|