pikuri-core 0.0.3

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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +67 -0
  3. data/lib/pikuri/agent/chat_transport.rb +41 -0
  4. data/lib/pikuri/agent/configurator.rb +270 -0
  5. data/lib/pikuri/agent/context_window_detector.rb +111 -0
  6. data/lib/pikuri/agent/control/cancellable.rb +128 -0
  7. data/lib/pikuri/agent/control/interloper.rb +167 -0
  8. data/lib/pikuri/agent/control/step_limit.rb +93 -0
  9. data/lib/pikuri/agent/control.rb +45 -0
  10. data/lib/pikuri/agent/event.rb +190 -0
  11. data/lib/pikuri/agent/extension.rb +82 -0
  12. data/lib/pikuri/agent/listener/in_memory_event_list.rb +34 -0
  13. data/lib/pikuri/agent/listener/rate_limited.rb +172 -0
  14. data/lib/pikuri/agent/listener/terminal.rb +264 -0
  15. data/lib/pikuri/agent/listener/token_log.rb +216 -0
  16. data/lib/pikuri/agent/listener.rb +54 -0
  17. data/lib/pikuri/agent/listener_list.rb +102 -0
  18. data/lib/pikuri/agent/synthesizer.rb +145 -0
  19. data/lib/pikuri/agent.rb +731 -0
  20. data/lib/pikuri/subprocess.rb +166 -0
  21. data/lib/pikuri/tool/calculator.rb +82 -0
  22. data/lib/pikuri/tool/fetch.rb +171 -0
  23. data/lib/pikuri/tool/parameters.rb +314 -0
  24. data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
  25. data/lib/pikuri/tool/scraper/html.rb +285 -0
  26. data/lib/pikuri/tool/scraper/pdf.rb +54 -0
  27. data/lib/pikuri/tool/scraper/simple.rb +183 -0
  28. data/lib/pikuri/tool/search/brave.rb +184 -0
  29. data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
  30. data/lib/pikuri/tool/search/engines.rb +163 -0
  31. data/lib/pikuri/tool/search/exa.rb +217 -0
  32. data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
  33. data/lib/pikuri/tool/search/result.rb +29 -0
  34. data/lib/pikuri/tool/sub_agent.rb +150 -0
  35. data/lib/pikuri/tool/web_scrape.rb +121 -0
  36. data/lib/pikuri/tool/web_search.rb +38 -0
  37. data/lib/pikuri/tool.rb +118 -0
  38. data/lib/pikuri/url_cache.rb +112 -0
  39. data/lib/pikuri/version.rb +10 -0
  40. data/lib/pikuri-core.rb +177 -0
  41. data/prompts/pikuri-chat.txt +15 -0
  42. metadata +251 -0
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Tool
5
+ # Namespace for the web-search stack used by {Tool::WEB_SEARCH}: per-
6
+ # provider modules ({DuckDuckGo}, {Brave}, {Exa}), the {Result} value
7
+ # object they all return, the cross-provider {Engines} cascade with
8
+ # its on-disk cache, and the shared {RateLimiter} a provider can wire
9
+ # in to back off when a quota header says so.
10
+ module Search
11
+ # Search-orchestration entry point: the cascade across configured
12
+ # providers, the result cache, and the {Unavailable} protocol marker
13
+ # the cascade uses to fall back. The LLM-facing tool itself
14
+ # ({Tool::WEB_SEARCH}) lives in +lib/tool/web_search.rb+ and calls
15
+ # into {.search} below. Each {Tool::Search} provider module
16
+ # ({DuckDuckGo}, {Brave}, {Exa}) raises {Unavailable} when it wants
17
+ # the cascade to try the next one.
18
+ module Engines
19
+ # Subsystem logger; set its level with +PIKURI_LOG_ENGINES+
20
+ # (e.g. +PIKURI_LOG_ENGINES=debug+) or the global +PIKURI_LOG+.
21
+ #
22
+ # @return [Logger]
23
+ LOGGER = Pikuri.logger_for('Engines')
24
+
25
+ # Raised by a provider when it is temporarily unavailable (rate-limited,
26
+ # bot-blocked, quota-exhausted, or otherwise saying "try again later"
27
+ # rather than "your request is wrong"). The cascade in {Engines.search}
28
+ # catches this and tries the next provider; any other exception bubbles
29
+ # up unchanged so genuine bugs and config errors stay visible.
30
+ class Unavailable < StandardError; end
31
+
32
+ # All providers that are currently configured. {DuckDuckGo} is always
33
+ # available (no API key needed); {Brave} and {Exa} each join the
34
+ # list when their API token is present in the environment. Recomputed
35
+ # on every call so a process picks up a newly-set token without a
36
+ # restart.
37
+ #
38
+ # @return [Array<Module>] +Tool::Search::*+ provider modules, each
39
+ # exposing +.search(query, max_results:)+ → +Array<Result>+
40
+ def self.providers
41
+ list = [DuckDuckGo]
42
+ list << Brave unless ENV[Brave::ENV_KEY].to_s.strip.empty?
43
+ list << Exa unless ENV[Exa::ENV_KEY].to_s.strip.empty?
44
+ list
45
+ end
46
+
47
+ # On-disk cache used by {.search} to memoize answered queries.
48
+ # Defined as a method so specs can swap it for an isolated cache
49
+ # or {UrlCache::NULL} without touching the shared instance.
50
+ #
51
+ # @return [UrlCache, #fetch]
52
+ CACHE = UrlCache.new(ttl: UrlCache::DEFAULT_TTL, dir: "#{UrlCache::ROOT_DIR}/web_search")
53
+ # Accessor for {CACHE}; specs override this to swap in
54
+ # {UrlCache::NULL} or an isolated cache.
55
+ #
56
+ # @return [UrlCache, #fetch]
57
+ def self.cache
58
+ CACHE
59
+ end
60
+
61
+ # Run +query+ through the configured providers in random order, falling
62
+ # back to the next one each time a provider raises {Unavailable}. The
63
+ # shuffle spreads load so a single provider isn't always hit first
64
+ # (and exhausted first); revisit if it stops being the right default.
65
+ #
66
+ # The query is whitespace-trimmed and runs of whitespace collapsed
67
+ # to a single space before the cascade runs. The winning provider's
68
+ # +Array<Result>+ is rendered into smolagents-style Markdown here
69
+ # (+"## Search Results"+ header, then +[title](url)\nbody+ entries
70
+ # joined by blank lines; an empty array becomes +"No results found."+),
71
+ # and the rendered Markdown is cached on disk via {.cache}, keyed by
72
+ # the cleaned query. A cache hit short-circuits the cascade entirely
73
+ # (and benefits whichever provider would have answered next time too
74
+ # — once a query is cached, the cooldown state of the original
75
+ # answering provider no longer matters). +max_results+ is not part
76
+ # of the cache key, so callers passing a non-default value may get
77
+ # a result rendered with the previously-cached size.
78
+ #
79
+ # If every provider reports temporary unavailability, returns an
80
+ # +"Error: ..."+ string instead of raising — same convention as
81
+ # {Tool::Calculator.calculate}, so the agent loop can feed the failure
82
+ # back to the model as the next observation. Any non-{Unavailable}
83
+ # exception (network error, parser failure, malformed response, bad
84
+ # API key) bubbles up to the caller.
85
+ #
86
+ # @param query [String] search query
87
+ # @param max_results [Integer] maximum number of result entries
88
+ # @return [String] Markdown-formatted result list, or +"Error: ..."+
89
+ # when all providers are exhausted
90
+ # @raise [ArgumentError] if the query is empty after normalization
91
+ def self.search(query, max_results:)
92
+ cleaned = query.to_s.strip.gsub(/\s+/, ' ')
93
+ raise ArgumentError, 'query is empty' if cleaned.empty?
94
+
95
+ current_providers = providers
96
+ log_providers(current_providers)
97
+
98
+ hit = true
99
+ result = cache.fetch(cleaned) do
100
+ hit = false
101
+ failures = []
102
+ results = nil
103
+ chosen = nil
104
+ current_providers.shuffle.each do |provider|
105
+ results = provider.search(cleaned, max_results: max_results)
106
+ chosen = provider
107
+ break
108
+ rescue Unavailable => e
109
+ failures << "#{provider.name.split('::').last} (#{e.message})"
110
+ end
111
+ # Raise so {UrlCache#fetch} does NOT persist the all-unavailable
112
+ # message — otherwise that string would block every future search
113
+ # for this query until the TTL expires. The outer +rescue+ turns
114
+ # the raise back into the calculator-style "Error: …" string.
115
+ chosen or raise Unavailable, "all search providers temporarily unavailable: #{failures.join('; ')}"
116
+
117
+ LOGGER.info do
118
+ "engine=#{chosen.name.split('::').last} query=#{cleaned.inspect} results=#{results.size}"
119
+ end
120
+ render(results)
121
+ end
122
+ LOGGER.info { "cache=hit query=#{cleaned.inspect} bytes=#{result.bytesize}" } if hit
123
+ result
124
+ rescue Unavailable => e
125
+ "Error: #{e.message}"
126
+ end
127
+
128
+ # Render an +Array<Result>+ into the smolagents-style Markdown the
129
+ # LLM consumes: +"## Search Results"+ header, then +[title](url)\nbody+
130
+ # entries joined by blank lines. An empty array becomes the
131
+ # +"No results found."+ stub so the agent still gets a real
132
+ # observation to act on.
133
+ #
134
+ # @param results [Array<Result>] hits from the winning provider
135
+ # @return [String] Markdown-formatted result list
136
+ def self.render(results)
137
+ return "## Search Results\n\nNo results found." if results.empty?
138
+
139
+ "## Search Results\n\n" + results.map { |r| "[#{r.title}](#{r.url})\n#{r.body}" }.join("\n\n")
140
+ end
141
+ private_class_method :render
142
+
143
+ # Emit an INFO log line listing the currently-available providers,
144
+ # but only when the set differs from the last one we logged.
145
+ # {.providers} is recomputed on every {.search} call so a process
146
+ # picks up newly-set API keys without a restart; the memo here
147
+ # keeps the log to one line per distinct configuration rather
148
+ # than one per search.
149
+ #
150
+ # @param current [Array<Module>] providers returned by {.providers}
151
+ # @return [void]
152
+ def self.log_providers(current)
153
+ return if @last_logged_providers == current
154
+
155
+ @last_logged_providers = current
156
+ names = current.map { |p| p.name.split('::').last }.join(', ')
157
+ LOGGER.info("engines available: #{names}")
158
+ end
159
+ private_class_method :log_providers
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module Pikuri
7
+ class Tool
8
+ module Search
9
+ # Performs an Exa search via the official +/search+ endpoint and
10
+ # returns the hits as a list of {Result} rows. Split into a thin HTTP
11
+ # fetch (#search) and a pure parser (#parse) so tests can exercise
12
+ # the parser against fixture JSON without hitting the network. The
13
+ # cascade in {Engines.search} owns the final Markdown rendering.
14
+ #
15
+ # Requires an Exa API key. Get one at https://exa.ai — the service is
16
+ # paid, so the cascade in {Engines.providers} only includes Exa when
17
+ # {ENV_KEY} is set in the environment; users who haven't registered
18
+ # never spend money on it.
19
+ #
20
+ # Calls request +type: "auto"+ (Exa picks neural vs keyword per
21
+ # query) and +contents: { highlights: true }+ so each result carries
22
+ # a short neural-ranked snippet — the closest analog to Brave's
23
+ # +description+ field, populating {Result#body} consistently across
24
+ # providers.
25
+ #
26
+ # == Privacy posture
27
+ #
28
+ # Exa's Privacy Policy states +Query Data is used to improve our
29
+ # products and technology, including by training and fine-tuning
30
+ # models that power our Services+, and the Terms of Service §1.2(c)
31
+ # grant Exa a +perpetual and irrevocable+, +sub-licensable+,
32
+ # worldwide license over User Input that can be disclosed to third
33
+ # parties +as needed+. Business customers under a Master Subscription
34
+ # Agreement / DPA get carve-outs; the default pay-as-you-go API key
35
+ # (which is what pikuri uses) does not.
36
+ #
37
+ # Bottom line: Exa does not sell queries to data brokers, but it
38
+ # does mine them to train competing models, and the license it
39
+ # claims is effectively "do what we want with this, forever". If a
40
+ # query would be embarrassing or sensitive in a training set, drop
41
+ # Exa out of the cascade by unsetting {ENV_KEY} — {Engines.providers}
42
+ # is recomputed every call.
43
+ module Exa
44
+ # @return [String] Search endpoint (POST, JSON body)
45
+ ENDPOINT = 'https://api.exa.ai/search'
46
+ # @return [Integer] default number of results returned, matching
47
+ # {DuckDuckGo::DEFAULT_MAX_RESULTS}
48
+ DEFAULT_MAX_RESULTS = 10
49
+ # @return [String] env var holding the API key; sent as +x-api-key+
50
+ ENV_KEY = 'EXA_API_KEY'
51
+ # @return [RateLimiter] Exa is paid and doesn't aggressively
52
+ # throttle, so no minimum interval is enforced. The 5-minute
53
+ # cooldown still applies on {Engines::Unavailable} so the user's
54
+ # budget isn't burned on doomed retries while a 429 / 5xx
55
+ # condition persists.
56
+ LIMITER = RateLimiter.new(min_interval: 0.0, cooldown: 300.0)
57
+
58
+ # Fetch results for +query+ and return them as an +Array<Result>+.
59
+ # Calls are circuit-broken for 5 minutes on rate-limit / unavailable
60
+ # responses; see {LIMITER}. The caller (typically {Engines.search})
61
+ # is expected to have already normalized the query and to wrap this
62
+ # in a result cache.
63
+ #
64
+ # @param query [String] search query (already normalized)
65
+ # @param max_results [Integer] maximum number of result entries;
66
+ # passed through as Exa's +numResults+
67
+ # @param api_key [String] Exa API key; defaults to the {ENV_KEY}
68
+ # environment variable
69
+ # @return [Array<Result>] hits, possibly empty when Exa ran the
70
+ # query and matched nothing
71
+ # @raise [ArgumentError] if no API key is available
72
+ # @raise [Engines::Unavailable] when Exa returns HTTP 429
73
+ # (rate limit / quota exhausted) or 5xx — "try again later"
74
+ # responses the cascade in {Engines.search} can fall back from.
75
+ # Also raised immediately if {LIMITER} is in cooldown. Other
76
+ # non-2xx (e.g. 401/403 from a bad API key) bubble up as
77
+ # +RuntimeError+ so config problems stay visible.
78
+ # @raise [RuntimeError] for non-rate-limit HTTP failures or when the
79
+ # response shape contains no results and isn't a recognized
80
+ # empty-results payload.
81
+ def self.search(query, max_results: DEFAULT_MAX_RESULTS, api_key: ENV.fetch(ENV_KEY, nil))
82
+ raise ArgumentError, "Exa Search API key not set (#{ENV_KEY})" if api_key.to_s.strip.empty?
83
+
84
+ LIMITER.call do
85
+ response = Faraday.post(ENDPOINT) do |req|
86
+ req.headers['x-api-key'] = api_key
87
+ req.headers['Content-Type'] = 'application/json'
88
+ req.headers['Accept'] = 'application/json'
89
+ req.body = JSON.dump(
90
+ query: query,
91
+ type: 'auto',
92
+ numResults: max_results,
93
+ contents: { highlights: true }
94
+ )
95
+ end
96
+ unless response.success?
97
+ if response.status == 429 || response.status >= 500
98
+ raise Engines::Unavailable, "HTTP #{response.status}"
99
+ end
100
+
101
+ raise "Exa Search request failed: #{response.status} #{response.body}"
102
+ end
103
+
104
+ parse(response.body, max_results: max_results)
105
+ end
106
+ end
107
+
108
+ # Parse an Exa Search JSON response into a list of {Result} rows,
109
+ # where +body+ is the first non-empty +highlights+ snippet (empty
110
+ # when Exa returned no highlight for that result — e.g. for
111
+ # navigational results).
112
+ #
113
+ # When the response yields zero result entries, two cases are
114
+ # distinguished: a genuine "no results" payload (response carries
115
+ # a +requestId+ and an empty +results+ array — Exa ran the query
116
+ # but matched nothing) returns an empty array instead of raising,
117
+ # so {Engines.search} can render its standard no-results stub.
118
+ # Anything else (unknown shape, structured error) raises with a
119
+ # diagnostic so the failure surfaces.
120
+ #
121
+ # @param json [String] response body from {ENDPOINT}
122
+ # @param max_results [Integer] maximum number of result entries
123
+ # @return [Array<Result>] hits, possibly empty on a recognized
124
+ # empty-results payload
125
+ # @raise [RuntimeError] when the response yields no result entries and
126
+ # is not recognized as a genuine empty-results payload
127
+ def self.parse(json, max_results: DEFAULT_MAX_RESULTS)
128
+ data = JSON.parse(json)
129
+ results = Array(data['results']).take(max_results).filter_map do |r|
130
+ href = r['url'].to_s
131
+ next nil if href.empty?
132
+
133
+ Result.new(
134
+ url: href,
135
+ title: clean(r['title']) || href,
136
+ body: first_highlight(r['highlights'])
137
+ )
138
+ end
139
+
140
+ if results.empty?
141
+ return [] if genuine_no_results?(data)
142
+
143
+ raise diagnose_empty(data, json)
144
+ end
145
+
146
+ results
147
+ end
148
+
149
+ # Collapse whitespace and strip; returns +nil+ for nil/empty input
150
+ # so the caller can fall back (typically to the URL when a result
151
+ # has no usable title).
152
+ #
153
+ # @param text [String, nil] raw text from an Exa result field
154
+ # @return [String, nil] cleaned text, or +nil+ if input was blank
155
+ def self.clean(text)
156
+ return nil if text.nil?
157
+
158
+ cleaned = text.to_s.gsub(/\s+/, ' ').strip
159
+ cleaned.empty? ? nil : cleaned
160
+ end
161
+ private_class_method :clean
162
+
163
+ # First non-empty entry from a +highlights+ array, cleaned. Exa
164
+ # returns highlights as an array sorted by relevance; we surface
165
+ # only the top one to keep the observation compact and match the
166
+ # one-line +body+ convention used by Brave / DuckDuckGo.
167
+ #
168
+ # @param highlights [Array<String>, nil] +highlights+ field
169
+ # @return [String] cleaned snippet, or empty string if none usable
170
+ def self.first_highlight(highlights)
171
+ return '' unless highlights.is_a?(Array)
172
+
173
+ highlights.each do |h|
174
+ cleaned = clean(h)
175
+ return cleaned if cleaned
176
+ end
177
+ ''
178
+ end
179
+ private_class_method :first_highlight
180
+
181
+ # True when a parsed response with zero +results+ entries looks
182
+ # like Exa's own "search ran, nothing matched" payload rather than
183
+ # a malformed or error response. The marker is the +requestId+
184
+ # field, which Exa always sets on a successful request.
185
+ #
186
+ # @param data [Hash, Object] parsed response
187
+ # @return [Boolean]
188
+ def self.genuine_no_results?(data)
189
+ return false unless data.is_a?(Hash)
190
+ return false unless data.key?('requestId')
191
+
192
+ Array(data['results']).empty?
193
+ end
194
+ private_class_method :genuine_no_results?
195
+
196
+ # Build an error message for a parsed response that yielded zero
197
+ # results. Quotes Exa's +error+ / +message+ / +detail+ field if
198
+ # present, otherwise truncates the raw body so the caller can see
199
+ # the actual payload.
200
+ #
201
+ # @param data [Hash, Object] parsed response
202
+ # @param raw [String] raw response body
203
+ # @return [String] human-readable diagnostic to feed to +raise+
204
+ def self.diagnose_empty(data, raw)
205
+ if data.is_a?(Hash) && (msg = data['error'] || data['message'] || data['detail'])
206
+ return "Exa Search returned an error: #{msg}"
207
+ end
208
+
209
+ snippet = raw.to_s[0, 800]
210
+ snippet += '…' if raw.to_s.length > 800
211
+ "Exa Search returned no results. Body: #{snippet}"
212
+ end
213
+ private_class_method :diagnose_empty
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ # Loaded after {Tool} itself is defined; the +class Tool+ reopening below
5
+ # assumes that order.
6
+ class Tool
7
+ module Search
8
+ # Thread-safe pacing + circuit-breaker wrapper for a search provider.
9
+ #
10
+ # +#call { ... }+ enforces a minimum interval between consecutive
11
+ # invocations of the block (sleeping if the previous one was too
12
+ # recent), and watches for {Engines::Unavailable} raised by the
13
+ # block: when that happens, a cooldown deadline is recorded and
14
+ # further calls within the window raise {Engines::Unavailable}
15
+ # immediately without invoking the block. This stops a provider
16
+ # that has been rate-limited or bot-blocked from being hammered
17
+ # with retries.
18
+ #
19
+ # The mutex is held across the block, so concurrent callers
20
+ # serialize — matching the behavior {DuckDuckGo} has always
21
+ # required to keep its IP-spacing throttle correct under
22
+ # concurrent agents.
23
+ #
24
+ # Uses wall-clock {Time.now} rather than the monotonic clock; the
25
+ # intervals here are 1s–5min, well above any realistic NTP step,
26
+ # and {Time.now} keeps tests trivially fakeable with Timecop.
27
+ class RateLimiter
28
+ # @param min_interval [Float] minimum seconds between consecutive
29
+ # block invocations. {#call} sleeps if a previous call was more
30
+ # recent.
31
+ # @param cooldown [Float] seconds to refuse calls after the block
32
+ # raises {Engines::Unavailable}. Calls within this window raise
33
+ # {Engines::Unavailable} immediately without invoking the block.
34
+ def initialize(min_interval:, cooldown:)
35
+ @min_interval = min_interval
36
+ @cooldown = cooldown
37
+ @mutex = Mutex.new
38
+ @last_call_at = nil
39
+ @cooldown_until = nil
40
+ end
41
+
42
+ # Run the given block subject to throttle and cooldown rules.
43
+ #
44
+ # The block is invoked with the mutex held, so concurrent calls
45
+ # serialize: only one block runs at a time per limiter instance.
46
+ # If the block raises {Engines::Unavailable}, the cooldown is
47
+ # armed and the exception is re-raised. Any other exception
48
+ # bubbles up without arming cooldown — only "try again later"
49
+ # signals from the provider are treated as backoff triggers.
50
+ #
51
+ # @yieldreturn [Object] block's return value is passed through
52
+ # @return [Object] whatever the block returned
53
+ # @raise [Engines::Unavailable] either re-raised from the block,
54
+ # or raised directly when the limiter is currently in cooldown
55
+ def call
56
+ @mutex.synchronize do
57
+ now = Time.now
58
+ if @cooldown_until && now < @cooldown_until
59
+ remaining = (@cooldown_until - now).ceil
60
+ raise Engines::Unavailable, "rate-limiter cooldown active for another #{remaining}s"
61
+ end
62
+
63
+ if @last_call_at
64
+ elapsed = now - @last_call_at
65
+ sleep_for(@min_interval - elapsed) if elapsed < @min_interval
66
+ end
67
+ @last_call_at = Time.now
68
+
69
+ begin
70
+ yield
71
+ rescue Engines::Unavailable
72
+ @cooldown_until = Time.now + @cooldown
73
+ raise
74
+ end
75
+ end
76
+ end
77
+
78
+ # Sleep for +seconds+. Isolated as a private method so tests can
79
+ # override it on a single instance (typically to advance a frozen
80
+ # Timecop clock by the same amount) without monkey-patching the
81
+ # global +sleep+.
82
+ #
83
+ # @param seconds [Float] non-negative duration to sleep
84
+ # @return [void]
85
+ def sleep_for(seconds)
86
+ sleep(seconds)
87
+ end
88
+ private :sleep_for
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Tool
5
+ module Search
6
+ # A single search hit produced by a {Tool::Search} provider
7
+ # ({DuckDuckGo}, {Brave}, {Exa}). Providers return +Array<Result>+
8
+ # from +.parse+ / +.search+; {Engines.search} concatenates the
9
+ # rows into the smolagents-style Markdown the LLM sees.
10
+ #
11
+ # Splitting structure from rendering keeps the three providers
12
+ # interchangeable — they only have to agree on these three fields
13
+ # (provider-specific extras like relevance scores or published
14
+ # dates are discarded today, which is fine because no caller uses
15
+ # them).
16
+ #
17
+ # @!attribute [r] url
18
+ # @return [String] absolute URL of the hit
19
+ # @!attribute [r] title
20
+ # @return [String] plain-text title, with provider-specific
21
+ # highlight markup ({Brave}'s +<strong>+, {DuckDuckGo}'s
22
+ # +<b>+) already stripped
23
+ # @!attribute [r] body
24
+ # @return [String] plain-text snippet, possibly empty (e.g. an
25
+ # {Exa} navigational result with no highlights)
26
+ Result = Data.define(:url, :title, :body)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Tool
5
+ # The +sub_agent+ tool, expressed as a {Tool} subclass: instantiating
6
+ # +Tool::SubAgent.new(parent_agent)+ produces a tool whose
7
+ # {Tool#to_ruby_llm_tool} wiring is identical to any bundled tool's,
8
+ # so ruby_llm sees nothing special about it. When the model calls it,
9
+ # the closure inside +execute+ spawns a fresh {Agent} that runs its
10
+ # own Thought / Tool-call / Observation loop on a clean message
11
+ # history, then returns only the sub-agent's final assistant message
12
+ # back as the parent's next observation.
13
+ #
14
+ # The sub-agent reuses the parent's +transport+, +system_prompt+,
15
+ # +context_window_cap+, and +name+ (as its hierarchical prefix), so
16
+ # it shares the same persona, hits the same server, and inherits the
17
+ # same context-window cap without re-probing. Its tool list is a
18
+ # snapshot of the parent's {Agent#tools} taken at construction —
19
+ # {Agent#allow_sub_agent} only appends the sub-agent tool to its own
20
+ # +@tools+ *after* this snapshot, so the sub-agent's tool list never
21
+ # contains itself (recursion guard).
22
+ #
23
+ # Its listener list comes from the parent's {Agent#listeners} via
24
+ # {Agent::ListenerList#for_sub_agent}, which forwards to each
25
+ # listener's own +for_sub_agent+ hook: +Terminal+ swaps to a padded
26
+ # fresh instance, +TokenLog+ resets its snapshot, and listeners
27
+ # without the hook ({Agent::Listener::InMemoryEventList}, …) are
28
+ # shared by reference so structured capture flows continuously.
29
+ #
30
+ # Controls are derived per the per-control rule: a fresh
31
+ # {Agent::Control::StepLimit} at the new cap (mutable counter is
32
+ # per-chat), the same {Agent::Control::Cancellable} shared by
33
+ # reference (one +cancel!+ stops the whole tree), and no
34
+ # {Agent::Control::Interloper} (the host has no handle to
35
+ # sub-agents).
36
+ #
37
+ # All parent state is captured by value at construction — the closure
38
+ # does not chase +parent_agent+ mutations later. The one piece of
39
+ # mutable state is a monotonic counter used to generate sub-agent ids:
40
+ # +"sub_agent 0"+, +"sub_agent 1"+, ... at the top level; nested
41
+ # children of +"sub_agent 0"+ are +"sub_agent 0_0"+, +"sub_agent 0_1"+,
42
+ # ... — the +"sub_agent "+ prefix appears once at the top and the
43
+ # underscore-separated counter chain records depth.
44
+ class SubAgent < Tool
45
+ # Description shown to the LLM. Follows the opencode-shape (summary
46
+ # + +Usage:+ bullets) prescribed by the project's tool-description
47
+ # convention.
48
+ #
49
+ # @return [String]
50
+ DESCRIPTION = <<~DESC
51
+ Delegate a self-contained task to a fresh sub-agent that runs its own Thought / Tool-call / Observation loop on a clean conversation, returning only its final assistant message.
52
+
53
+ Usage:
54
+ - Use to isolate side-quests — research, multi-step lookups, exploratory tool use — so intermediate observations do not clutter your own context.
55
+ - The sub-agent has your tools minus `sub_agent` itself, so it cannot recurse.
56
+ - It shares your system prompt — persona, tool-use conventions, and output format carry over. Do NOT re-explain who you are or how to use tools.
57
+ - It cannot see your conversation. Put ALL task-specific context inside `task`; the sub-agent has zero memory of what came before.
58
+ DESC
59
+
60
+ # @param parent_agent [Agent] the calling agent. Read for its
61
+ # {Agent#transport}, {Agent#system_prompt}, {Agent#tools},
62
+ # {Agent#listeners}, {Agent#step_limit}, {Agent#cancellable},
63
+ # {Agent#context_window_cap}, {Agent#name}, and
64
+ # {Agent#extensions} (so the sub-agent inherits and re-binds
65
+ # the parent's extension list).
66
+ # @param max_steps [Integer] step budget for each sub-agent run,
67
+ # used to construct the sub-agent's own
68
+ # {Agent::Control::StepLimit}.
69
+ # @return [SubAgent]
70
+ def initialize(parent_agent, max_steps: 10)
71
+ transport = parent_agent.transport
72
+ system_prompt = parent_agent.system_prompt
73
+ sub_tools = parent_agent.tools.dup
74
+ listeners = parent_agent.listeners
75
+ parent_step_limit = parent_agent.step_limit
76
+ parent_cancel = parent_agent.cancellable
77
+ context_window = parent_agent.context_window_cap
78
+ parent_name = parent_agent.name
79
+ streaming = parent_agent.streaming
80
+ # Parent's extension list, captured at SubAgent construction
81
+ # so spawned sub-agents share the *same* extension instances
82
+ # (configure has already run on the parent — the resulting
83
+ # tools / snippets / listeners are inherited verbatim via
84
+ # the kwargs above). Each inherited extension's +bind+ fires
85
+ # inside the sub-agent's +Agent#initialize+ — that's how
86
+ # MCP's per-agent connect tool ends up keyed to the
87
+ # sub-agent rather than the parent, while still sharing the
88
+ # parent's live MCP clients through the extension instance.
89
+ # See IDEAS.md §"Sub-agent inheritance — configure-once,
90
+ # bind-per-agent".
91
+ inherited_exts = parent_agent.extensions
92
+ sub_counter = 0
93
+
94
+ super(
95
+ name: 'sub_agent',
96
+ description: DESCRIPTION,
97
+ parameters: Parameters.build { |p|
98
+ p.required_string :task,
99
+ 'Self-contained instructions for the sub-agent, ' \
100
+ 'e.g. "Find the populations of Reykjavik and ' \
101
+ 'Helsinki in 2024 and report both numbers." ' \
102
+ 'It has no access to the parent conversation, ' \
103
+ 'so include all necessary context.'
104
+ },
105
+ execute: lambda { |task:|
106
+ idx = sub_counter
107
+ sub_counter += 1
108
+ sub_name = parent_name.empty? ? "sub_agent #{idx}" : "#{parent_name}_#{idx}"
109
+ sub_listeners = listeners.for_sub_agent(name: sub_name)
110
+
111
+ # All inherited state is seeded through the Configurator
112
+ # block — tools and listeners via add_tools / add_listeners,
113
+ # extensions via inherit_extensions which retains them for
114
+ # the bind sweep without re-running configure (the parent
115
+ # already drove that and the resulting system-prompt
116
+ # snippets are inherited verbatim through +system_prompt+).
117
+ sub = Agent.new(
118
+ transport: transport,
119
+ system_prompt: system_prompt,
120
+ step_limit: parent_step_limit&.for_sub_agent(max_steps: max_steps),
121
+ cancellable: parent_cancel&.for_sub_agent,
122
+ context_window: context_window,
123
+ name: sub_name,
124
+ streaming: streaming
125
+ ) do |c|
126
+ c.add_tools(sub_tools)
127
+ c.add_listeners(sub_listeners)
128
+ c.inherit_extensions(inherited_exts)
129
+ end
130
+ begin
131
+ sub.run_loop(user_message: task)
132
+ sub.last_assistant_content
133
+ ensure
134
+ # The sub-agent borrows the parent's MCP clients via
135
+ # the shared {Mcp::Extension} instance; it doesn't own
136
+ # them. {#close} still fires its own +on_close+ list
137
+ # (empty for a sub-agent — no extensions registered
138
+ # any handlers via the inherited path, since they only
139
+ # re-bind here, not re-configure), so this is a no-op
140
+ # today. Calling +#close+ anyway means any future
141
+ # sub-agent-owned resource gets released without
142
+ # revisiting this site. See {Agent#close}.
143
+ sub.close
144
+ end
145
+ }
146
+ )
147
+ end
148
+ end
149
+ end
150
+ end