nanobot 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.
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require 'logger'
5
+ require_relative 'memory'
6
+
7
+ module Nanobot
8
+ module Agent
9
+ # ContextBuilder assembles the system prompt and message context for LLM calls
10
+ class ContextBuilder
11
+ BOOTSTRAP_FILES = %w[AGENTS.md SOUL.md USER.md TOOLS.md IDENTITY.md].freeze
12
+
13
+ attr_reader :workspace, :memory_store
14
+
15
+ # @param workspace [String, Pathname] path to the agent workspace directory
16
+ # @param logger [Logger, nil] optional logger instance
17
+ def initialize(workspace, logger: nil)
18
+ @workspace = Pathname.new(workspace).expand_path
19
+ @memory_store = MemoryStore.new(@workspace)
20
+ @logger = logger || Logger.new(IO::NULL)
21
+ end
22
+
23
+ # Build the complete system prompt
24
+ # @return [String]
25
+ def build_system_prompt
26
+ parts = []
27
+
28
+ # Runtime information
29
+ parts << build_runtime_info
30
+
31
+ # Bootstrap files
32
+ BOOTSTRAP_FILES.each do |filename|
33
+ content = read_bootstrap_file(filename)
34
+ if content
35
+ parts << content
36
+ @logger.debug "Loaded bootstrap file: #{filename} (#{content.length} chars)"
37
+ else
38
+ @logger.debug "Bootstrap file not found or empty: #{filename}"
39
+ end
40
+ end
41
+
42
+ # Memory context
43
+ memory_context = @memory_store.get_memory_context
44
+ if memory_context
45
+ parts << "# Memory\n\n#{memory_context}"
46
+ @logger.debug "Loaded memory context (#{memory_context.length} chars)"
47
+ end
48
+
49
+ prompt = parts.join("\n\n---\n\n")
50
+ @logger.debug "System prompt assembled: #{prompt.length} chars total"
51
+ prompt
52
+ end
53
+
54
+ # Build messages array for LLM
55
+ # @param history [Array<Hash>] conversation history
56
+ # @param current_message [String] current user message
57
+ # @param channel [String, nil] current channel name
58
+ # @param chat_id [String, nil] current chat ID
59
+ # @return [Array<Hash>] messages in OpenAI format
60
+ def build_messages(current_message:, history: [], channel: nil, chat_id: nil)
61
+ messages = []
62
+
63
+ # System prompt
64
+ system_prompt = build_system_prompt
65
+ messages << { role: 'system', content: system_prompt }
66
+
67
+ # Add history
68
+ history.each do |msg|
69
+ messages << msg
70
+ end
71
+ @logger.debug "History: #{history.length} messages"
72
+
73
+ # Add channel context as a separate system message if provided
74
+ if channel && chat_id
75
+ messages << { role: 'system', content: "Current channel: #{channel}, Chat ID: #{chat_id}" }
76
+ end
77
+
78
+ # Add current message
79
+ messages << { role: 'user', content: current_message }
80
+ @logger.debug "Built #{messages.length} messages for LLM"
81
+
82
+ messages
83
+ end
84
+
85
+ # Add a tool result to messages
86
+ # @param messages [Array<Hash>] existing messages
87
+ # @param tool_call_id [String] ID of the tool call
88
+ # @param tool_name [String] name of the tool
89
+ # @param result [String] tool execution result
90
+ # @return [Array<Hash>] updated messages
91
+ def add_tool_result(messages, tool_call_id, tool_name, result)
92
+ messages << {
93
+ role: 'tool',
94
+ tool_call_id: tool_call_id,
95
+ name: tool_name,
96
+ content: result
97
+ }
98
+ messages
99
+ end
100
+
101
+ private
102
+
103
+ # Assemble OS, Ruby, and workspace info for the system prompt
104
+ # @return [String]
105
+ def build_runtime_info
106
+ <<~INFO
107
+ # Runtime Information
108
+
109
+ - OS: #{RbConfig::CONFIG['host_os']}
110
+ - Ruby Version: #{RUBY_VERSION}
111
+ - Platform: #{RUBY_PLATFORM}
112
+ - Workspace: #{@workspace}
113
+ - Current Time: #{Time.now.strftime('%Y-%m-%d %H:%M:%S %Z')}
114
+ INFO
115
+ end
116
+
117
+ # Read a bootstrap markdown file from the workspace root
118
+ # @param filename [String] name of the file (e.g. 'SOUL.md')
119
+ # @return [String, nil] file content with header, or nil if missing/empty
120
+ def read_bootstrap_file(filename)
121
+ file_path = @workspace / filename
122
+ return nil unless file_path.exist?
123
+
124
+ content = file_path.read.strip
125
+ return nil if content.empty?
126
+
127
+ # Return with header
128
+ "# #{filename.sub('.md', '')}\n\n#{content}"
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require_relative 'context'
5
+ require_relative '../session/manager'
6
+ require_relative '../bus/events'
7
+
8
+ module Nanobot
9
+ module Agent
10
+ # AgentLoop is the core processing engine that handles message->response pipeline
11
+ class Loop
12
+ attr_reader :bus, :provider, :workspace, :logger
13
+
14
+ # @param bus [Bus::MessageBus] message bus for inbound/outbound messages
15
+ # @param provider [Provider] LLM provider instance
16
+ # @param workspace [String, Pathname] path to the agent workspace directory
17
+ # @param logger [Logger, nil] optional logger instance
18
+ # @param opts [Hash] additional options:
19
+ # :model [String] LLM model override,
20
+ # :max_iterations [Integer] max tool-call loop iterations (default 20),
21
+ # :brave_api_key [String] Brave search API key,
22
+ # :exec_config [Hash] shell exec configuration,
23
+ # :restrict_to_workspace [Boolean] restrict file tools to workspace,
24
+ # :confirm_tool_call [Proc] callback to confirm tool execution,
25
+ # :schedule_store [Scheduler::ScheduleStore] schedule store for scheduling tools
26
+ def initialize(bus:, provider:, workspace:, logger: nil, **opts)
27
+ @bus = bus
28
+ @provider = provider
29
+ @workspace = Pathname.new(workspace).expand_path
30
+ @model = opts[:model] || provider.default_model
31
+ @max_iterations = opts[:max_iterations] || 20
32
+ @brave_api_key = opts[:brave_api_key]
33
+ @exec_config = opts[:exec_config] || {}
34
+ @restrict_to_workspace = opts.fetch(:restrict_to_workspace, true)
35
+ @confirm_tool_call = opts[:confirm_tool_call]
36
+ @schedule_store = opts[:schedule_store]
37
+ @logger = logger || Logger.new(IO::NULL)
38
+
39
+ @context = ContextBuilder.new(@workspace, logger: @logger)
40
+ @sessions = Session::Manager.new(@workspace)
41
+ @running = false
42
+
43
+ register_default_tools
44
+ end
45
+
46
+ # Start the agent loop (consumes from bus)
47
+ def run
48
+ @running = true
49
+ @logger.info 'Agent loop started'
50
+
51
+ loop do
52
+ break unless @running
53
+
54
+ begin
55
+ msg = @bus.consume_inbound(timeout: 1)
56
+ next unless msg
57
+
58
+ response = process_message(msg)
59
+ @bus.publish_outbound(response) if response
60
+ rescue StandardError => e
61
+ @logger.error "Error processing message: #{e.message}"
62
+ @logger.error e.backtrace.join("\n")
63
+ end
64
+ end
65
+ end
66
+
67
+ # Stop the agent loop
68
+ def stop
69
+ @running = false
70
+ @logger.info 'Agent loop stopping'
71
+ end
72
+
73
+ # Process a single message directly (for CLI/testing)
74
+ # @param content [String] message content
75
+ # @param channel [String] channel name (default: 'cli')
76
+ # @param chat_id [String] chat ID (default: 'default')
77
+ # @return [String] response content
78
+ def process_direct(content, channel: 'cli', chat_id: 'default')
79
+ msg = Bus::InboundMessage.new(
80
+ channel: channel,
81
+ sender_id: 'user',
82
+ chat_id: chat_id,
83
+ content: content
84
+ )
85
+
86
+ response = process_message(msg)
87
+ response&.content
88
+ end
89
+
90
+ # Process a message and return a response
91
+ # @param msg [Bus::InboundMessage] inbound message
92
+ # @return [Bus::OutboundMessage, nil] outbound message
93
+ # This method orchestrates message processing and requires complexity
94
+ def process_message(msg)
95
+ @logger.info "Processing message from #{msg.channel}:#{msg.chat_id}"
96
+
97
+ # Handle slash commands before LLM processing
98
+ slash_response = handle_slash_command(msg)
99
+ return slash_response if slash_response
100
+
101
+ # Get or create session
102
+ session = @sessions.get_or_create(msg.session_key)
103
+
104
+ # Build messages for LLM
105
+ messages = @context.build_messages(
106
+ history: session.get_history,
107
+ current_message: msg.content,
108
+ channel: msg.channel,
109
+ chat_id: msg.chat_id
110
+ )
111
+
112
+ # Save user message to session
113
+ session.add_message('user', msg.content)
114
+
115
+ # Agent loop (also saves intermediate tool call messages to session)
116
+ final_content = agent_loop(messages, session: session, channel: msg.channel)
117
+
118
+ # Save final assistant response to session
119
+ session.add_message('assistant', final_content)
120
+ @sessions.save(session)
121
+
122
+ # Return response
123
+ Bus::OutboundMessage.new(
124
+ channel: msg.channel,
125
+ chat_id: msg.chat_id,
126
+ content: final_content
127
+ )
128
+ rescue StandardError => e
129
+ handle_processing_error(msg, e)
130
+ end
131
+
132
+ # Channels considered local (full tool access by default)
133
+ LOCAL_CHANNELS = %w[cli].freeze
134
+
135
+ private
136
+
137
+ # Build an error response, sanitizing details for remote channels.
138
+ # @param msg [Bus::InboundMessage] the original message
139
+ # @param error [StandardError] the exception that occurred
140
+ # @return [Bus::OutboundMessage]
141
+ def handle_processing_error(msg, error)
142
+ @logger.error "Error in process_message: #{error.message}"
143
+ @logger.error error.backtrace.join("\n")
144
+
145
+ # Don't leak exception details (file paths, credentials, stack traces)
146
+ # to remote channels. Local CLI gets the full message for debugging.
147
+ error_content = if LOCAL_CHANNELS.include?(msg.channel)
148
+ "Sorry, I encountered an error: #{error.message}"
149
+ else
150
+ 'Sorry, I encountered an internal error. Check the server logs for details.'
151
+ end
152
+
153
+ Bus::OutboundMessage.new(channel: msg.channel, chat_id: msg.chat_id, content: error_content)
154
+ end
155
+
156
+ # Main agent loop: repeatedly calls LLM and executes tool calls until
157
+ # the LLM returns a final text response or max_iterations is reached
158
+ # @param messages [Array<Hash>] message history in OpenAI format
159
+ # @param session [Session::Session, nil] current session for persistence
160
+ # @param channel [String] channel name for tool scoping
161
+ # @return [String] final response content
162
+ def agent_loop(messages, session: nil, channel: 'cli')
163
+ tools = tools_for_channel(channel)
164
+ iteration = 0
165
+
166
+ while iteration < @max_iterations
167
+ iteration += 1
168
+ @logger.debug "--- Agent loop iteration #{iteration}/#{@max_iterations} ---"
169
+ @logger.debug "Message history size: #{messages.length} messages"
170
+
171
+ response = @provider.chat(messages: messages, tools: tools, model: @model)
172
+
173
+ if response.finish_reason == 'error'
174
+ @logger.error "LLM API error: #{response.content}"
175
+ return "[LLM Error] #{response.content}"
176
+ end
177
+
178
+ unless response.tool_calls?
179
+ @logger.debug "No tool calls, agent loop complete after #{iteration} iteration(s)"
180
+ return response.content || "I've completed processing."
181
+ end
182
+
183
+ process_tool_calls(response, messages, session, tools)
184
+ end
185
+
186
+ @logger.warn "Max iterations (#{@max_iterations}) reached"
187
+ "I've completed processing but reached the maximum iteration limit."
188
+ end
189
+
190
+ # Append assistant tool-call message and execute each tool call
191
+ # @param response [Provider::Response] LLM response containing tool calls
192
+ # @param messages [Array<Hash>] message history to append to
193
+ # @param session [Session::Session, nil] session for persistence
194
+ # @param tools [Array<RubyLLM::Tool>] tools available for this request
195
+ def process_tool_calls(response, messages, session, tools)
196
+ @logger.debug "Got #{response.tool_calls.length} tool call(s)"
197
+
198
+ payload = serialize_tool_calls(response.tool_calls)
199
+ assistant_msg = { role: 'assistant', content: response.content || '', tool_calls: payload }
200
+ messages << assistant_msg
201
+ session&.add_message('assistant', response.content || '', tool_calls: payload)
202
+
203
+ response.tool_calls.each do |tool_call|
204
+ result_str = execute_tool_call(tool_call, tools)
205
+ messages << { role: 'tool', tool_call_id: tool_call.id, name: tool_call.name, content: result_str }
206
+ session&.add_message('tool', result_str)
207
+ end
208
+ end
209
+
210
+ # Convert tool call objects to OpenAI-compatible hash format
211
+ # @param tool_calls [Array] tool call objects from provider response
212
+ # @return [Array<Hash>] serialized tool calls
213
+ def serialize_tool_calls(tool_calls)
214
+ tool_calls.map do |tc|
215
+ hash = { id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.generate(tc.arguments) } }
216
+ hash[:thought_signature] = tc.thought_signature if tc.respond_to?(:thought_signature) && tc.thought_signature
217
+ hash
218
+ end
219
+ end
220
+
221
+ # Execute a single tool call, checking user confirmation if configured
222
+ # @param tool_call [Object] tool call with #name, #id, and #arguments
223
+ # @param tools [Array<RubyLLM::Tool>] tools available for this request
224
+ # @return [String] tool execution result
225
+ def execute_tool_call(tool_call, tools)
226
+ @logger.debug "Executing tool: #{tool_call.name} id=#{tool_call.id}"
227
+ @logger.debug " Arguments: #{tool_call.arguments}"
228
+
229
+ result_str = if @confirm_tool_call && !@confirm_tool_call.call(tool_call.name, tool_call.arguments)
230
+ 'Error: Tool execution was denied by user.'
231
+ else
232
+ run_tool(tool_call, tools)
233
+ end
234
+
235
+ @logger.debug " Result (#{result_str.length} chars): #{result_str.slice(0, 1000)}"
236
+ result_str
237
+ end
238
+
239
+ # Look up and invoke the tool by name from the scoped tool set.
240
+ # Only tools in the provided set can be invoked — this enforces
241
+ # per-channel tool restrictions even if the LLM hallucinates tool names.
242
+ # @param tool_call [Object] tool call with #name and #arguments
243
+ # @param tools [Array<RubyLLM::Tool>] scoped tool set
244
+ # @return [String] tool result or error message
245
+ def run_tool(tool_call, tools)
246
+ tool = tools.find { |t| t.name == tool_call.name }
247
+ return "Error: Tool '#{tool_call.name}' not found" unless tool
248
+
249
+ result = tool.call(tool_call.arguments)
250
+ result.is_a?(String) ? result : result.to_s
251
+ end
252
+
253
+ # Handle built-in slash commands (/new, /help) before LLM processing
254
+ # @param msg [Bus::InboundMessage]
255
+ # @return [Bus::OutboundMessage, nil] response if command matched, nil otherwise
256
+ def handle_slash_command(msg)
257
+ case msg.content.strip
258
+ when '/new'
259
+ session = @sessions.get_or_create(msg.session_key)
260
+ session.clear
261
+ @sessions.save(session)
262
+ Bus::OutboundMessage.new(channel: msg.channel, chat_id: msg.chat_id,
263
+ content: 'New session started.')
264
+ when '/help'
265
+ Bus::OutboundMessage.new(channel: msg.channel, chat_id: msg.chat_id,
266
+ content: help_text)
267
+ end
268
+ end
269
+
270
+ # @return [String] formatted help text listing available commands
271
+ def help_text
272
+ "Available commands:\n " \
273
+ "/new - Start a new conversation session\n " \
274
+ "/help - Show this help message\n\n" \
275
+ 'Send any other message to chat with the AI assistant.'
276
+ end
277
+
278
+ # Return the tools available for a given channel.
279
+ # Remote channels (Telegram, Discord, Slack, Email, Gateway) get
280
+ # read-only tools by default to limit blast radius from prompt
281
+ # injection or unauthorized senders.
282
+ # @param channel [String] channel name
283
+ # @return [Array<RubyLLM::Tool>] tools available for this channel
284
+ def tools_for_channel(channel)
285
+ if LOCAL_CHANNELS.include?(channel)
286
+ @tool_instances
287
+ else
288
+ @read_only_tools
289
+ end
290
+ end
291
+
292
+ # Register default tools (RubyLLM-compatible)
293
+ def register_default_tools
294
+ require_relative 'tools/filesystem'
295
+ require_relative 'tools/shell'
296
+ require_relative 'tools/web'
297
+
298
+ allowed_dir = @restrict_to_workspace ? @workspace : nil
299
+
300
+ # Read-only tools (safe for remote channels)
301
+ read_file = Tools::ReadFile.new(allowed_dir: allowed_dir)
302
+ list_dir = Tools::ListDir.new(allowed_dir: allowed_dir)
303
+ web_search = @brave_api_key ? Tools::WebSearch.new(api_key: @brave_api_key) : nil
304
+ web_fetch = Tools::WebFetch.new
305
+
306
+ @read_only_tools = [read_file, list_dir, web_fetch]
307
+ @read_only_tools << web_search if web_search
308
+
309
+ # Write/exec tools (local channels only by default)
310
+ write_file = Tools::WriteFile.new(allowed_dir: allowed_dir)
311
+ edit_file = Tools::EditFile.new(allowed_dir: allowed_dir)
312
+ exec_tool = Tools::Exec.new(
313
+ working_dir: @workspace.to_s,
314
+ timeout: @exec_config[:timeout] || 60,
315
+ restrict_to_workspace: @restrict_to_workspace
316
+ )
317
+
318
+ # Full tool set
319
+ @tool_instances = @read_only_tools + [write_file, edit_file, exec_tool]
320
+
321
+ if @schedule_store
322
+ require_relative 'tools/schedule'
323
+ schedule_tools = [
324
+ Tools::ScheduleAdd.new(store: @schedule_store),
325
+ Tools::ScheduleList.new(store: @schedule_store),
326
+ Tools::ScheduleRemove.new(store: @schedule_store)
327
+ ]
328
+ @tool_instances += schedule_tools
329
+ @read_only_tools << Tools::ScheduleList.new(store: @schedule_store)
330
+ end
331
+
332
+ @logger.info "Registered #{@tool_instances.length} tools (#{@read_only_tools.length} read-only)"
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Nanobot
6
+ module Agent
7
+ # MemoryStore manages persistent memory for the agent
8
+ # Supports both long-term memory and daily notes
9
+ class MemoryStore
10
+ attr_reader :workspace
11
+
12
+ # @param workspace [String, Pathname] path to the agent workspace directory
13
+ def initialize(workspace)
14
+ @workspace = Pathname.new(workspace).expand_path
15
+ @memory_dir = @workspace / 'memory'
16
+ @memory_dir.mkpath unless @memory_dir.exist?
17
+ end
18
+
19
+ # Read long-term memory
20
+ # @return [String, nil] content of MEMORY.md or nil if not exists
21
+ def read_long_term
22
+ memory_file = @memory_dir / 'MEMORY.md'
23
+ return nil unless memory_file.exist?
24
+
25
+ memory_file.read
26
+ end
27
+
28
+ # Write to long-term memory
29
+ # @param content [String] content to write
30
+ def write_long_term(content)
31
+ memory_file = @memory_dir / 'MEMORY.md'
32
+ memory_file.write(content)
33
+ end
34
+
35
+ # Append to long-term memory
36
+ # @param content [String] content to append
37
+ def append_long_term(content)
38
+ memory_file = @memory_dir / 'MEMORY.md'
39
+ current = memory_file.exist? ? memory_file.read : ''
40
+
41
+ # Add separator if there's existing content
42
+ separator = current.empty? ? '' : "\n\n"
43
+ memory_file.write(current + separator + content)
44
+ end
45
+
46
+ # Read today's daily note
47
+ # @return [String, nil] content of today's note or nil if not exists
48
+ def read_today
49
+ file = today_file
50
+ return nil unless file.exist?
51
+
52
+ file.read
53
+ end
54
+
55
+ # Write to today's daily note
56
+ # @param content [String] content to write
57
+ def write_today(content)
58
+ file = today_file
59
+ file.write(content)
60
+ end
61
+
62
+ # Append to today's daily note
63
+ # @param content [String] content to append
64
+ # @param timestamp [Boolean] whether to add timestamp (default: true)
65
+ def append_today(content, timestamp: true)
66
+ file = today_file
67
+ current = file.exist? ? file.read : "# Daily Notes - #{Date.today}\n\n"
68
+
69
+ entry = if timestamp
70
+ "\n## #{Time.now.strftime('%H:%M:%S')}\n\n#{content}"
71
+ else
72
+ "\n#{content}"
73
+ end
74
+
75
+ today_file.write(current + entry)
76
+ end
77
+
78
+ # Get memory context for agent prompts
79
+ # @param include_today [Boolean] whether to include today's notes
80
+ # @return [String] formatted memory context
81
+ def get_memory_context(include_today: true)
82
+ parts = []
83
+
84
+ # Long-term memory
85
+ long_term = read_long_term
86
+ parts << "## Long-term Memory\n\n#{long_term}" if long_term && !long_term.strip.empty?
87
+
88
+ # Today's notes
89
+ if include_today
90
+ today = read_today
91
+ parts << "## Today's Notes\n\n#{today}" if today && !today.strip.empty?
92
+ end
93
+
94
+ return nil if parts.empty?
95
+
96
+ parts.join("\n\n---\n\n")
97
+ end
98
+
99
+ # List all daily notes
100
+ # @return [Array<Hash>] array of {date: Date, path: Pathname}
101
+ def list_daily_notes
102
+ notes = @memory_dir.glob('????-??-??.md').map do |path|
103
+ date_str = path.basename('.md').to_s
104
+ {
105
+ date: Date.parse(date_str),
106
+ path: path
107
+ }
108
+ end
109
+ notes.sort_by { |n| n[:date] }.reverse
110
+ end
111
+
112
+ # Read a specific daily note
113
+ # @param date [Date, String] the date to read
114
+ # @return [String, nil]
115
+ def read_daily_note(date)
116
+ date_obj = date.is_a?(Date) ? date : Date.parse(date.to_s)
117
+ note_file = @memory_dir / "#{date_obj.strftime('%Y-%m-%d')}.md"
118
+
119
+ return nil unless note_file.exist?
120
+
121
+ note_file.read
122
+ end
123
+
124
+ private
125
+
126
+ def today_file
127
+ @memory_dir / "#{Date.today.strftime('%Y-%m-%d')}.md"
128
+ end
129
+ end
130
+ end
131
+ end