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
@@ -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