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
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Tool for reading content from scratchpad memory
|
6
|
+
#
|
7
|
+
# Retrieves content stored by any agent using scratchpad_write.
|
8
|
+
# All agents in the swarm share the same scratchpad.
|
9
|
+
class ScratchpadRead < RubyLLM::Tool
|
10
|
+
define_method(:name) { "ScratchpadRead" }
|
11
|
+
|
12
|
+
description <<~DESC
|
13
|
+
Read content from scratchpad.
|
14
|
+
Use this to retrieve detailed outputs, analysis, or results that were
|
15
|
+
stored using scratchpad_write. Any agent can read any scratchpad content.
|
16
|
+
DESC
|
17
|
+
|
18
|
+
param :file_path,
|
19
|
+
desc: "Path to read from scratchpad (e.g., 'analysis/report', 'parallel/batch1/task_0')",
|
20
|
+
required: true
|
21
|
+
|
22
|
+
class << self
|
23
|
+
# Create a ScratchpadRead tool for a specific scratchpad instance
|
24
|
+
#
|
25
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
26
|
+
# @return [ScratchpadRead] Tool instance
|
27
|
+
def create_for_scratchpad(scratchpad)
|
28
|
+
new(scratchpad)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Initialize with scratchpad instance
|
33
|
+
#
|
34
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
35
|
+
def initialize(scratchpad)
|
36
|
+
super() # Call RubyLLM::Tool's initialize
|
37
|
+
@scratchpad = scratchpad
|
38
|
+
end
|
39
|
+
|
40
|
+
# Execute the tool
|
41
|
+
#
|
42
|
+
# @param file_path [String] Path to read from
|
43
|
+
# @return [String] Content at the path or error message
|
44
|
+
def execute(file_path:)
|
45
|
+
scratchpad.read(file_path: file_path)
|
46
|
+
rescue ArgumentError => e
|
47
|
+
validation_error(e.message)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :scratchpad
|
53
|
+
|
54
|
+
def validation_error(message)
|
55
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# Tool for writing content to scratchpad memory
|
6
|
+
#
|
7
|
+
# Stores content in session-scoped, in-memory storage with metadata.
|
8
|
+
# All agents in the swarm share the same scratchpad.
|
9
|
+
class ScratchpadWrite < RubyLLM::Tool
|
10
|
+
define_method(:name) { "ScratchpadWrite" }
|
11
|
+
|
12
|
+
description <<~DESC
|
13
|
+
Store content in scratchpad for later retrieval.
|
14
|
+
Use this to save detailed outputs, analysis, or results that would
|
15
|
+
otherwise bloat tool responses. Any agent can read this content using scratchpad_read.
|
16
|
+
|
17
|
+
IMPORTANT: You must determine the appropriate file_path based on the task you're performing.
|
18
|
+
Choose a logical, descriptive path that reflects the content type and purpose.
|
19
|
+
Examples: 'analysis/code_review', 'research/findings', 'parallel/batch_1/results', 'logs/debug_trace'
|
20
|
+
DESC
|
21
|
+
|
22
|
+
param :file_path,
|
23
|
+
desc: "File-path-like address you determine based on the task (e.g., 'analysis/report', 'parallel/batch1/task_0')",
|
24
|
+
required: true
|
25
|
+
|
26
|
+
param :content,
|
27
|
+
desc: "Content to store in scratchpad (max 1MB per entry)",
|
28
|
+
required: true
|
29
|
+
|
30
|
+
param :title,
|
31
|
+
desc: "Brief title describing the content (shown in listings)",
|
32
|
+
required: true
|
33
|
+
|
34
|
+
class << self
|
35
|
+
# Create a ScratchpadWrite tool for a specific scratchpad instance
|
36
|
+
#
|
37
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
38
|
+
# @return [ScratchpadWrite] Tool instance
|
39
|
+
def create_for_scratchpad(scratchpad)
|
40
|
+
new(scratchpad)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Initialize with scratchpad instance
|
45
|
+
#
|
46
|
+
# @param scratchpad [Stores::Scratchpad] Shared scratchpad instance
|
47
|
+
def initialize(scratchpad)
|
48
|
+
super() # Call RubyLLM::Tool's initialize
|
49
|
+
@scratchpad = scratchpad
|
50
|
+
end
|
51
|
+
|
52
|
+
# Execute the tool
|
53
|
+
#
|
54
|
+
# @param file_path [String] Path to store content
|
55
|
+
# @param content [String] Content to store
|
56
|
+
# @param title [String] Brief title
|
57
|
+
# @return [String] Success message with path and size
|
58
|
+
def execute(file_path:, content:, title:)
|
59
|
+
entry = scratchpad.write(file_path: file_path, content: content, title: title)
|
60
|
+
"Stored at scratchpad://#{file_path} (#{format_bytes(entry.size)})"
|
61
|
+
rescue ArgumentError => e
|
62
|
+
validation_error(e.message)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
attr_reader :scratchpad
|
68
|
+
|
69
|
+
def validation_error(message)
|
70
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Format bytes to human-readable size
|
74
|
+
#
|
75
|
+
# @param bytes [Integer] Number of bytes
|
76
|
+
# @return [String] Formatted size
|
77
|
+
def format_bytes(bytes)
|
78
|
+
if bytes >= 1_000_000
|
79
|
+
"#{(bytes.to_f / 1_000_000).round(1)}MB"
|
80
|
+
elsif bytes >= 1_000
|
81
|
+
"#{(bytes.to_f / 1_000).round(1)}KB"
|
82
|
+
else
|
83
|
+
"#{bytes}B"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
module Stores
|
6
|
+
# ReadTracker manages read-file tracking for all agents
|
7
|
+
#
|
8
|
+
# This module maintains a global registry of which files each agent has read
|
9
|
+
# during their conversation. This enables enforcement of the "read-before-write"
|
10
|
+
# and "read-before-edit" rules that ensure agents have context before modifying files.
|
11
|
+
#
|
12
|
+
# Each agent maintains an independent set of read files, keyed by agent identifier.
|
13
|
+
module ReadTracker
|
14
|
+
@read_files = {}
|
15
|
+
@mutex = Mutex.new
|
16
|
+
|
17
|
+
class << self
|
18
|
+
# Register that an agent has read a file
|
19
|
+
#
|
20
|
+
# @param agent_id [Symbol] The agent identifier
|
21
|
+
# @param file_path [String] The absolute path to the file
|
22
|
+
def register_read(agent_id, file_path)
|
23
|
+
@mutex.synchronize do
|
24
|
+
@read_files[agent_id] ||= Set.new
|
25
|
+
@read_files[agent_id] << File.expand_path(file_path)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Check if an agent has read a file
|
30
|
+
#
|
31
|
+
# @param agent_id [Symbol] The agent identifier
|
32
|
+
# @param file_path [String] The absolute path to the file
|
33
|
+
# @return [Boolean] true if the agent has read this file
|
34
|
+
def file_read?(agent_id, file_path)
|
35
|
+
@mutex.synchronize do
|
36
|
+
return false unless @read_files[agent_id]
|
37
|
+
|
38
|
+
@read_files[agent_id].include?(File.expand_path(file_path))
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Clear read history for an agent (useful for testing)
|
43
|
+
#
|
44
|
+
# @param agent_id [Symbol] The agent identifier
|
45
|
+
def clear(agent_id)
|
46
|
+
@mutex.synchronize do
|
47
|
+
@read_files.delete(agent_id)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Clear all read history (useful for testing)
|
52
|
+
def clear_all
|
53
|
+
@mutex.synchronize do
|
54
|
+
@read_files.clear
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
module Stores
|
6
|
+
# Scratchpad provides session-scoped, in-memory storage for agents
|
7
|
+
# to store detailed outputs that would otherwise bloat tool responses.
|
8
|
+
#
|
9
|
+
# Features:
|
10
|
+
# - Session-scoped: Cleared when swarm execution completes
|
11
|
+
# - Shared: Any agent can read/write any scratchpad address
|
12
|
+
# - Path-based: Hierarchical organization using file-path-like addresses
|
13
|
+
# - In-memory: No filesystem I/O, pure memory storage
|
14
|
+
# - Metadata-rich: Stores content + title + timestamp + size
|
15
|
+
class Scratchpad
|
16
|
+
# Maximum size per scratchpad entry (1MB)
|
17
|
+
MAX_ENTRY_SIZE = 1_000_000
|
18
|
+
|
19
|
+
# Maximum total scratchpad size (100MB)
|
20
|
+
MAX_TOTAL_SIZE = 100_000_000
|
21
|
+
|
22
|
+
# Represents a single scratchpad entry with metadata
|
23
|
+
Entry = Struct.new(:content, :title, :created_at, :size, keyword_init: true)
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@entries = {}
|
27
|
+
@total_size = 0
|
28
|
+
end
|
29
|
+
|
30
|
+
# Write content to scratchpad
|
31
|
+
#
|
32
|
+
# @param file_path [String] Path to store content
|
33
|
+
# @param content [String] Content to store
|
34
|
+
# @param title [String] Brief title describing the content
|
35
|
+
# @raise [ArgumentError] If size limits are exceeded
|
36
|
+
# @return [Entry] The created entry
|
37
|
+
def write(file_path:, content:, title:)
|
38
|
+
raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
|
39
|
+
raise ArgumentError, "content is required" if content.nil?
|
40
|
+
raise ArgumentError, "title is required" if title.nil? || title.to_s.strip.empty?
|
41
|
+
|
42
|
+
content_size = content.bytesize
|
43
|
+
|
44
|
+
# Check entry size limit
|
45
|
+
if content_size > MAX_ENTRY_SIZE
|
46
|
+
raise ArgumentError, "Content exceeds maximum size (#{format_bytes(MAX_ENTRY_SIZE)}). " \
|
47
|
+
"Current: #{format_bytes(content_size)}"
|
48
|
+
end
|
49
|
+
|
50
|
+
# Calculate new total size
|
51
|
+
existing_entry = @entries[file_path]
|
52
|
+
existing_size = existing_entry ? existing_entry.size : 0
|
53
|
+
new_total_size = @total_size - existing_size + content_size
|
54
|
+
|
55
|
+
# Check total size limit
|
56
|
+
if new_total_size > MAX_TOTAL_SIZE
|
57
|
+
raise ArgumentError, "Scratchpad full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
|
58
|
+
"Current: #{format_bytes(@total_size)}, " \
|
59
|
+
"Would be: #{format_bytes(new_total_size)}. " \
|
60
|
+
"Clear old entries or use smaller content."
|
61
|
+
end
|
62
|
+
|
63
|
+
# Create entry
|
64
|
+
entry = Entry.new(
|
65
|
+
content: content,
|
66
|
+
title: title,
|
67
|
+
created_at: Time.now,
|
68
|
+
size: content_size,
|
69
|
+
)
|
70
|
+
|
71
|
+
# Update storage
|
72
|
+
@entries[file_path] = entry
|
73
|
+
@total_size = new_total_size
|
74
|
+
|
75
|
+
entry
|
76
|
+
end
|
77
|
+
|
78
|
+
# Read content from scratchpad
|
79
|
+
#
|
80
|
+
# @param file_path [String] Path to read from
|
81
|
+
# @raise [ArgumentError] If path not found
|
82
|
+
# @return [String] Content at the path
|
83
|
+
def read(file_path:)
|
84
|
+
raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
|
85
|
+
|
86
|
+
entry = @entries[file_path]
|
87
|
+
raise ArgumentError, "scratchpad://#{file_path} not found" unless entry
|
88
|
+
|
89
|
+
entry.content
|
90
|
+
end
|
91
|
+
|
92
|
+
# List scratchpad entries, optionally filtered by prefix
|
93
|
+
#
|
94
|
+
# @param prefix [String, nil] Filter by path prefix
|
95
|
+
# @return [Array<Hash>] Array of entry metadata (path, title, size, created_at)
|
96
|
+
def list(prefix: nil)
|
97
|
+
entries = @entries
|
98
|
+
|
99
|
+
# Filter by prefix if provided
|
100
|
+
if prefix && !prefix.empty?
|
101
|
+
entries = entries.select { |path, _| path.start_with?(prefix) }
|
102
|
+
end
|
103
|
+
|
104
|
+
# Return metadata
|
105
|
+
entries.map do |path, entry|
|
106
|
+
{
|
107
|
+
path: path,
|
108
|
+
title: entry.title,
|
109
|
+
size: entry.size,
|
110
|
+
created_at: entry.created_at,
|
111
|
+
}
|
112
|
+
end.sort_by { |e| e[:path] }
|
113
|
+
end
|
114
|
+
|
115
|
+
# Clear all entries
|
116
|
+
#
|
117
|
+
# @return [void]
|
118
|
+
def clear
|
119
|
+
@entries.clear
|
120
|
+
@total_size = 0
|
121
|
+
end
|
122
|
+
|
123
|
+
# Get current total size
|
124
|
+
#
|
125
|
+
# @return [Integer] Total size in bytes
|
126
|
+
attr_reader :total_size
|
127
|
+
|
128
|
+
# Get number of entries
|
129
|
+
#
|
130
|
+
# @return [Integer] Number of entries
|
131
|
+
def size
|
132
|
+
@entries.size
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
# Format bytes to human-readable size
|
138
|
+
#
|
139
|
+
# @param bytes [Integer] Number of bytes
|
140
|
+
# @return [String] Formatted size (e.g., "1.5MB", "500.0KB")
|
141
|
+
def format_bytes(bytes)
|
142
|
+
if bytes >= 1_000_000
|
143
|
+
"#{(bytes.to_f / 1_000_000).round(1)}MB"
|
144
|
+
elsif bytes >= 1_000
|
145
|
+
"#{(bytes.to_f / 1_000).round(1)}KB"
|
146
|
+
else
|
147
|
+
"#{bytes}B"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
module Stores
|
6
|
+
# TodoManager provides per-agent todo list storage
|
7
|
+
#
|
8
|
+
# Each agent maintains its own independent todo list that persists
|
9
|
+
# throughout the agent's execution session. This allows agents to
|
10
|
+
# track progress on complex multi-step tasks.
|
11
|
+
class TodoManager
|
12
|
+
@storage = {}
|
13
|
+
@mutex = Mutex.new
|
14
|
+
|
15
|
+
class << self
|
16
|
+
# Get the current todo list for an agent
|
17
|
+
#
|
18
|
+
# @param agent_id [Symbol, String] Unique agent identifier
|
19
|
+
# @return [Array<Hash>] Array of todo items
|
20
|
+
def get_todos(agent_id)
|
21
|
+
@mutex.synchronize do
|
22
|
+
@storage[agent_id.to_sym] ||= []
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Set the todo list for an agent
|
27
|
+
#
|
28
|
+
# @param agent_id [Symbol, String] Unique agent identifier
|
29
|
+
# @param todos [Array<Hash>] Array of todo items
|
30
|
+
# @return [Array<Hash>] The stored todos
|
31
|
+
def set_todos(agent_id, todos)
|
32
|
+
@mutex.synchronize do
|
33
|
+
@storage[agent_id.to_sym] = todos
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Clear all todos for an agent
|
38
|
+
#
|
39
|
+
# @param agent_id [Symbol, String] Unique agent identifier
|
40
|
+
def clear_todos(agent_id)
|
41
|
+
@mutex.synchronize do
|
42
|
+
@storage.delete(agent_id.to_sym)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Clear all todos for all agents
|
47
|
+
def clear_all
|
48
|
+
@mutex.synchronize do
|
49
|
+
@storage.clear
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Get summary of all agent todo lists
|
54
|
+
#
|
55
|
+
# @return [Hash] Map of agent_id => todo count
|
56
|
+
def summary
|
57
|
+
@mutex.synchronize do
|
58
|
+
@storage.transform_values(&:size)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,216 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SwarmSDK
|
4
|
+
module Tools
|
5
|
+
# TodoWrite tool for creating and managing structured task lists
|
6
|
+
#
|
7
|
+
# This tool helps agents track progress on complex multi-step tasks.
|
8
|
+
# Each agent maintains its own independent todo list.
|
9
|
+
class TodoWrite < RubyLLM::Tool
|
10
|
+
description <<~DESC
|
11
|
+
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
|
12
|
+
It also helps the user understand the progress of the task and overall progress of their requests.
|
13
|
+
|
14
|
+
## When to Use This Tool
|
15
|
+
Use this tool proactively in these scenarios:
|
16
|
+
|
17
|
+
**CRITICAL**: Follow this workflow for multi-step tasks:
|
18
|
+
1. FIRST: Analyze the task scope (search files, read code, understand requirements)
|
19
|
+
2. SECOND: Create a COMPLETE todo list with ALL known tasks BEFORE starting implementation
|
20
|
+
3. THIRD: Execute tasks, marking in_progress → completed as you work
|
21
|
+
4. ONLY add new todos if unexpected work is discovered during implementation
|
22
|
+
|
23
|
+
Use the todo list when:
|
24
|
+
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
|
25
|
+
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
|
26
|
+
3. User explicitly requests todo list - When the user directly asks you to use the todo list
|
27
|
+
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
|
28
|
+
5. After receiving new instructions - After analyzing scope, create complete todo list before starting work
|
29
|
+
6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time
|
30
|
+
7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
|
31
|
+
|
32
|
+
## When NOT to Use This Tool
|
33
|
+
|
34
|
+
Skip using this tool when:
|
35
|
+
1. There is only a single, straightforward task
|
36
|
+
2. The task is trivial and tracking it provides no organizational benefit
|
37
|
+
3. The task can be completed in less than 3 trivial steps
|
38
|
+
4. The task is purely conversational or informational
|
39
|
+
|
40
|
+
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
|
41
|
+
|
42
|
+
## Task States and Management
|
43
|
+
|
44
|
+
1. **Task States**: Use these states to track progress:
|
45
|
+
- pending: Task not yet started
|
46
|
+
- in_progress: Currently working on (limit to ONE task at a time)
|
47
|
+
- completed: Task finished successfully
|
48
|
+
|
49
|
+
**IMPORTANT**: Task descriptions must have two forms:
|
50
|
+
- content: The imperative form describing what needs to be done (e.g., "Run tests", "Build the project")
|
51
|
+
- activeForm: The present continuous form shown during execution (e.g., "Running tests", "Building the project")
|
52
|
+
|
53
|
+
2. **Task Management**:
|
54
|
+
- Update task status in real-time as you work
|
55
|
+
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
|
56
|
+
- Exactly ONE task must be in_progress at any time (not less, not more)
|
57
|
+
- Complete current tasks before starting new ones
|
58
|
+
- Remove tasks that are no longer relevant from the list entirely
|
59
|
+
- **CRITICAL**: You MUST complete ALL pending todos before giving your final answer to the user
|
60
|
+
- NEVER leave in_progress or pending tasks when you finish responding
|
61
|
+
|
62
|
+
3. **Task Completion Requirements**:
|
63
|
+
- ONLY mark a task as completed when you have FULLY accomplished it
|
64
|
+
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
|
65
|
+
- When blocked, create a new task describing what needs to be resolved
|
66
|
+
- Never mark a task as completed if:
|
67
|
+
- Tests are failing
|
68
|
+
- Implementation is partial
|
69
|
+
- You encountered unresolved errors
|
70
|
+
- You couldn't find necessary files or dependencies
|
71
|
+
|
72
|
+
4. **Task Breakdown**:
|
73
|
+
- Create specific, actionable items
|
74
|
+
- Break complex tasks into smaller, manageable steps
|
75
|
+
- Use clear, descriptive task names
|
76
|
+
- Always provide both forms:
|
77
|
+
- content: "Fix authentication bug"
|
78
|
+
- activeForm: "Fixing authentication bug"
|
79
|
+
|
80
|
+
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
|
81
|
+
DESC
|
82
|
+
|
83
|
+
param :todos_json,
|
84
|
+
type: "string",
|
85
|
+
desc: <<~DESC.chomp,
|
86
|
+
JSON array of todo objects. Each todo must have:
|
87
|
+
content (string, task in imperative form like 'Run tests'),
|
88
|
+
status (string, one of: 'pending', 'in_progress', 'completed'),
|
89
|
+
activeForm (string, task in present continuous form like 'Running tests').
|
90
|
+
Example: [{"content":"Read file","status":"pending","activeForm":"Reading file"}]
|
91
|
+
DESC
|
92
|
+
required: true
|
93
|
+
|
94
|
+
# Initialize the TodoWrite tool for a specific agent
|
95
|
+
#
|
96
|
+
# @param agent_name [Symbol, String] The agent identifier
|
97
|
+
def initialize(agent_name:)
|
98
|
+
super()
|
99
|
+
@agent_name = agent_name.to_sym
|
100
|
+
end
|
101
|
+
|
102
|
+
# Override name to return simple "TodoWrite" instead of full class path
|
103
|
+
def name
|
104
|
+
"TodoWrite"
|
105
|
+
end
|
106
|
+
|
107
|
+
def execute(todos_json:)
|
108
|
+
# Parse JSON
|
109
|
+
todos = begin
|
110
|
+
JSON.parse(todos_json)
|
111
|
+
rescue JSON::ParserError
|
112
|
+
nil
|
113
|
+
end
|
114
|
+
|
115
|
+
return validation_error("Invalid JSON format. Please provide a valid JSON array of todo objects.") if todos.nil?
|
116
|
+
|
117
|
+
# Validate todos structure
|
118
|
+
unless todos.is_a?(Array)
|
119
|
+
return validation_error("todos must be an array of todo objects")
|
120
|
+
end
|
121
|
+
|
122
|
+
if todos.empty?
|
123
|
+
return validation_error("todos array cannot be empty")
|
124
|
+
end
|
125
|
+
|
126
|
+
validated_todos = []
|
127
|
+
errors = []
|
128
|
+
|
129
|
+
todos.each_with_index do |todo, index|
|
130
|
+
unless todo.is_a?(Hash)
|
131
|
+
errors << "Todo at index #{index} must be a hash/object"
|
132
|
+
next
|
133
|
+
end
|
134
|
+
|
135
|
+
# Convert string keys to symbols for consistency
|
136
|
+
todo = todo.transform_keys(&:to_sym) if todo.is_a?(Hash)
|
137
|
+
|
138
|
+
# Validate required fields
|
139
|
+
unless todo[:content]
|
140
|
+
errors << "Todo at index #{index} missing required field 'content'"
|
141
|
+
next
|
142
|
+
end
|
143
|
+
|
144
|
+
unless todo[:status]
|
145
|
+
errors << "Todo at index #{index} missing required field 'status'"
|
146
|
+
next
|
147
|
+
end
|
148
|
+
|
149
|
+
unless todo[:activeForm]
|
150
|
+
errors << "Todo at index #{index} missing required field 'activeForm'"
|
151
|
+
next
|
152
|
+
end
|
153
|
+
|
154
|
+
# Validate status values
|
155
|
+
valid_statuses = ["pending", "in_progress", "completed"]
|
156
|
+
unless valid_statuses.include?(todo[:status].to_s)
|
157
|
+
errors << "Todo at index #{index} has invalid status '#{todo[:status]}'. Must be one of: #{valid_statuses.join(", ")}"
|
158
|
+
next
|
159
|
+
end
|
160
|
+
|
161
|
+
# Validate content and activeForm are non-empty
|
162
|
+
if todo[:content].to_s.strip.empty?
|
163
|
+
errors << "Todo at index #{index} has empty content"
|
164
|
+
next
|
165
|
+
end
|
166
|
+
|
167
|
+
if todo[:activeForm].to_s.strip.empty?
|
168
|
+
errors << "Todo at index #{index} has empty activeForm"
|
169
|
+
next
|
170
|
+
end
|
171
|
+
|
172
|
+
validated_todos << {
|
173
|
+
content: todo[:content].to_s,
|
174
|
+
status: todo[:status].to_s,
|
175
|
+
activeForm: todo[:activeForm].to_s,
|
176
|
+
}
|
177
|
+
end
|
178
|
+
|
179
|
+
return validation_error("TodoWrite failed due to the following issues:\n#{errors.join("\n")}") unless errors.empty?
|
180
|
+
|
181
|
+
# Check that exactly one task is in_progress (with helpful message)
|
182
|
+
in_progress_count = validated_todos.count { |t| t[:status] == "in_progress" }
|
183
|
+
warning_message = if in_progress_count == 0
|
184
|
+
"Warning: No tasks marked as in_progress. You should have exactly ONE task in_progress at a time.\n" \
|
185
|
+
"Please mark the task you're currently working on as in_progress.\n\n"
|
186
|
+
elsif in_progress_count > 1
|
187
|
+
"Warning: Multiple tasks marked as in_progress (#{in_progress_count} tasks).\n" \
|
188
|
+
"You should have exactly ONE task in_progress at a time.\n" \
|
189
|
+
"Please ensure only the current task is in_progress, others should be pending or completed.\n\n"
|
190
|
+
else
|
191
|
+
""
|
192
|
+
end
|
193
|
+
|
194
|
+
# Store the validated todos
|
195
|
+
Stores::TodoManager.set_todos(@agent_name, validated_todos)
|
196
|
+
|
197
|
+
<<~RESPONSE
|
198
|
+
<system-reminder>
|
199
|
+
#{warning_message}Your todo list has changed. DO NOT mention this explicitly to the user. Here are the latest contents of your todo list:
|
200
|
+
#{validated_todos.map { |t| "- #{t[:content]} (#{t[:status]})" }.join("\n")}
|
201
|
+
Continue on with the tasks at hand if applicable.
|
202
|
+
</system-reminder>
|
203
|
+
RESPONSE
|
204
|
+
rescue StandardError => e
|
205
|
+
"Error managing todos: #{e.class.name} - #{e.message}"
|
206
|
+
end
|
207
|
+
|
208
|
+
private
|
209
|
+
|
210
|
+
# Helper method for validation errors
|
211
|
+
def validation_error(message)
|
212
|
+
"<tool_use_error>InputValidationError: #{message}</tool_use_error>"
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|