pikuri 0.0.1 → 0.0.4

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 +4 -4
  2. data/README.md +43 -179
  3. data/lib/pikuri.rb +16 -162
  4. metadata +66 -123
  5. data/CHANGELOG.md +0 -62
  6. data/GETTING_STARTED.md +0 -223
  7. data/LICENSE +0 -21
  8. data/lib/pikuri/agent/chat_transport.rb +0 -41
  9. data/lib/pikuri/agent/context_window_detector.rb +0 -101
  10. data/lib/pikuri/agent/listener/in_memory_message_list.rb +0 -33
  11. data/lib/pikuri/agent/listener/message_listener.rb +0 -93
  12. data/lib/pikuri/agent/listener/step_limit.rb +0 -97
  13. data/lib/pikuri/agent/listener/terminal.rb +0 -137
  14. data/lib/pikuri/agent/listener/token_log.rb +0 -166
  15. data/lib/pikuri/agent/listener_list.rb +0 -113
  16. data/lib/pikuri/agent/message.rb +0 -61
  17. data/lib/pikuri/agent/synthesizer.rb +0 -120
  18. data/lib/pikuri/agent/tokens.rb +0 -56
  19. data/lib/pikuri/agent.rb +0 -286
  20. data/lib/pikuri/subprocess.rb +0 -166
  21. data/lib/pikuri/tool/bash.rb +0 -272
  22. data/lib/pikuri/tool/calculator.rb +0 -82
  23. data/lib/pikuri/tool/confirmer.rb +0 -96
  24. data/lib/pikuri/tool/edit.rb +0 -196
  25. data/lib/pikuri/tool/fetch.rb +0 -167
  26. data/lib/pikuri/tool/glob.rb +0 -310
  27. data/lib/pikuri/tool/grep.rb +0 -338
  28. data/lib/pikuri/tool/parameters.rb +0 -314
  29. data/lib/pikuri/tool/read.rb +0 -254
  30. data/lib/pikuri/tool/scraper/fetch_error.rb +0 -16
  31. data/lib/pikuri/tool/scraper/html.rb +0 -285
  32. data/lib/pikuri/tool/scraper/pdf.rb +0 -54
  33. data/lib/pikuri/tool/scraper/simple.rb +0 -177
  34. data/lib/pikuri/tool/search/brave.rb +0 -184
  35. data/lib/pikuri/tool/search/duckduckgo.rb +0 -196
  36. data/lib/pikuri/tool/search/engines.rb +0 -154
  37. data/lib/pikuri/tool/search/exa.rb +0 -217
  38. data/lib/pikuri/tool/search/rate_limiter.rb +0 -92
  39. data/lib/pikuri/tool/search/result.rb +0 -29
  40. data/lib/pikuri/tool/skill.rb +0 -80
  41. data/lib/pikuri/tool/skill_catalog.rb +0 -376
  42. data/lib/pikuri/tool/sub_agent.rb +0 -102
  43. data/lib/pikuri/tool/web_scrape.rb +0 -117
  44. data/lib/pikuri/tool/web_search.rb +0 -38
  45. data/lib/pikuri/tool/workspace.rb +0 -150
  46. data/lib/pikuri/tool/write.rb +0 -170
  47. data/lib/pikuri/tool.rb +0 -118
  48. data/lib/pikuri/url_cache.rb +0 -106
  49. data/lib/pikuri/version.rb +0 -10
  50. data/prompts/coding-system-prompt.txt +0 -28
  51. data/prompts/pikuri-chat.txt +0 -15
