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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- 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
|