pikuri 0.0.1

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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/GETTING_STARTED.md +223 -0
  4. data/LICENSE +21 -0
  5. data/README.md +193 -0
  6. data/lib/pikuri/agent/chat_transport.rb +41 -0
  7. data/lib/pikuri/agent/context_window_detector.rb +101 -0
  8. data/lib/pikuri/agent/listener/in_memory_message_list.rb +33 -0
  9. data/lib/pikuri/agent/listener/message_listener.rb +93 -0
  10. data/lib/pikuri/agent/listener/step_limit.rb +97 -0
  11. data/lib/pikuri/agent/listener/terminal.rb +137 -0
  12. data/lib/pikuri/agent/listener/token_log.rb +166 -0
  13. data/lib/pikuri/agent/listener_list.rb +113 -0
  14. data/lib/pikuri/agent/message.rb +61 -0
  15. data/lib/pikuri/agent/synthesizer.rb +120 -0
  16. data/lib/pikuri/agent/tokens.rb +56 -0
  17. data/lib/pikuri/agent.rb +286 -0
  18. data/lib/pikuri/subprocess.rb +166 -0
  19. data/lib/pikuri/tool/bash.rb +272 -0
  20. data/lib/pikuri/tool/calculator.rb +82 -0
  21. data/lib/pikuri/tool/confirmer.rb +96 -0
  22. data/lib/pikuri/tool/edit.rb +196 -0
  23. data/lib/pikuri/tool/fetch.rb +167 -0
  24. data/lib/pikuri/tool/glob.rb +310 -0
  25. data/lib/pikuri/tool/grep.rb +338 -0
  26. data/lib/pikuri/tool/parameters.rb +314 -0
  27. data/lib/pikuri/tool/read.rb +254 -0
  28. data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
  29. data/lib/pikuri/tool/scraper/html.rb +285 -0
  30. data/lib/pikuri/tool/scraper/pdf.rb +54 -0
  31. data/lib/pikuri/tool/scraper/simple.rb +177 -0
  32. data/lib/pikuri/tool/search/brave.rb +184 -0
  33. data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
  34. data/lib/pikuri/tool/search/engines.rb +154 -0
  35. data/lib/pikuri/tool/search/exa.rb +217 -0
  36. data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
  37. data/lib/pikuri/tool/search/result.rb +29 -0
  38. data/lib/pikuri/tool/skill.rb +80 -0
  39. data/lib/pikuri/tool/skill_catalog.rb +376 -0
  40. data/lib/pikuri/tool/sub_agent.rb +102 -0
  41. data/lib/pikuri/tool/web_scrape.rb +117 -0
  42. data/lib/pikuri/tool/web_search.rb +38 -0
  43. data/lib/pikuri/tool/workspace.rb +150 -0
  44. data/lib/pikuri/tool/write.rb +170 -0
  45. data/lib/pikuri/tool.rb +118 -0
  46. data/lib/pikuri/url_cache.rb +106 -0
  47. data/lib/pikuri/version.rb +10 -0
  48. data/lib/pikuri.rb +165 -0
  49. data/prompts/coding-system-prompt.txt +28 -0
  50. data/prompts/pikuri-chat.txt +15 -0
  51. metadata +259 -0
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Agent
5
+ # Step-exhaustion rescue. When an +Agent+'s {Listener::StepLimit}
6
+ # trips, +Agent#run_loop+ catches the +Exceeded+ exception and hands
7
+ # off to {Synthesizer.run} so the run still produces something useful
8
+ # — a tools-free assistant turn that answers the user's question
9
+ # from whatever evidence the failed agent collected before running
10
+ # out of budget.
11
+ #
12
+ # == Why this exists
13
+ #
14
+ # Without a rescue, a step-exhausted run just raises a stack trace
15
+ # past +bin/pikuri-chat+ and the user gets nothing despite the agent
16
+ # having gathered useful information in the first N-1 steps. The
17
+ # observed failure mode is the "wait, but what about X?" death-loop:
18
+ # the agent collects sound evidence in the first few rounds, then
19
+ # spends the rest of the budget second-guessing. By the time the cap
20
+ # trips, the answer is largely in the messages — it just needs a
21
+ # tools-free pass to synthesize.
22
+ #
23
+ # == Seam discipline
24
+ #
25
+ # {Synthesizer.run} does not reference +RubyLLM::*+. +Agent+
26
+ # constructs the synth chat itself (the one +RubyLLM.chat+ call lives
27
+ # in +lib/agent.rb+, same as the parent chat) and passes it in.
28
+ # +Synthesizer+ only calls instance methods on whatever +chat+ it
29
+ # receives — +#with_instructions+ and +#ask+ — so the seam stays at
30
+ # three files.
31
+ module Synthesizer
32
+ # The synthesizer's system prompt. Strict and short: use the
33
+ # evidence, don't apologize, admit gaps when present.
34
+ SYSTEM_PROMPT = <<~PROMPT
35
+ You are given evidence another agent collected before running out of steps. Answer the user's question using only this evidence. You have no tools. If the evidence is insufficient, state plainly what's missing and what partial answer you can give. Do not apologize or comment on the previous agent.
36
+ PROMPT
37
+
38
+ # Configure +chat+ for synthesis, run one turn against it, and
39
+ # return the final assistant content. Listeners are attached so
40
+ # the synth's reasoning and answer flow through the same surface
41
+ # the parent agent uses — terminal renders them inline, an
42
+ # in-memory recorder picks them up, and a future web sink sees
43
+ # them as normal +Message+ variants.
44
+ #
45
+ # @param chat [RubyLLM::Chat] a *fresh* chat with no tools. The
46
+ # caller is responsible for constructing it with the same
47
+ # model/provider configuration the parent used.
48
+ # @param parent_messages [Array<RubyLLM::Message>] the parent
49
+ # chat's full message history at the moment of step exhaustion.
50
+ # Used to build the evidence transcript.
51
+ # @param user_message [String] the user's original question from
52
+ # the parent turn that exhausted.
53
+ # @param listeners [Agent::ListenerList] listeners to attach to
54
+ # the synth chat. Typically the parent agent's list run through
55
+ # {ListenerList#for_sub_agent} with +max_steps: 1+ — same
56
+ # transformation a sub-agent invocation gets, since the synth
57
+ # runs on a fresh +RubyLLM::Chat+: +TokenLog+ zeroed, +Terminal+
58
+ # padded, +StepLimit+ at the defensive cap (the synth has no
59
+ # tools so it should never trip), shared listeners (e.g.
60
+ # +InMemoryMessageList+) kept by reference.
61
+ # @return [String, nil] the synth's final assistant content, or
62
+ # +nil+ if the synth somehow produced no assistant message
63
+ def self.run(chat:, parent_messages:, user_message:, listeners:)
64
+ chat.with_instructions(SYSTEM_PROMPT)
65
+ listeners.attach(chat)
66
+ chat.ask(build_prompt(parent_messages: parent_messages, user_message: user_message))
67
+ chat.messages.reverse.find { |m| m.role == :assistant }&.content
68
+ end
69
+
70
+ # Render the user's question plus an "Evidence gathered" section
71
+ # built from +parent_messages+ as a single prompt string. Pure
72
+ # function — no I/O, safe to test directly with fixture messages.
73
+ #
74
+ # @param parent_messages [Array<RubyLLM::Message>]
75
+ # @param user_message [String]
76
+ # @return [String]
77
+ def self.build_prompt(parent_messages:, user_message:)
78
+ transcript = format_evidence(parent_messages)
79
+ "Question: #{user_message}\n\nEvidence gathered:\n#{transcript}"
80
+ end
81
+
82
+ # Walk the parent's message history and produce a paired
83
+ # "Tool call:" / "Tool result:" log, preserving order. Tool calls
84
+ # that have no matching +:tool+ message are dropped — the call
85
+ # that tripped the step limit never executed, so including it
86
+ # would mislead the synth into citing nonexistent results.
87
+ # Non-empty assistant text content is preserved as a "Note:" line,
88
+ # since the parent may have summarized progress between tool
89
+ # calls.
90
+ #
91
+ # @param messages [Array<RubyLLM::Message>]
92
+ # @return [String]
93
+ def self.format_evidence(messages)
94
+ results_by_id = messages
95
+ .select { |m| m.role == :tool }
96
+ .to_h { |m| [m.tool_call_id, m.content] }
97
+
98
+ lines = []
99
+ messages.each do |msg|
100
+ next unless msg.role == :assistant
101
+
102
+ text = msg.content
103
+ lines << "Note: #{text}" if text.is_a?(String) && !text.empty?
104
+
105
+ msg.tool_calls&.each_value do |tc|
106
+ result = results_by_id[tc.id]
107
+ next unless result
108
+
109
+ args = tc.arguments.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
110
+ lines << "Tool call: #{tc.name}(#{args})"
111
+ lines << "Tool result: #{result}"
112
+ lines << ''
113
+ end
114
+ end
115
+ lines.join("\n").rstrip
116
+ end
117
+ private_class_method :format_evidence
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ class Agent
5
+ # Provider-reported token usage for a single assistant turn, copied
6
+ # off a +RubyLLM::Message+'s +tokens+ block. Delivered to listeners
7
+ # through {Listener::MessageListener#on_tokens} rather than the
8
+ # {Message} stream — it's metadata about an exchange, not an event
9
+ # in it.
10
+ #
11
+ # Emitted by {Listener::MessageListener#dispatch_chat_message} on
12
+ # every assistant +after_message+ event, including pure tool-call
13
+ # turns where {Message::Assistant} would have been filtered out for
14
+ # empty content. Those are exactly the turns where context-window
15
+ # growth matters most.
16
+ #
17
+ # All counts are +Integer, nil+. +nil+ means the provider did not
18
+ # report that field — common with local llama.cpp / Ollama servers
19
+ # that leave parts of the OpenAI +usage+ block empty. Listeners
20
+ # treat +nil+ as zero.
21
+ #
22
+ # The fields +input+, +cached+, and +cache_creation+ are
23
+ # **exclusive portions of this turn's full prompt** under the shape
24
+ # ruby_llm exposes for llama.cpp and Anthropic: they sum to the
25
+ # total prompt size processed on this request. OpenAI proper nests
26
+ # +cached_tokens+ inside its +prompt_tokens+ instead — if pikuri
27
+ # ever talks there directly, the sum formula needs revisiting.
28
+ #
29
+ # - +input+ — newly-processed (uncached) prompt tokens this turn.
30
+ # - +output+ — tokens in this single assistant reply.
31
+ # - +cached+ — portion of this turn's prompt served from the
32
+ # provider's prompt cache. Still counts against the context
33
+ # window (caching is a speed/cost optimization, not a context-
34
+ # savings mechanism).
35
+ # - +cache_creation+ — portion of this turn's prompt written into
36
+ # the prompt cache. Anthropic-specific; usually +nil+ on
37
+ # OpenAI-compatible local servers.
38
+ # - +thinking+ — extended-thinking (Anthropic) or reasoning
39
+ # (OpenAI o-series) tokens produced on this turn. +nil+ on
40
+ # providers without a reasoning channel.
41
+ # - +model_id+ — provider-side model name as reported on the
42
+ # response; useful when a process targets multiple models.
43
+ #
44
+ # == Computing "current context window size"
45
+ #
46
+ # +input + cached + cache_creation+ is the size of the prompt
47
+ # processed on this turn. Add +output+ to get tokens consumed by the
48
+ # conversation *through* this turn — this turn's prompt plus its
49
+ # reply, both of which the model will re-process on the next turn.
50
+ # That's what climbs toward +RubyLLM::ContextLengthExceededError+
51
+ # and is the snapshot {Listener::TokenLog#context_window_size}
52
+ # tracks (without the +output+ term, a long reply stays invisible
53
+ # in the headline until the next turn pulls it in as cached prompt).
54
+ Tokens = Data.define(:input, :output, :cached, :cache_creation, :thinking, :model_id)
55
+ end
56
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ module Pikuri
6
+ # Thin wrapper around +RubyLLM::Chat+: pikuri owns the *extension surface*
7
+ # (the listener objects that consume normalized chat events) while
8
+ # ruby_llm owns the loop itself. The Thought / Tool-call / Observation
9
+ # iteration lives in +Chat#complete+; pikuri's job is just attaching
10
+ # listeners at construction time, forwarding the user turn, and
11
+ # notifying the listeners of the new {Message::User} so any that care
12
+ # about turn boundaries (notably {Listener::StepLimit}) can react.
13
+ #
14
+ # Listeners live in a {ListenerList} the caller supplies — duck-typed
15
+ # against a tiny +attach(chat)+ / +on_message(msg)+ protocol, with the
16
+ # list itself implementing the same protocol so +Agent+ never touches
17
+ # the underlying +Array+. There are no defaults for +tools:+ or
18
+ # +listeners:+ on {#initialize}: both are conscious decisions the
19
+ # caller must state every time.
20
+ #
21
+ # == Step-exhaustion rescue
22
+ #
23
+ # If a {Listener::StepLimit} in {#listeners} trips during +Chat#ask+,
24
+ # {#run_loop} catches the +Exceeded+ exception, emits a
25
+ # {Message::FallbackNotice} to every listener, and hands off to
26
+ # {Synthesizer.run} on a fresh +RubyLLM::Chat+. The synth reuses the
27
+ # parent's {ListenerList} via {ListenerList#for_sub_agent} with
28
+ # +max_steps: 1+ — same transformation a sub-agent invocation gets,
29
+ # since the synth is a fresh context: +TokenLog+ zeroed, +Terminal+
30
+ # padded, +StepLimit+ at the defensive cap (the synth has no tools so
31
+ # it should never trip), +InMemoryMessageList+ shared by reference. The
32
+ # listener +name:+ becomes +"<@name>_synthesizer"+ (or just
33
+ # +"synthesizer"+ for the main agent) so the synth turn is distinct
34
+ # from the parent's normal turns in any name-aware log line. The
35
+ # synth's answer becomes the value reported by
36
+ # {#last_assistant_content}, so callers (notably {Tool::SubAgent})
37
+ # still get a usable reply instead of raising past +bin/pikuri-chat+.
38
+ class Agent
39
+ # @param transport [ChatTransport] the model-resolution triple
40
+ # (+model+ / +provider+ / +assume_model_exists+) forwarded to
41
+ # +RubyLLM.chat+. Bundled into one value object so every
42
+ # construction site — this constructor and the synthesizer rescue
43
+ # below — can forward all three with one assignment instead of
44
+ # three kwargs (where dropping one would silently route the chat
45
+ # elsewhere or raise +RubyLLM::ModelNotFoundError+). If
46
+ # +transport.model+ is +nil+, it's filled in from
47
+ # +RubyLLM.config.default_model+.
48
+ # @param system_prompt [String] system message prepended to the chat
49
+ # @param tools [Array<Tool>] pikuri tools registered with the
50
+ # underlying chat in declaration order. Each is converted to
51
+ # ruby_llm's runtime shape via {Tool#to_ruby_llm_tool} when wired
52
+ # in. Required — no default, because the tool set is a deliberate
53
+ # per-call decision (pass +[]+ for a tools-free agent).
54
+ # @param listeners [ListenerList] the listener list whose +attach+
55
+ # the constructor calls on the underlying chat. Required — no
56
+ # default, because the renderer and step-budget choices are
57
+ # deliberate per-call decisions. Typical CLI shape:
58
+ # +ListenerList.new([Listener::Terminal.new, Listener::StepLimit.new(max: 20)])+.
59
+ # @param context_window [Integer, nil] explicit override for the
60
+ # model's context-window cap. When set, it wins over ruby_llm's
61
+ # reported value and the llama.cpp probe — see
62
+ # {ContextWindowDetector} for precedence. Resolved cap is pushed to
63
+ # every {Listener::TokenLog} so the +ctx=<used>/<cap>+ headline
64
+ # lights up.
65
+ # @param llama_probe_url [String, nil] llama.cpp +/props+ URL used as
66
+ # the third detection source. Only consulted when neither
67
+ # +context_window+ nor ruby_llm's reported value is set. Typically
68
+ # derived by +bin/pikuri-chat+ from its configured +openai_api_base+;
69
+ # leave +nil+ when the configured server is anything other than
70
+ # llama.cpp.
71
+ # @param name [String] identifier for this agent. Empty for the main
72
+ # agent; sub-agents get monotonic hierarchical names like
73
+ # +"sub_agent 0"+, +"sub_agent 1"+, +"sub_agent 0_0"+, ... generated
74
+ # by {Tool::SubAgent} from the parent's name + a per-parent counter.
75
+ # Forwarded to listeners through {ListenerList#for_sub_agent} so
76
+ # name-aware ones (notably {Listener::TokenLog}) can tag their output.
77
+ # @param skill_catalog [Tool::SkillCatalog] catalog of on-disk skills
78
+ # the agent may load on demand. Defaults to
79
+ # +Tool::SkillCatalog::EMPTY+, which is a no-op singleton. When
80
+ # non-empty: the catalog's prompt block ({Tool::SkillCatalog#format_for_prompt})
81
+ # is appended to +system_prompt+ so the LLM can see what's available,
82
+ # and a {Tool::Skill} bound to the catalog is appended to +tools+
83
+ # so the LLM can actually load them. The two changes are coupled —
84
+ # advertising skills without a loader (or vice versa) would be a
85
+ # bug, so the catalog is the single source of truth for both.
86
+ # @return [Agent]
87
+ def initialize(transport:, system_prompt:, tools:, listeners:,
88
+ context_window: nil, llama_probe_url: nil, name: '',
89
+ skill_catalog: Tool::SkillCatalog::EMPTY)
90
+ @transport = transport.model ? transport : transport.with(model: RubyLLM.config.default_model)
91
+ @system_prompt = skill_catalog.empty? ? system_prompt : system_prompt + skill_catalog.format_for_prompt
92
+ @skill_catalog = skill_catalog
93
+ @tools = tools.dup
94
+ @listeners = listeners
95
+ @name = name
96
+ @synth_answer = nil
97
+
98
+ unless skill_catalog.empty?
99
+ raise 'Tool::Skill cannot be passed in tools: when skill_catalog is non-empty; ' \
100
+ 'Agent auto-registers it from the catalog.' \
101
+ if @tools.any?(Tool::Skill)
102
+
103
+ @tools << Tool::Skill.new(catalog: skill_catalog)
104
+ end
105
+
106
+ @chat = RubyLLM.chat(**@transport.to_h)
107
+ @chat.with_instructions(@system_prompt)
108
+ @tools.each { |t| @chat.with_tool(t.to_ruby_llm_tool) }
109
+
110
+ @context_window_cap = ContextWindowDetector.new(
111
+ override: context_window,
112
+ ruby_llm_reported: @chat.model.context_window,
113
+ llama_probe_url: llama_probe_url
114
+ ).detect
115
+ @listeners.context_window_cap = @context_window_cap
116
+ @listeners.attach(@chat)
117
+ end
118
+
119
+ # @return [RubyLLM::Chat] underlying chat; the extension seam
120
+ attr_reader :chat
121
+
122
+ # @return [ChatTransport] the resolved transport bundle this agent
123
+ # was constructed with — same model id / provider /
124
+ # assume-model-exists flag passed to every +RubyLLM.chat+ call
125
+ # originating from this agent (the main chat, the synthesizer
126
+ # rescue, the sub-agent tool). Read by {Tool::SubAgent} so
127
+ # spawned sub-agents reuse the same transport.
128
+ attr_reader :transport
129
+
130
+ # @return [Array<Tool>] this agent's tool list in declaration order.
131
+ # Snapshotted by {Tool::SubAgent} so spawned sub-agents inherit
132
+ # the parent's tools (minus the sub-agent tool itself, which
133
+ # {#allow_sub_agent} appends to +@tools+ only after the snapshot
134
+ # has been taken — recursion guard).
135
+ attr_reader :tools
136
+
137
+ # @return [String] resolved model id from {#transport}. Convenience
138
+ # delegator for callers that don't need the full transport bundle.
139
+ def model
140
+ @transport.model
141
+ end
142
+
143
+ # @return [String] system prompt actually sent to the chat — equal to
144
+ # the constructor's +system_prompt:+ argument plus, when a non-
145
+ # empty +skill_catalog:+ was supplied, the catalog's
146
+ # +<available_skills>+ block. {Tool::SubAgent} forwards this
147
+ # already-augmented value to spawned sub-agents, so they see the
148
+ # same catalog without needing the +skill_catalog:+ kwarg themselves.
149
+ attr_reader :system_prompt
150
+
151
+ # @return [Tool::SkillCatalog] catalog passed to the constructor;
152
+ # +Tool::SkillCatalog::EMPTY+ if none was supplied. Read by callers
153
+ # that want to inspect the loaded skills (e.g. for a startup banner).
154
+ attr_reader :skill_catalog
155
+
156
+ # @return [ListenerList] the listener list attached to this agent's
157
+ # chat
158
+ attr_reader :listeners
159
+
160
+ # @return [String] this agent's identifier — empty for the main agent;
161
+ # for sub-agents, the hierarchical id assigned by
162
+ # {Tool::SubAgent} (e.g. +"sub_agent 0"+, +"sub_agent 1"+,
163
+ # +"sub_agent 0_0"+). Read by the sub-agent tool so spawned
164
+ # sub-agents prefix their own names with this one, and propagated
165
+ # to listeners via {ListenerList#for_sub_agent} so name-aware ones
166
+ # can tag output.
167
+ attr_reader :name
168
+
169
+ # @return [Integer, nil] context-window cap resolved by
170
+ # {ContextWindowDetector} at construction time. +nil+ when no
171
+ # source produced a value (custom local model with no override and
172
+ # no reachable llama.cpp +/props+). Read by {Tool::SubAgent} so
173
+ # spawned sub-agents inherit the same cap without re-probing.
174
+ attr_reader :context_window_cap
175
+
176
+ # Final assistant message content for the most recent {#run_loop}.
177
+ # When the synthesizer rescue fired, returns its answer; otherwise
178
+ # walks the underlying chat's history. Returns +nil+ if neither
179
+ # source has produced an assistant turn yet.
180
+ #
181
+ # @return [String, nil]
182
+ def last_assistant_content
183
+ return @synth_answer if @synth_answer
184
+
185
+ last = @chat.messages.reverse.find { |m| m.role == :assistant }
186
+ last&.content
187
+ end
188
+
189
+ # Run the agent loop for a single user turn. Notifies every listener of
190
+ # the {Message::User} — which is also how {Listener::StepLimit}
191
+ # learns to reset its counter — and forwards +user_message+ to
192
+ # {#chat} via +ask+. Returns nil; rendering and any other observable
193
+ # output is the listeners' responsibility.
194
+ #
195
+ # If a {Listener::StepLimit} trips during +ask+, the rescue branch
196
+ # emits a {Message::FallbackNotice} and runs {Synthesizer.run} on a
197
+ # fresh +RubyLLM::Chat+. The synth's answer is captured for
198
+ # {#last_assistant_content}; the exception does not bubble out.
199
+ #
200
+ # Subsequent calls keep building on the same chat history, so the
201
+ # model sees full multi-turn context.
202
+ #
203
+ # @param user_message [String] the user's request for this turn; must
204
+ # not be +nil+, empty, or whitespace-only
205
+ # @raise [ArgumentError] if +user_message+ is +nil+, empty, or
206
+ # contains only whitespace — an empty turn would poison the chat
207
+ # history and burn a step budget on nothing
208
+ # @return [nil]
209
+ def run_loop(user_message:)
210
+ raise ArgumentError, "user_message must not be blank, got #{user_message.inspect}" \
211
+ if user_message.nil? || user_message.to_s.strip.empty?
212
+
213
+ @synth_answer = nil
214
+ @listeners.on_message(Message::User.new(content: user_message))
215
+ @chat.ask(user_message)
216
+ nil
217
+ rescue Listener::StepLimit::Exceeded => e
218
+ notice = Message::FallbackNotice.new(
219
+ reason: "agent exhausted #{e.max_steps} steps; synthesizing answer from gathered evidence"
220
+ )
221
+ @listeners.on_message(notice)
222
+
223
+ synth_chat = RubyLLM.chat(**@transport.to_h)
224
+ # Synth runs under this agent's identity but on a fresh chat with a
225
+ # different system prompt, so it gets a distinct +_synthesizer+
226
+ # suffix on the name — same +_+ separator the sub-agent generator
227
+ # uses, so main becomes +"synthesizer"+ and a sub-agent
228
+ # +"sub_agent 0"+ becomes +"sub_agent 0_synthesizer"+. Any
229
+ # +TokenLog+ in the list tags the synth's prompt under that bracket
230
+ # so it's obvious from the log which turns were the rescue rather
231
+ # than the original loop.
232
+ synth_name = @name.empty? ? 'synthesizer' : "#{@name}_synthesizer"
233
+ @synth_answer = Synthesizer.run(
234
+ chat: synth_chat,
235
+ parent_messages: @chat.messages,
236
+ user_message: user_message,
237
+ listeners: @listeners.for_sub_agent(max_steps: 1, name: synth_name)
238
+ )
239
+ nil
240
+ end
241
+
242
+ # Adds a +sub_agent+ tool that lets this agent spawn sub-agents which
243
+ # share the parent's model, system prompt, and current tool set (minus
244
+ # the sub-agent tool itself, so recursion is impossible).
245
+ #
246
+ # {Tool::SubAgent} snapshots +@tools+ during construction; we append
247
+ # the new sub-agent tool to +@tools+ only after that, so the
248
+ # sub-agent's tool list never contains itself.
249
+ #
250
+ # Each sub-agent run gets a derived {ListenerList} via
251
+ # {ListenerList#for_sub_agent} — listeners that define a sub-agent
252
+ # variant return a fresh instance (e.g. +StepLimit+ at the new cap,
253
+ # +Terminal+ with sub-agent padding, +TokenLog+ zeroed); listeners
254
+ # without the hook (+InMemoryMessageList+, ...) are shared by reference so
255
+ # the sub-agent's events render and capture continuously with the
256
+ # parent's.
257
+ #
258
+ # @param max_steps [Integer] step budget for each sub-agent run,
259
+ # passed through to {Tool::SubAgent#initialize}
260
+ # @raise [RuntimeError] if a {Tool::SubAgent} is already registered
261
+ # on this agent — calling twice would advertise two identically
262
+ # named tools to ruby_llm and double the sub-agent's tool list
263
+ # (the second snapshot would contain the first sub-agent tool).
264
+ # @return [void]
265
+ def allow_sub_agent(max_steps: 10)
266
+ raise "Tool::SubAgent already registered on this agent; allow_sub_agent may only be called once" \
267
+ if @tools.any?(Tool::SubAgent)
268
+
269
+ sub_tool = Tool::SubAgent.new(self, max_steps: max_steps)
270
+ @tools << sub_tool
271
+ @chat.with_tool(sub_tool.to_ruby_llm_tool)
272
+ end
273
+
274
+ # Short, single-line config dump suitable for a startup banner or a
275
+ # debug print. Delegates the listener rendering to {ListenerList#to_s}.
276
+ #
277
+ # @example
278
+ # agent.to_s
279
+ # # => "Agent(model=qwen3-35b, tools=4, listeners=[Terminal, StepLimit(max=20)])"
280
+ #
281
+ # @return [String]
282
+ def to_s
283
+ "Agent(model=#{model}, tools=#{@tools.size}, listeners=#{@listeners})"
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'set'
5
+
6
+ module Pikuri
7
+ # Chokepoint for *all* subprocess spawning in pikuri. Forces a new
8
+ # process group for each invocation, tracks pgids so descendants of
9
+ # the direct child (commands backgrounded with +&+) can be cleaned
10
+ # up at process exit, and captures combined stdout+stderr through a
11
+ # single pipe.
12
+ #
13
+ # == Seam discipline
14
+ #
15
+ # All subprocess spawning in +lib/+ goes through {.spawn}. Direct
16
+ # +Process.spawn+ / +Open3.*+ / +system+ / backticks anywhere in
17
+ # +lib/+ are bugs. The convention is grep-enforceable:
18
+ # +grep -rn 'Process\.spawn\|Open3\|system\|backtick' lib/+ should
19
+ # only hit this file.
20
+ #
21
+ # == Timeouts are the caller's job
22
+ #
23
+ # {.spawn} does not implement a timeout — Ruby's +Timeout.timeout+
24
+ # cannot kill subprocesses cleanly. Callers that need a timeout
25
+ # wrap their argv with coreutils' +timeout+ binary:
26
+ #
27
+ # Pikuri::Subprocess.spawn(
28
+ # 'timeout', '--signal=TERM', '--kill-after=5s', '120s',
29
+ # 'bash', '-c', command,
30
+ # chdir: workspace.cwd.to_s
31
+ # )
32
+ #
33
+ # When +timeout+ and its FD-inheriting children die, the combined
34
+ # output pipe closes and {#wait}'s +io.read+ returns. No Ruby-side
35
+ # timeout machinery; the +timeout+ binary handles SIGTERM-then-
36
+ # SIGKILL race-free.
37
+ #
38
+ # == Backgrounded subprocesses
39
+ #
40
+ # When a shell command backgrounds work with +&+, the resulting
41
+ # process stays in our pgroup. {#wait} returns as soon as the
42
+ # direct child exits, but {.active} keeps the pgid in the tracked
43
+ # set as long as any process in the group is alive (probed with
44
+ # +kill(0, -pgid)+). On pikuri exit, {.cleanup!} sends SIGTERM to
45
+ # every tracked group. The model can opt out via +nohup cmd &+ or
46
+ # +setsid cmd &+ — both detach from our group.
47
+ #
48
+ # == State is process-global
49
+ #
50
+ # One +@active+ Set and one +at_exit+ for the whole process. A
51
+ # +Mutex+ guards register/prune/cleanup; v1 is single-threaded, so
52
+ # this is more for the +at_exit+/register race than for current
53
+ # callers.
54
+ #
55
+ # == Why +Pikuri::Subprocess+, not top-level
56
+ #
57
+ # First class actually under the +Pikuri::+ namespace. Domain
58
+ # classes (+Tool+, +Agent+, +URLCache+) are top-level as a legacy
59
+ # convention — they predate the namespacing decision and an
60
+ # eventual refactor moves them too. For now: library-level
61
+ # infrastructure under +Pikuri::+; domain objects flat. See
62
+ # +CLAUDE.md+ for the convention.
63
+ class Subprocess
64
+ # Combined output + exit status, returned from {#wait}.
65
+ Result = Data.define(:output, :status)
66
+
67
+ # Spawn +argv+ in a new process group, redirecting stderr onto
68
+ # stdout. Tracked for cleanup.
69
+ #
70
+ # @param argv [Array<String>] command and arguments. Caller does
71
+ # any shell wrapping (e.g. +'bash', '-c', cmd+) when shell
72
+ # interpretation is wanted; +argv+ is passed to +exec+
73
+ # directly, so no implicit shell expansion happens here.
74
+ # @param chdir [String, Pathname] working directory
75
+ # @return [Subprocess] handle — call {#wait} to block for the
76
+ # direct child to exit and read the captured output
77
+ def self.spawn(*argv, chdir:)
78
+ stdin, io, wait_thr = Open3.popen2e(*argv, chdir: chdir.to_s, pgroup: true)
79
+ stdin.close
80
+ register(wait_thr.pid)
81
+ new(io: io, wait_thr: wait_thr)
82
+ end
83
+
84
+ # @return [Integer] direct child's pid
85
+ attr_reader :pid
86
+
87
+ # @return [Integer] process group id. Equal to {#pid} since the
88
+ # child was spawned with +pgroup: true+ (it's the group leader).
89
+ attr_reader :pgid
90
+
91
+ # @return [IO] read end of the combined stdout+stderr pipe.
92
+ # Exposed for future live-streaming consumers; v1 callers go
93
+ # straight to {#wait}, which drains it.
94
+ attr_reader :io
95
+
96
+ # @api private — call {.spawn}, not the constructor.
97
+ def initialize(io:, wait_thr:)
98
+ @io = io
99
+ @wait_thr = wait_thr
100
+ @pid = wait_thr.pid
101
+ @pgid = wait_thr.pid # pgroup:true → pgid == pid
102
+ end
103
+
104
+ # Block until the direct child exits, read whatever remains on
105
+ # the combined-output pipe, return a {Result}. The pgid stays
106
+ # tracked if the group still has live members (backgrounded
107
+ # children); pruned if everything's gone.
108
+ #
109
+ # @return [Result]
110
+ def wait
111
+ output = @io.read
112
+ @io.close
113
+ Result.new(output: output, status: @wait_thr.value)
114
+ ensure
115
+ self.class.send(:prune, @pgid)
116
+ end
117
+
118
+ class << self
119
+ # Currently-tracked process groups, with dead ones pruned as a
120
+ # side effect. Useful for a future +/bg+ REPL command or a
121
+ # between-turn status line.
122
+ #
123
+ # @return [Array<Integer>]
124
+ def active
125
+ @mutex.synchronize do
126
+ @active.delete_if { |g| !alive?(g) }
127
+ @active.to_a
128
+ end
129
+ end
130
+
131
+ # SIGTERM every tracked process group. Used by +at_exit+
132
+ # (production) and +after+ blocks (specs). Best-effort —
133
+ # ignores errors from already-dead groups.
134
+ #
135
+ # @return [void]
136
+ def cleanup!
137
+ @mutex.synchronize do
138
+ @active.each { |g| Process.kill('-TERM', g) rescue nil }
139
+ @active.clear
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def register(pgid)
146
+ @mutex.synchronize { @active << pgid }
147
+ end
148
+
149
+ def prune(pgid)
150
+ @mutex.synchronize { @active.delete(pgid) unless alive?(pgid) }
151
+ end
152
+
153
+ def alive?(pgid)
154
+ Process.kill(0, -pgid)
155
+ true
156
+ rescue Errno::ESRCH
157
+ false
158
+ end
159
+ end
160
+
161
+ @active = Set.new
162
+ @mutex = Mutex.new
163
+ end
164
+ end
165
+
166
+ at_exit { Pikuri::Subprocess.cleanup! }