@@ -1,113 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pikuri
4
- class Agent
5
- # Listener-list value object that an {Agent} owns. Implements the same
6
- # +attach(chat)+ / +on_message(msg)+ protocol as an individual
7
- # listener — every call is fanned out to the underlying list — so the
8
- # +Agent+ never sees a raw +Array+ and never has to express
9
- # "for each listener, do X" inline.
10
- #
11
- # == Why a class, not an Array
12
- #
13
- # Two operations want one home rather than scattered helpers:
14
- #
15
- # 1. {#for_sub_agent} produces a derived list for a sub-agent run by
16
- # forwarding to each listener's own +for_sub_agent(**params)+
17
- # hook (default identity for listeners that don't define one).
18
- # The dispatch lives on the listener — +Terminal+ swaps in a
19
- # padded fresh instance, +TokenLog+ resets its snapshot,
20
- # +StepLimit+ picks +max_steps+ out of the params hash — so this
21
- # class doesn't grow a method per new listener type. The
22
- # synthesizer rescue uses the same hook with +max_steps: 1+,
23
- # since a step-exhausted synth is just another fresh-context run.
24
- #
25
- # 2. {#attach} / {#on_message} replace +each { |l| l.attach(chat) }+
26
- # and +each { |l| l.on_message(msg) }+ at the call sites, which
27
- # makes the seam ("a listener list is a thing the agent owns")
28
- # more visible.
29
- class ListenerList
30
- # @param listeners [Array] listeners that respond to the duck-typed
31
- # +attach(chat)+ / +on_message(msg)+ protocol
32
- def initialize(listeners)
33
- @listeners = listeners.dup
34
- end
35
-
36
- # Wire every listener into +chat+'s callback API. Forwarded verbatim
37
- # to each listener's +#attach+ — see {Listener::MessageListener#attach}
38
- # and {Listener::StepLimit#attach} for what each one registers.
39
- #
40
- # @param chat [RubyLLM::Chat]
41
- # @return [void]
42
- def attach(chat)
43
- @listeners.each { |l| l.attach(chat) }
44
- end
45
-
46
- # Dispatch one message to every listener.
47
- #
48
- # @param message [Agent::Message]
49
- # @return [void]
50
- def on_message(message)
51
- @listeners.each { |l| l.on_message(message) }
52
- end
53
-
54
- # Return a new {ListenerList} in which every listener has been
55
- # asked for its sub-agent variant. Each listener that defines
56
- # +for_sub_agent(**params)+ receives the forwarded +params+ and
57
- # returns either +self+ or a replacement; listeners that don't
58
- # define the method are kept by reference (output, structured
59
- # capture, and anything else stateful flow continuously into the
60
- # parent's instances).
61
- #
62
- # The dispatch lives on the listener so adding a new listener type
63
- # with sub-agent-specific behavior doesn't change this class — see
64
- # {Listener::Terminal#for_sub_agent} (fresh padded instance),
65
- # {Listener::TokenLog#for_sub_agent} (fresh, zeroed snapshot), and
66
- # {Listener::StepLimit#for_sub_agent} (fresh cap from +max_steps:+).
67
- #
68
- # +params+ is a flat hash forwarded as kwargs to every listener's
69
- # hook; each listener picks the keys it cares about and ignores
70
- # the rest (the +**+ catch-all in their signatures). Calling with
71
- # no params is always valid — every listener's +for_sub_agent+
72
- # treats its consumed keys as optional (e.g. +StepLimit+ falls
73
- # back to its own cap when +max_steps:+ is absent).
74
- #
75
- # @param params [Hash{Symbol => Object}] kwargs forwarded to each
76
- # listener's +for_sub_agent+. Currently +max_steps:+ is the only
77
- # key any listener consumes.
78
- # @return [ListenerList]
79
- def for_sub_agent(**params)
80
- swapped = @listeners.map do |l|
81
- l.respond_to?(:for_sub_agent) ? l.for_sub_agent(**params) : l
82
- end
83
- self.class.new(swapped)
84
- end
85
-
86
- # Set the context-window cap on every {Listener::TokenLog} in the
87
- # list. Called by {Agent#initialize} once
88
- # {Agent::ContextWindowDetector} has resolved a value, so the
89
- # +ctx=<used>/<cap>+ form lights up across all token loggers without
90
- # the caller having to know which listeners they registered.
91
- #
92
- # Non-+TokenLog+ listeners are left alone — they have no cap to
93
- # carry.
94
- #
95
- # @param cap [Integer, nil] cap to apply; +nil+ is allowed (and
96
- # keeps the existing +ctx=<used>+ form)
97
- # @return [void]
98
- def context_window_cap=(cap)
99
- @listeners.each do |l|
100
- l.context_window_cap = cap if l.is_a?(Listener::TokenLog)
101
- end
102
- end
103
-
104
- # @example
105
- # list.to_s # => "[Terminal, StepLimit(max=20)]"
106
- #
107
- # @return [String]
108
- def to_s
109
- "[#{@listeners.map(&:to_s).join(', ')}]"
110
- end
111
- end
112
- end
113
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pikuri
4
- class Agent
5
- # Sealed value-object hierarchy describing a single event in the
6
- # +Agent+'s normalized stream. Listeners (Terminal renderer,
7
- # in-memory recorder, future web sink) receive these through one
8
- # +Listener::MessageListener#on_message+ entry point and dispatch on
9
- # the variant's class.
10
- #
11
- # Each variant is a +Data.define+ with the minimal fields it needs;
12
- # value equality and pattern-matching support come for free.
13
- #
14
- # == Where each variant comes from
15
- #
16
- # * {User} — synthesized by {Agent#run_loop} before forwarding the
17
- # turn to +Chat#ask+. Never appears in ruby_llm's listener stream.
18
- # * {Thinking} / {Assistant} — extracted from a +Chat#after_message+
19
- # payload when the role is +:assistant+. Empty +thinking.text+ and
20
- # empty +content+ are filtered out at the dispatch site so
21
- # listeners never see vacuous events.
22
- # * {ToolCall} — emitted on +Chat#before_tool_call+, carrying the
23
- # tool's name and the LLM-supplied argument hash.
24
- # * {ToolResult} — emitted on +Chat#after_tool_result+, carrying the
25
- # observation string the tool produced.
26
- #
27
- # Provider-reported token usage is *not* a {Message} variant — it's
28
- # metadata about an exchange, not an event in it. See {Agent::Tokens}
29
- # and {Listener::MessageListener#on_tokens}.
30
- module Message
31
- # User's input for a turn, as passed to {Agent#run_loop}.
32
- User = Data.define(:content)
33
-
34
- # Assistant reasoning ("thinking") block, extracted from the
35
- # +thinking.text+ field on a +RubyLLM::Message+ with role
36
- # +:assistant+.
37
- Thinking = Data.define(:content)
38
-
39
- # Assistant Markdown content, extracted from a +RubyLLM::Message+
40
- # with role +:assistant+.
41
- Assistant = Data.define(:content)
42
-
43
- # A tool invocation the LLM has requested but not yet observed.
44
- # Arguments are the raw hash ruby_llm parsed from the model's
45
- # +tool_calls+ JSON — no validation has run yet.
46
- ToolCall = Data.define(:name, :arguments)
47
-
48
- # The observation a tool produced, as returned by
49
- # {Tool#run}. Recoverable failures arrive here as +"Error: ..."+
50
- # strings (per the pikuri error convention), not as exceptions.
51
- ToolResult = Data.define(:content)
52
-
53
- # Out-of-band notice that the agent had to take a rescue path —
54
- # currently emitted by {Agent#run_loop} when {Listener::StepLimit}
55
- # trips and the synthesizer fallback runs. Lets listeners (Terminal,
56
- # future web UI) surface the divergence to the user before the
57
- # synthesizer's own assistant output flows through.
58
- FallbackNotice = Data.define(:reason)
59
- end
60
- end
61
- end
@@ -1,120 +0,0 @@
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
@@ -1,56 +0,0 @@
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 DELETED
@@ -1,286 +0,0 @@
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