pocketrb 0.1.0

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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. metadata +327 -0
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Agent
5
+ # Context compaction to summarize long conversations and save tokens
6
+ class Compaction
7
+ # Default thresholds (balanced for context retention)
8
+ DEFAULT_MESSAGE_THRESHOLD = 40 # Compact when exceeding this many messages
9
+ DEFAULT_TOKEN_THRESHOLD = 50_000 # Compact when exceeding this many estimated tokens
10
+ DEFAULT_KEEP_RECENT = 15 # Keep this many recent messages uncompacted
11
+ CHARS_PER_TOKEN = 4 # Rough estimate for token counting
12
+
13
+ COMPACTION_PROMPT = <<~PROMPT
14
+ Summarize this conversation history concisely. Include:
15
+ - Key decisions made
16
+ - Important information learned
17
+ - Current task/goal if any
18
+ - Any pending items or context needed for continuation
19
+
20
+ Keep the summary under 500 words. Focus on information the assistant needs to continue effectively.
21
+ PROMPT
22
+
23
+ attr_reader :provider, :model
24
+ attr_accessor :message_threshold, :token_threshold, :keep_recent
25
+
26
+ def initialize(provider:, model: nil, message_threshold: nil, token_threshold: nil, keep_recent: nil)
27
+ @provider = provider
28
+ @model = model || provider.default_model
29
+ @message_threshold = message_threshold || DEFAULT_MESSAGE_THRESHOLD
30
+ @token_threshold = token_threshold || DEFAULT_TOKEN_THRESHOLD
31
+ @keep_recent = keep_recent || DEFAULT_KEEP_RECENT
32
+ end
33
+
34
+ # Check if compaction is needed for messages
35
+ # @param messages [Array<Message>] Conversation messages
36
+ # @return [Boolean]
37
+ def needs_compaction?(messages)
38
+ return false if messages.size <= @keep_recent
39
+
40
+ messages.size > @message_threshold || estimate_tokens(messages) > @token_threshold
41
+ end
42
+
43
+ # Compact messages by summarizing older ones
44
+ # @param messages [Array<Message>] Conversation messages (excluding system)
45
+ # @return [Array<Message>] Compacted messages
46
+ def compact(messages)
47
+ return messages unless needs_compaction?(messages)
48
+
49
+ # Split messages: older ones to summarize, recent ones to keep
50
+ split_point = [messages.size - @keep_recent, 0].max
51
+ to_summarize = messages[0...split_point]
52
+ to_keep = messages[split_point..]
53
+
54
+ return messages if to_summarize.empty?
55
+
56
+ Pocketrb.logger.info("Compacting #{to_summarize.size} messages into summary")
57
+
58
+ # Generate summary
59
+ summary = generate_summary(to_summarize)
60
+
61
+ # Build compacted message list
62
+ compacted = []
63
+ compacted << build_summary_message(summary)
64
+ compacted.concat(to_keep)
65
+
66
+ compacted
67
+ end
68
+
69
+ # Compact a session's messages in place
70
+ # @param session [Session::Session] Session to compact
71
+ # @return [Boolean] Whether compaction occurred
72
+ def compact_session!(session)
73
+ messages = session.messages.dup
74
+ return false unless needs_compaction?(messages)
75
+
76
+ # Filter out system messages for compaction
77
+ user_assistant_messages = messages.reject { |m| m.role == Providers::Role::SYSTEM }
78
+ return false if user_assistant_messages.size <= @keep_recent
79
+
80
+ compacted = compact(user_assistant_messages)
81
+
82
+ # Update session
83
+ session.messages.clear
84
+ compacted.each { |m| session.messages << m }
85
+
86
+ Pocketrb.logger.info("Session compacted: #{messages.size} -> #{compacted.size} messages")
87
+ true
88
+ end
89
+
90
+ # Estimate token count for messages
91
+ # @param messages [Array<Message>] Messages to count
92
+ # @return [Integer] Estimated tokens
93
+ def estimate_tokens(messages)
94
+ total_chars = messages.sum do |msg|
95
+ content = msg.content
96
+ if content.is_a?(Array)
97
+ # Content blocks (text + media references)
98
+ content.sum do |block|
99
+ if block.is_a?(Hash) && block[:type] == "text"
100
+ block[:text].to_s.length
101
+ elsif block.is_a?(String)
102
+ block.length
103
+ else
104
+ 50 # Estimate for non-text blocks
105
+ end
106
+ end
107
+ else
108
+ content.to_s.length
109
+ end
110
+ end
111
+
112
+ (total_chars / CHARS_PER_TOKEN).to_i
113
+ end
114
+
115
+ private
116
+
117
+ def generate_summary(messages)
118
+ # Format messages for summarization
119
+ formatted = format_for_summary(messages)
120
+
121
+ summary_request = [
122
+ Providers::Message.system(COMPACTION_PROMPT),
123
+ Providers::Message.user("Conversation history to summarize:\n\n#{formatted}")
124
+ ]
125
+
126
+ response = @provider.chat(
127
+ messages: summary_request,
128
+ model: @model,
129
+ max_tokens: 1000
130
+ )
131
+
132
+ response.content || "Previous conversation summary unavailable."
133
+ rescue StandardError => e
134
+ Pocketrb.logger.error("Compaction summary failed: #{e.message}")
135
+ # Fallback: create basic summary
136
+ basic_summary(messages)
137
+ end
138
+
139
+ def format_for_summary(messages)
140
+ messages.map do |msg|
141
+ role = msg.role.capitalize
142
+ content = extract_text_content(msg.content)
143
+ "#{role}: #{content[0..500]}#{"..." if content.length > 500}"
144
+ end.join("\n\n")
145
+ end
146
+
147
+ def extract_text_content(content)
148
+ if content.is_a?(Array)
149
+ content.filter_map do |block|
150
+ if block.is_a?(Hash) && block[:type] == "text"
151
+ block[:text]
152
+ elsif block.is_a?(String)
153
+ block
154
+ end
155
+ end.join("\n")
156
+ else
157
+ content.to_s
158
+ end
159
+ end
160
+
161
+ def build_summary_message(summary)
162
+ Providers::Message.user(
163
+ "[Previous conversation summary]\n#{summary}\n[End of summary - continuing conversation]"
164
+ )
165
+ end
166
+
167
+ def basic_summary(messages)
168
+ # Create a very basic summary without LLM
169
+ user_count = messages.count { |m| m.role == Providers::Role::USER }
170
+ assistant_count = messages.count { |m| m.role == Providers::Role::ASSISTANT }
171
+ tool_count = messages.count { |m| m.role == Providers::Role::TOOL }
172
+
173
+ parts = ["Previous conversation: #{user_count} user messages, #{assistant_count} assistant responses"]
174
+ parts << "#{tool_count} tool calls" if tool_count.positive?
175
+
176
+ # Extract last few user queries for context
177
+ recent_queries = messages.select { |m| m.role == Providers::Role::USER }
178
+ .last(3)
179
+ .map { |m| "- #{extract_text_content(m.content)[0..100]}" }
180
+
181
+ parts << "Recent topics:\n#{recent_queries.join("\n")}" unless recent_queries.empty?
182
+
183
+ parts.join("\n\n")
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Agent
5
+ # Builds context for LLM requests
6
+ class Context
7
+ TOOL_GUIDELINES = <<~PROMPT
8
+ ## Tool Usage Guidelines
9
+
10
+ You have access to tools for interacting with files, executing commands, and searching the web.
11
+
12
+ - Use tools when they would help accomplish the user's request
13
+ - Be concise and direct in your responses
14
+ - When executing commands, explain what you're doing
15
+ - If a task requires multiple steps, plan them out first
16
+ - Report errors clearly and suggest fixes when possible
17
+ - Use the memory tool to store important information for future reference
18
+ - Search memory when the user asks about something you may have learned before
19
+ PROMPT
20
+
21
+ DEFAULT_IDENTITY = <<~PROMPT
22
+ You are Pocketrb, an AI assistant with access to tools for interacting with files, executing commands, and searching the web.
23
+ PROMPT
24
+
25
+ attr_reader :system_prompt, :workspace, :skills_summary
26
+
27
+ def initialize(workspace: nil, system_prompt: nil, skills_summary: nil)
28
+ @workspace = workspace
29
+ @skills_summary = skills_summary
30
+ @system_prompt = system_prompt || build_base_prompt
31
+ end
32
+
33
+ private
34
+
35
+ def build_base_prompt
36
+ parts = []
37
+
38
+ # Load identity from file or use default
39
+ identity = load_workspace_file("IDENTITY.md")
40
+ parts << (identity || DEFAULT_IDENTITY)
41
+
42
+ # Add tool guidelines
43
+ parts << TOOL_GUIDELINES
44
+
45
+ # Load static memory/knowledge if exists
46
+ memory = load_workspace_file("MEMORY.md")
47
+ parts << "## Background Knowledge\n\n#{memory}" if memory
48
+
49
+ parts.join("\n\n")
50
+ end
51
+
52
+ def load_workspace_file(filename)
53
+ return nil unless @workspace
54
+
55
+ path = @workspace.join(filename)
56
+ return nil unless path.exist?
57
+
58
+ content = File.read(path).strip
59
+ content.empty? ? nil : content
60
+ rescue StandardError => e
61
+ Pocketrb.logger.debug("Failed to load #{filename}: #{e.message}")
62
+ nil
63
+ end
64
+
65
+ public
66
+
67
+ # Build the complete message array for an LLM request
68
+ # @param history [Array<Message>] Conversation history
69
+ # @param current [String] Current user message
70
+ # @param media [Array<Bus::Media>] Media attachments
71
+ # @param memory_context [String|nil] Memory context from Memory system
72
+ # @return [Array<Message>]
73
+ def build_messages(history:, current:, media: nil, memory_context: nil)
74
+ messages = []
75
+
76
+ # Add system message with memory context
77
+ messages << build_system_message(memory_context)
78
+
79
+ # Add conversation history (strip media to prevent context bloat)
80
+ messages.concat(strip_media_from_history(history))
81
+
82
+ # Add current user message with media (only current message gets images)
83
+ messages << Providers::Message.user(current, media: media)
84
+
85
+ messages
86
+ end
87
+
88
+ # Build messages for continuing after tool execution
89
+ # @param history [Array<Message>] Full history including tool results
90
+ # @return [Array<Message>]
91
+ def build_continuation(history:, memory_context: nil)
92
+ messages = []
93
+ messages << build_system_message(memory_context)
94
+ messages.concat(history)
95
+ messages
96
+ end
97
+
98
+ # Update system prompt
99
+ def update_system_prompt(prompt)
100
+ @system_prompt = prompt
101
+ end
102
+
103
+ # Add to system prompt
104
+ def append_to_system_prompt(content)
105
+ @system_prompt = "#{@system_prompt}\n\n#{content}"
106
+ end
107
+
108
+ # Update skills summary
109
+ def update_skills_summary(summary)
110
+ @skills_summary = summary
111
+ end
112
+
113
+ private
114
+
115
+ # Strip media from history messages to prevent context bloat
116
+ # Only the current message should include images
117
+ def strip_media_from_history(history)
118
+ history.map do |msg|
119
+ content = msg.content
120
+
121
+ # If content is an array with media blocks, convert to text-only
122
+ if content.is_a?(Array)
123
+ text_parts = content.filter_map do |block|
124
+ if block.is_a?(Hash)
125
+ case block[:type]
126
+ when "text"
127
+ block[:text]
128
+ when "media"
129
+ media = block[:media]
130
+ filename = media.is_a?(Hash) ? media[:filename] : media&.filename
131
+ "[Previous image: #{filename || "attachment"}]"
132
+ end
133
+ elsif block.is_a?(String)
134
+ block
135
+ end
136
+ end
137
+
138
+ # Create new message with text-only content
139
+ Providers::Message.new(
140
+ role: msg.role,
141
+ content: text_parts.join("\n"),
142
+ name: msg.name,
143
+ tool_call_id: msg.tool_call_id,
144
+ tool_calls: msg.tool_calls
145
+ )
146
+ else
147
+ msg
148
+ end
149
+ end
150
+ end
151
+
152
+ def build_system_message(memory_context = nil)
153
+ parts = [@system_prompt]
154
+
155
+ # Add workspace info
156
+ parts << "Working directory: #{@workspace}" if @workspace
157
+
158
+ # Add skills summary
159
+ parts << "Available skills:\n#{@skills_summary}" if @skills_summary && !@skills_summary.empty?
160
+
161
+ # Add memory context if provided
162
+ parts << "Relevant context from memory:\n#{memory_context}" if memory_context && !memory_context.empty?
163
+
164
+ # Add timestamp
165
+ parts << "Current time: #{Time.now.strftime("%Y-%m-%d %H:%M:%S %Z")}"
166
+
167
+ Providers::Message.system(parts.join("\n\n"))
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+
5
+ module Pocketrb
6
+ # Agent module provides the core agentic loop and context management
7
+ module Agent
8
+ # Core agent processing loop
9
+ class Loop
10
+ attr_reader :bus, :provider, :tools, :sessions, :context, :model, :max_iterations, :workspace, :memory_dir,
11
+ :memory, :compaction
12
+
13
+ def initialize(
14
+ bus:,
15
+ provider:,
16
+ workspace:,
17
+ memory_dir: nil,
18
+ model: nil,
19
+ max_iterations: 50,
20
+ system_prompt: nil,
21
+ mcp_endpoint: nil,
22
+ enable_compaction: true,
23
+ compaction_threshold: nil
24
+ )
25
+ @bus = bus
26
+ @provider = provider
27
+ @workspace = Pathname.new(workspace)
28
+ @memory_dir = Pathname.new(memory_dir || workspace)
29
+ @model = model || provider.default_model
30
+ @max_iterations = max_iterations
31
+
32
+ @sessions = Session::Manager.new(storage_dir: @memory_dir.join(".pocketrb", "sessions"))
33
+
34
+ # Initialize simple memory system
35
+ @memory = Memory.new(workspace: @memory_dir)
36
+
37
+ @context = Context.new(
38
+ workspace: @memory_dir,
39
+ system_prompt: system_prompt
40
+ )
41
+
42
+ @tools = Tools::Registry.new(workspace: @workspace, memory_dir: @memory_dir, bus: @bus)
43
+ @tools.register_defaults!
44
+
45
+ # Pass memory instance to tools
46
+ @tools.update_context(memory: @memory)
47
+
48
+ # Load always-on skills into context
49
+ @skills_loader = Skills::Loader.new(workspace: @memory_dir)
50
+ load_always_skills!
51
+
52
+ # Initialize context compaction
53
+ @compaction = if enable_compaction
54
+ Compaction.new(
55
+ provider: @provider,
56
+ model: @model,
57
+ message_threshold: compaction_threshold
58
+ )
59
+ end
60
+
61
+ @running = false
62
+ end
63
+
64
+ # Start the agent loop (async)
65
+ def run
66
+ @running = true
67
+ Pocketrb.logger.info("Agent loop starting with model: #{@model}")
68
+
69
+ Async do
70
+ while @running
71
+ begin
72
+ msg = @bus.consume_inbound
73
+ response = process_message(msg)
74
+ @bus.publish_outbound(response) if response
75
+ rescue StandardError => e
76
+ Pocketrb.logger.error("Error processing message: #{e.message}")
77
+ Pocketrb.logger.error(e.backtrace.first(5).join("\n"))
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ # Stop the agent loop
84
+ def stop
85
+ @running = false
86
+ Pocketrb.logger.info("Agent loop stopping")
87
+ end
88
+
89
+ # Process a single message (synchronous, for direct use)
90
+ # @param msg [Bus::InboundMessage]
91
+ # @return [Bus::OutboundMessage|nil]
92
+ def process_message(msg)
93
+ # Update tools context with current channel info for message/send_file tools
94
+ @tools.update_context(
95
+ default_channel: msg.channel,
96
+ default_chat_id: msg.chat_id
97
+ )
98
+
99
+ session = @sessions.get_or_create(msg.session_key)
100
+ session.add_user_message(msg.content, media: msg.media)
101
+
102
+ publish_state_change(msg.session_key, :idle, :processing)
103
+
104
+ # Compact session history if needed (before building messages)
105
+ if @compaction&.needs_compaction?(session.messages)
106
+ @compaction.compact_session!(session)
107
+ @sessions.save(session)
108
+ end
109
+
110
+ # Get relevant memory context for this message
111
+ memory_context = @memory.relevant_context(msg.content, max_facts: 10)
112
+
113
+ # Build initial messages (with media support)
114
+ messages = @context.build_messages(
115
+ history: session.get_history(max_messages: 50),
116
+ current: msg.content,
117
+ media: msg.media,
118
+ memory_context: memory_context
119
+ )
120
+
121
+ # Drop the last message since we already added it to history
122
+ messages = messages[0..-2] + [session.last_message]
123
+
124
+ iteration = 0
125
+ final_response = nil
126
+
127
+ while iteration < @max_iterations
128
+ iteration += 1
129
+ Pocketrb.logger.debug("Iteration #{iteration}/#{@max_iterations}")
130
+
131
+ response = @provider.chat(
132
+ messages: messages,
133
+ tools: @tools.definitions,
134
+ model: @model
135
+ )
136
+
137
+ if response.has_tool_calls?
138
+ messages = execute_tool_calls(session, messages, response)
139
+ else
140
+ final_response = response
141
+ break
142
+ end
143
+ end
144
+
145
+ if final_response.nil?
146
+ Pocketrb.logger.warn("Max iterations reached without completion")
147
+ final_response = Providers::LLMResponse.new(
148
+ content: "I apologize, but I've reached the maximum number of iterations. Please try breaking down your request into smaller steps."
149
+ )
150
+ end
151
+
152
+ # Save assistant response to session
153
+ session.add_assistant_message(final_response.content, tool_calls: final_response.tool_calls)
154
+ @sessions.save(session)
155
+
156
+ publish_state_change(msg.session_key, :processing, :idle)
157
+
158
+ # Build outbound message
159
+ Bus::OutboundMessage.new(
160
+ channel: msg.channel,
161
+ chat_id: msg.chat_id,
162
+ content: final_response.content || "",
163
+ reply_to: msg.metadata[:message_id]
164
+ )
165
+ end
166
+
167
+ # Register an additional tool
168
+ def register_tool(tool)
169
+ @tools.register(tool)
170
+ end
171
+
172
+ # Update the system prompt
173
+ def update_system_prompt(prompt)
174
+ @context.update_system_prompt(prompt)
175
+ end
176
+
177
+ private
178
+
179
+ def execute_tool_calls(session, messages, response)
180
+ # Add assistant message with tool calls
181
+ assistant_msg = Providers::Message.assistant(
182
+ response.content || "",
183
+ tool_calls: response.tool_calls
184
+ )
185
+ messages << assistant_msg
186
+ session.add_assistant_message(response.content, tool_calls: response.tool_calls)
187
+
188
+ # Execute each tool call
189
+ response.tool_calls.each do |tool_call|
190
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
+
192
+ begin
193
+ result = @tools.execute(tool_call.name, tool_call.arguments)
194
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).to_i
195
+
196
+ publish_tool_event(tool_call, result, nil, duration_ms)
197
+
198
+ # Add tool result to messages
199
+ tool_msg = Providers::Message.tool_result(
200
+ tool_call_id: tool_call.id,
201
+ name: tool_call.name,
202
+ content: result
203
+ )
204
+ messages << tool_msg
205
+ session.add_tool_result(
206
+ tool_call_id: tool_call.id,
207
+ name: tool_call.name,
208
+ content: result
209
+ )
210
+ rescue ToolError => e
211
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).to_i
212
+ error_msg = "Tool error: #{e.message}"
213
+
214
+ publish_tool_event(tool_call, nil, e.message, duration_ms)
215
+
216
+ tool_msg = Providers::Message.tool_result(
217
+ tool_call_id: tool_call.id,
218
+ name: tool_call.name,
219
+ content: error_msg
220
+ )
221
+ messages << tool_msg
222
+ session.add_tool_result(
223
+ tool_call_id: tool_call.id,
224
+ name: tool_call.name,
225
+ content: error_msg
226
+ )
227
+ end
228
+ end
229
+
230
+ @sessions.save(session)
231
+ messages
232
+ end
233
+
234
+ def publish_tool_event(tool_call, result, error, duration_ms)
235
+ event = Bus::ToolExecution.new(
236
+ tool_call_id: tool_call.id,
237
+ name: tool_call.name,
238
+ arguments: tool_call.arguments,
239
+ result: result,
240
+ error: error,
241
+ duration_ms: duration_ms
242
+ )
243
+ @bus.publish_tool_event(event)
244
+ end
245
+
246
+ def publish_state_change(session_key, from, to, reason = nil)
247
+ event = Bus::StateChange.new(
248
+ session_key: session_key,
249
+ from_state: from,
250
+ to_state: to,
251
+ reason: reason
252
+ )
253
+ @bus.publish_state_event(event)
254
+ end
255
+
256
+ # Load skills marked as always: true into the system prompt
257
+ def load_always_skills!
258
+ always_skills = @skills_loader.get_always_skills
259
+ return if always_skills.empty?
260
+
261
+ skill_content = always_skills.map(&:to_prompt).join("\n\n")
262
+ @context.append_to_system_prompt(skill_content)
263
+
264
+ Pocketrb.logger.debug("Loaded #{always_skills.size} always-on skills: #{always_skills.map(&:name).join(", ")}")
265
+ end
266
+
267
+ # Load skills triggered by a message (for context-aware skill loading)
268
+ def load_triggered_skills(text)
269
+ triggered = @skills_loader.get_triggered_skills(text)
270
+ return "" if triggered.empty?
271
+
272
+ triggered.map(&:to_prompt).join("\n\n")
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ module Agent
5
+ # Tool for spawning subagents
6
+ class SpawnTool < Tools::Base
7
+ def name
8
+ "spawn"
9
+ end
10
+
11
+ def description
12
+ "Spawn a subagent to work on a specific task in the background. Useful for delegating independent work or parallel processing."
13
+ end
14
+
15
+ def parameters
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ task: {
20
+ type: "string",
21
+ description: "The task for the subagent to complete"
22
+ },
23
+ skills: {
24
+ type: "array",
25
+ items: { type: "string" },
26
+ description: "Skills to load for this subagent"
27
+ },
28
+ wait: {
29
+ type: "boolean",
30
+ description: "Wait for the subagent to complete before returning (default: false)"
31
+ },
32
+ timeout: {
33
+ type: "integer",
34
+ description: "Timeout in seconds if waiting (default: 300)"
35
+ }
36
+ },
37
+ required: ["task"]
38
+ }
39
+ end
40
+
41
+ def available?
42
+ @context[:subagent_manager] != nil
43
+ end
44
+
45
+ def execute(task:, skills: [], wait: false, timeout: 300)
46
+ manager = @context[:subagent_manager]
47
+ return error("Subagent spawning not available") unless manager
48
+
49
+ origin_channel = @context[:current_channel] || :cli
50
+ origin_chat_id = @context[:current_chat_id] || "main"
51
+
52
+ agent_id = manager.spawn(
53
+ task: task,
54
+ skills: skills,
55
+ origin_channel: origin_channel,
56
+ origin_chat_id: origin_chat_id
57
+ )
58
+
59
+ if wait
60
+ result = manager.wait_for(agent_id, timeout: timeout)
61
+ if result
62
+ success("Subagent #{agent_id} completed:\n\n#{result}")
63
+ else
64
+ error("Subagent #{agent_id} did not complete within timeout")
65
+ end
66
+ else
67
+ success("Spawned subagent #{agent_id} for task: #{task[0..100]}...\nResults will be announced when complete.")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end