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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/GETTING_STARTED.md +223 -0
- data/LICENSE +21 -0
- data/README.md +193 -0
- data/lib/pikuri/agent/chat_transport.rb +41 -0
- data/lib/pikuri/agent/context_window_detector.rb +101 -0
- data/lib/pikuri/agent/listener/in_memory_message_list.rb +33 -0
- data/lib/pikuri/agent/listener/message_listener.rb +93 -0
- data/lib/pikuri/agent/listener/step_limit.rb +97 -0
- data/lib/pikuri/agent/listener/terminal.rb +137 -0
- data/lib/pikuri/agent/listener/token_log.rb +166 -0
- data/lib/pikuri/agent/listener_list.rb +113 -0
- data/lib/pikuri/agent/message.rb +61 -0
- data/lib/pikuri/agent/synthesizer.rb +120 -0
- data/lib/pikuri/agent/tokens.rb +56 -0
- data/lib/pikuri/agent.rb +286 -0
- data/lib/pikuri/subprocess.rb +166 -0
- data/lib/pikuri/tool/bash.rb +272 -0
- data/lib/pikuri/tool/calculator.rb +82 -0
- data/lib/pikuri/tool/confirmer.rb +96 -0
- data/lib/pikuri/tool/edit.rb +196 -0
- data/lib/pikuri/tool/fetch.rb +167 -0
- data/lib/pikuri/tool/glob.rb +310 -0
- data/lib/pikuri/tool/grep.rb +338 -0
- data/lib/pikuri/tool/parameters.rb +314 -0
- data/lib/pikuri/tool/read.rb +254 -0
- data/lib/pikuri/tool/scraper/fetch_error.rb +16 -0
- data/lib/pikuri/tool/scraper/html.rb +285 -0
- data/lib/pikuri/tool/scraper/pdf.rb +54 -0
- data/lib/pikuri/tool/scraper/simple.rb +177 -0
- data/lib/pikuri/tool/search/brave.rb +184 -0
- data/lib/pikuri/tool/search/duckduckgo.rb +196 -0
- data/lib/pikuri/tool/search/engines.rb +154 -0
- data/lib/pikuri/tool/search/exa.rb +217 -0
- data/lib/pikuri/tool/search/rate_limiter.rb +92 -0
- data/lib/pikuri/tool/search/result.rb +29 -0
- data/lib/pikuri/tool/skill.rb +80 -0
- data/lib/pikuri/tool/skill_catalog.rb +376 -0
- data/lib/pikuri/tool/sub_agent.rb +102 -0
- data/lib/pikuri/tool/web_scrape.rb +117 -0
- data/lib/pikuri/tool/web_search.rb +38 -0
- data/lib/pikuri/tool/workspace.rb +150 -0
- data/lib/pikuri/tool/write.rb +170 -0
- data/lib/pikuri/tool.rb +118 -0
- data/lib/pikuri/url_cache.rb +106 -0
- data/lib/pikuri/version.rb +10 -0
- data/lib/pikuri.rb +165 -0
- data/prompts/coding-system-prompt.txt +28 -0
- data/prompts/pikuri-chat.txt +15 -0
- 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
|
data/lib/pikuri/agent.rb
ADDED
|
@@ -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! }
|