pikuri-core 0.0.6 → 0.0.7
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 +4 -4
- data/README.md +5 -3
- data/lib/pikuri/agent/chat_transport.rb +135 -11
- data/lib/pikuri/agent/configurator.rb +4 -4
- data/lib/pikuri/agent/context_window_detector.rb +103 -52
- data/lib/pikuri/agent/control/step_limit.rb +39 -7
- data/lib/pikuri/agent/event.rb +43 -16
- data/lib/pikuri/agent/extension.rb +31 -17
- data/lib/pikuri/agent/extension_context.rb +147 -0
- data/lib/pikuri/agent/listener/terminal.rb +13 -2
- data/lib/pikuri/agent/listener/token_log.rb +60 -13
- data/lib/pikuri/agent/listener.rb +12 -5
- data/lib/pikuri/agent/listener_list.rb +7 -17
- data/lib/pikuri/agent/synthesizer.rb +93 -67
- data/lib/pikuri/agent.rb +358 -403
- data/lib/pikuri/sanitizer.rb +179 -0
- data/lib/pikuri/tool/parameters.rb +65 -2
- data/lib/pikuri/tool/search/brave.rb +32 -18
- data/lib/pikuri/tool/search/duckduckgo.rb +18 -7
- data/lib/pikuri/tool/search/engines.rb +72 -49
- data/lib/pikuri/tool/search/exa.rb +34 -22
- data/lib/pikuri/tool/web_search.rb +45 -26
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +11 -9
- metadata +5 -6
|
@@ -10,6 +10,14 @@ module Pikuri
|
|
|
10
10
|
# is fully constructed, and {#on_user_message} on every user turn
|
|
11
11
|
# thereafter.
|
|
12
12
|
#
|
|
13
|
+
# Each phase receives that phase's context object: +configure+
|
|
14
|
+
# gets the build-time {Configurator}; +bind+ and
|
|
15
|
+
# +on_user_message+ get the runtime {ExtensionContext} — the
|
|
16
|
+
# capability facade for everything that acts on the live agent
|
|
17
|
+
# (domain-event emission, raw tool registration, sub-agent
|
|
18
|
+
# listener derivation), with the agent itself readable via
|
|
19
|
+
# {ExtensionContext#agent}.
|
|
20
|
+
#
|
|
13
21
|
# Mix this module into an extension class to inherit empty
|
|
14
22
|
# default implementations of all three hooks; override the ones
|
|
15
23
|
# you need. Extensions that don't +include+ this module still
|
|
@@ -59,25 +67,30 @@ module Pikuri
|
|
|
59
67
|
def configure(c); end
|
|
60
68
|
|
|
61
69
|
# Called by {Agent#initialize} after the block returns and the
|
|
62
|
-
# chat is fully wired, with the
|
|
63
|
-
# Fires once per agent the extension was
|
|
64
|
-
# {Configurator#add_extension} — in the
|
|
65
|
-
# the parent agent only, since sub-agents
|
|
66
|
-
# extensions. The default is a no-op; override
|
|
67
|
-
# to install state keyed to the live agent
|
|
68
|
-
# you typically do here:
|
|
70
|
+
# chat is fully wired, with the agent's {ExtensionContext} as
|
|
71
|
+
# the argument. Fires once per agent the extension was
|
|
72
|
+
# registered to via {Configurator#add_extension} — in the
|
|
73
|
+
# typical setup that's the parent agent only, since sub-agents
|
|
74
|
+
# do not inherit extensions. The default is a no-op; override
|
|
75
|
+
# when you need to install state keyed to the live agent.
|
|
76
|
+
# Things you typically do here:
|
|
69
77
|
#
|
|
70
|
-
# * register dynamic tools via {
|
|
78
|
+
# * register dynamic tools via {ExtensionContext#add_raw_tool}
|
|
71
79
|
# (used by {Pikuri::Mcp::Extension} for +mcp_connect+,
|
|
72
|
-
# whose +execute+ closure needs the
|
|
73
|
-
#
|
|
74
|
-
# *
|
|
75
|
-
#
|
|
76
|
-
#
|
|
80
|
+
# whose +execute+ closure needs the context so activations
|
|
81
|
+
# register on the right chat)
|
|
82
|
+
# * wire domain-event emission via
|
|
83
|
+
# {ExtensionContext#emit_event} (e.g. +Pikuri::Tasks::Extension+
|
|
84
|
+
# arms its list's +on_change+ here)
|
|
85
|
+
# * register per-agent +on_close+ handlers via
|
|
86
|
+
# {ExtensionContext#on_close}
|
|
87
|
+
# * stash the +ctx+ if the extension's tools need to act on
|
|
88
|
+
# this specific agent later
|
|
77
89
|
#
|
|
78
|
-
# @param
|
|
90
|
+
# @param ctx [ExtensionContext] capability facade for the
|
|
91
|
+
# live, fully wired agent
|
|
79
92
|
# @return [void]
|
|
80
|
-
def bind(
|
|
93
|
+
def bind(ctx); end
|
|
81
94
|
|
|
82
95
|
# Optional per-turn hook fired by the {Agent} after a user-message
|
|
83
96
|
# is added to the chat. The
|
|
@@ -97,12 +110,13 @@ module Pikuri
|
|
|
97
110
|
# only — sub-agents do not inherit extensions, so a persona's turns are
|
|
98
111
|
# never prefetched or recorded by the parent's memory.
|
|
99
112
|
#
|
|
100
|
-
# @param
|
|
113
|
+
# @param ctx [ExtensionContext] capability facade for the live
|
|
114
|
+
# agent whose turn this is — same instance +bind+ received
|
|
101
115
|
# @param content [String] the user message (initial or interloper) about
|
|
102
116
|
# to be sent to the model
|
|
103
117
|
# @return [String, nil] an optional block of text to be injected verbatim as
|
|
104
118
|
# a system-role message (after the user message), or +nil+ to inject nothing
|
|
105
|
-
def on_user_message(
|
|
119
|
+
def on_user_message(ctx, content); end
|
|
106
120
|
end
|
|
107
121
|
end
|
|
108
122
|
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
class Agent
|
|
5
|
+
# Capability facade handed to {Extension#bind} and
|
|
6
|
+
# {Extension#on_user_message} — the runtime counterpart of
|
|
7
|
+
# {Configurator}. Where the Configurator collects *build-time*
|
|
8
|
+
# declarations (tools, listeners, prompt snippets), this object
|
|
9
|
+
# grants the *runtime* capabilities an extension needs once the
|
|
10
|
+
# agent is fully wired: emitting domain events onto the listener
|
|
11
|
+
# stream, registering raw per-agent tools, and deriving sub-agent
|
|
12
|
+
# listener lists.
|
|
13
|
+
#
|
|
14
|
+
# == Why a handed object, not a getter on Agent
|
|
15
|
+
#
|
|
16
|
+
# The {Agent} deliberately exposes NO public path to these
|
|
17
|
+
# capabilities — no +listeners+ reader, no +chat+ reader, no
|
|
18
|
+
# emit method. Holding an agent reference grants read access to
|
|
19
|
+
# its configuration ({Agent#tools}, {Agent#transport}, ...) and
|
|
20
|
+
# nothing more; the write capabilities live here, and the only
|
|
21
|
+
# way to obtain this object is to be an {Extension} receiving a
|
|
22
|
+
# +bind+ / +on_user_message+ call (or to be handed it onward by
|
|
23
|
+
# one, e.g. {Pikuri::SubAgent::SubAgentTool} and
|
|
24
|
+
# {Pikuri::Mcp::Servers::Connect} both capture the context their
|
|
25
|
+
# extension's +bind+ received). Capabilities flow by explicit
|
|
26
|
+
# handoff, never by fetching from a globally reachable object.
|
|
27
|
+
#
|
|
28
|
+
# The usual Ruby caveat applies: nothing here is mechanically
|
|
29
|
+
# sealed (+instance_variable_get+ exists). The boundary is the
|
|
30
|
+
# API contract — same as every seam in CLAUDE.md.
|
|
31
|
+
#
|
|
32
|
+
# == Boundary rule
|
|
33
|
+
#
|
|
34
|
+
# Operations that *act on* the live agent's wiring live here.
|
|
35
|
+
# Passive readers of constructor-given config (+transport+,
|
|
36
|
+
# +id+, +streaming+, +tools+, +cancellable+, ...) stay on
|
|
37
|
+
# {Agent}, reachable via {#agent}. Don't move readers in; don't
|
|
38
|
+
# add capabilities to Agent.
|
|
39
|
+
#
|
|
40
|
+
# == Audit
|
|
41
|
+
#
|
|
42
|
+
# One context per agent, constructed by {Agent#initialize} right
|
|
43
|
+
# before the extension +bind+ sweep. {ListenerList#emit} has
|
|
44
|
+
# exactly two callers: {Agent} (loop narration) and this class
|
|
45
|
+
# (extension domain events). The roster of capability users is
|
|
46
|
+
# +grep -rn 'emit_event\|add_raw_tool\|sub_agent_listeners'
|
|
47
|
+
# pikuri-*/lib/+.
|
|
48
|
+
class ExtensionContext
|
|
49
|
+
# @param agent [Agent] the live, fully wired agent.
|
|
50
|
+
# @param chat [RubyLLM::Chat] the agent's underlying chat —
|
|
51
|
+
# target of {#add_raw_tool}.
|
|
52
|
+
# @param listeners [ListenerList] the agent's listener list —
|
|
53
|
+
# target of {#emit_event} / {#sub_agent_listeners}.
|
|
54
|
+
# @param on_close_sink [Array<Proc>] the agent's live
|
|
55
|
+
# +@on_close_handlers+ array, which {#on_close} appends to —
|
|
56
|
+
# same live-sink shape as {Configurator}'s +on_close_sink:+.
|
|
57
|
+
def initialize(agent:, chat:, listeners:, on_close_sink:)
|
|
58
|
+
@agent = agent
|
|
59
|
+
@chat = chat
|
|
60
|
+
@listeners = listeners
|
|
61
|
+
@on_close_handlers = on_close_sink
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Agent] the live agent, for read access to its
|
|
65
|
+
# configuration (tools, transport, id, streaming, ...).
|
|
66
|
+
attr_reader :agent
|
|
67
|
+
|
|
68
|
+
# Emit a domain event onto the agent's listener stream.
|
|
69
|
+
#
|
|
70
|
+
# Core {Event} variants narrate the chat loop and are emitted
|
|
71
|
+
# by {Agent} alone; gems define their own variants (e.g.
|
|
72
|
+
# +Pikuri::Tasks::ListChanged+) in their own namespace and
|
|
73
|
+
# emit them here. Listeners must no-op on variants they don't
|
|
74
|
+
# recognize — {Listener::Base#on_event}'s default and
|
|
75
|
+
# +case+-fallthrough give that for free.
|
|
76
|
+
#
|
|
77
|
+
# Called on the agent's thread (typically from inside a tool's
|
|
78
|
+
# +execute+, where the event lands between {Event::ToolCall}
|
|
79
|
+
# and {Event::ToolResult} in the stream). Listeners doing
|
|
80
|
+
# cross-thread handoff snapshot/serialize inside +on_event+.
|
|
81
|
+
#
|
|
82
|
+
# @param event [Object] an immutable event value (by
|
|
83
|
+
# convention a +Data+ instance).
|
|
84
|
+
# @return [void]
|
|
85
|
+
def emit_event(event)
|
|
86
|
+
@listeners.emit(event)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Register a raw +RubyLLM::Tool+ subclass on the agent's
|
|
91
|
+
# underlying chat, bypassing the {Pikuri::Tool}
|
|
92
|
+
# strict-validation seam — hence "raw": native pikuri tools
|
|
93
|
+
# should go through {Pikuri::Tool} (registered at build time
|
|
94
|
+
# via {Configurator#add_tool}) so they get {Tool::Parameters}
|
|
95
|
+
# validation and the LLM-actionable +"Error: ..."+ contract.
|
|
96
|
+
# Intended callers: {Pikuri::Mcp::Servers} (MCP tools
|
|
97
|
+
# deliberately bypass — see IDEAS.md §"MCP tools bypass
|
|
98
|
+
# +Pikuri::Tool+ entirely") and
|
|
99
|
+
# {Pikuri::SubAgent::Extension} (the +agent+ tool must
|
|
100
|
+
# register after the parent's tool list is final).
|
|
101
|
+
#
|
|
102
|
+
# The added tool does NOT enter {Agent#tools}, only the chat's
|
|
103
|
+
# tool list. Sub-agents therefore cannot snapshot it — which
|
|
104
|
+
# is the whole point: activation is strictly per-agent, see
|
|
105
|
+
# IDEAS.md §"Per-agent activation, no propagation".
|
|
106
|
+
#
|
|
107
|
+
# @param ruby_llm_tool [Class] subclass of +RubyLLM::Tool+
|
|
108
|
+
# @return [void]
|
|
109
|
+
def add_raw_tool(ruby_llm_tool)
|
|
110
|
+
@chat.with_tool(ruby_llm_tool)
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Derive a listener list for a spawned sub-agent via
|
|
115
|
+
# {ListenerList#for_sub_agent}. Sole intended caller:
|
|
116
|
+
# {Pikuri::SubAgent::SubAgentTool}, once per spawn.
|
|
117
|
+
#
|
|
118
|
+
# The derived list deliberately aliases the parent's listener
|
|
119
|
+
# *instances* where a listener opts to share by reference
|
|
120
|
+
# (stateful sinks like {Listener::InMemoryEventList}) — see
|
|
121
|
+
# {ListenerList#for_sub_agent} for the per-listener semantics.
|
|
122
|
+
#
|
|
123
|
+
# @param params [Hash{Symbol => Object}] forwarded to each
|
|
124
|
+
# listener's +for_sub_agent+ hook (currently +id:+).
|
|
125
|
+
# @return [ListenerList]
|
|
126
|
+
def sub_agent_listeners(**params)
|
|
127
|
+
@listeners.for_sub_agent(**params)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Register a handler called by {Agent#close}. Symmetric to
|
|
131
|
+
# {Configurator#on_close} — same LIFO + per-handler-rescue +
|
|
132
|
+
# idempotent semantics — but available post-construction, so
|
|
133
|
+
# an {Extension}'s +bind+ can install per-agent cleanup keyed
|
|
134
|
+
# to this specific agent (e.g. +Pikuri::Memory::Extension+
|
|
135
|
+
# arms its recorder's bounded flush here).
|
|
136
|
+
#
|
|
137
|
+
# @yield called with no arguments at close time
|
|
138
|
+
# @return [void]
|
|
139
|
+
def on_close(&blk)
|
|
140
|
+
raise ArgumentError, 'on_close requires a block' unless block_given?
|
|
141
|
+
|
|
142
|
+
@on_close_handlers << blk
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -8,7 +8,8 @@ module Pikuri
|
|
|
8
8
|
# Terminal renderer for the normalized event stream: dim grey
|
|
9
9
|
# reasoning, assistant content printed raw (Markdown as-is),
|
|
10
10
|
# cyan tool-call and tool-result lines, yellow fallback
|
|
11
|
-
# notice, red cancelled notice
|
|
11
|
+
# notice, red cancelled notice, magenta model-switch notice.
|
|
12
|
+
# An {Event::SystemInjected} block (recalled
|
|
12
13
|
# memory / context an extension injected) renders dim grey
|
|
13
14
|
# with a +⊕+ marker. {Event::UserTurn} is intentionally silent
|
|
14
15
|
# (the terminal user just typed the message, so re-rendering
|
|
@@ -131,6 +132,8 @@ module Pikuri
|
|
|
131
132
|
println(indent(Rainbow("→ #{name}(#{args})").cyan))
|
|
132
133
|
in Event::ToolResult(content:)
|
|
133
134
|
println(indent(Rainbow("= #{truncate_tool_result(content)}").cyan))
|
|
135
|
+
in Event::ModelSwitched(from:, to:)
|
|
136
|
+
println(indent(Rainbow("⇄ model: #{from.model} → #{to.model}").magenta))
|
|
134
137
|
in Event::FallbackNotice(reason:)
|
|
135
138
|
println(indent(Rainbow("! #{reason}").yellow))
|
|
136
139
|
in Event::Cancelled
|
|
@@ -234,12 +237,20 @@ module Pikuri
|
|
|
234
237
|
# exactly this" from "tool returned much more, you're
|
|
235
238
|
# seeing a slice."
|
|
236
239
|
#
|
|
240
|
+
# Whitespace is flattened *first* (collapsing real tabs, CRs and
|
|
241
|
+
# newlines into single spaces — fine here, since this passive
|
|
242
|
+
# echo is a status line, not an approval artifact), then the
|
|
243
|
+
# result runs through {Pikuri::Sanitizer} so a tool observation
|
|
244
|
+
# can't smuggle an ESC or other control byte into this cyan line.
|
|
245
|
+
# The sanitizer's warnings are dropped — they belong at a
|
|
246
|
+
# confirmation prompt, not the stream narration.
|
|
247
|
+
#
|
|
237
248
|
# @param content [String] tool observation
|
|
238
249
|
# @return [String] single-line display form, possibly
|
|
239
250
|
# truncated
|
|
240
251
|
def truncate_tool_result(content)
|
|
241
252
|
original_bytes = content.to_s.bytesize
|
|
242
|
-
flattened = content.to_s.gsub(/\s+/, ' ').strip
|
|
253
|
+
flattened = Sanitizer.sanitize(content.to_s.gsub(/\s+/, ' ').strip).text
|
|
243
254
|
return flattened if flattened.length <= MAX_TOOL_RESULT_CHARS
|
|
244
255
|
|
|
245
256
|
"#{flattened[0, MAX_TOOL_RESULT_CHARS]}… (#{original_bytes} bytes total)"
|
|
@@ -6,8 +6,12 @@ module Pikuri
|
|
|
6
6
|
# Logs the conversation's context-window consumption per
|
|
7
7
|
# assistant turn via +Pikuri.logger_for('Tokens')+. Consumes
|
|
8
8
|
# {Event::Tokens} (one log line per emission) and
|
|
9
|
-
# {Event::ContextCap} (
|
|
10
|
-
#
|
|
9
|
+
# {Event::ContextCap} (the cap, picked off and cached —
|
|
10
|
+
# refreshed if a later {Event::ContextCap} arrives after a
|
|
11
|
+
# model switch); every other event variant is a no-op. A
|
|
12
|
+
# switch does not reset the running size or message count: the
|
|
13
|
+
# next {Event::Tokens} self-corrects the headline to the new
|
|
14
|
+
# model's count.
|
|
11
15
|
#
|
|
12
16
|
# Existence rationale: catch context-window growth before the
|
|
13
17
|
# provider raises +RubyLLM::ContextLengthExceededError+.
|
|
@@ -96,12 +100,19 @@ module Pikuri
|
|
|
96
100
|
# listener you get a fresh instance via {#for_sub_agent}.
|
|
97
101
|
attr_reader :id
|
|
98
102
|
|
|
99
|
-
# The most recent
|
|
100
|
-
#
|
|
101
|
-
#
|
|
103
|
+
# The most recent status line, *without* the +[<id>] +
|
|
104
|
+
# prefix — the prefix is {LOGGER}'s concern (the log
|
|
105
|
+
# stream interleaves agents, so its lines must carry the
|
|
106
|
+
# id inline); consumers of this reader get the id
|
|
107
|
+
# separately ({#id} here, the first callable parameter on
|
|
108
|
+
# {#on_status_line}) and decide the presentation
|
|
109
|
+
# themselves. Empty until the first {Event::Tokens} has
|
|
110
|
+
# been processed. Hosts that
|
|
102
111
|
# want to surface the current context-window snapshot in
|
|
103
112
|
# their own UI (e.g. a TUI status footer) read this
|
|
104
|
-
# instead of re-implementing the formatting.
|
|
113
|
+
# instead of re-implementing the formatting. Hosts that
|
|
114
|
+
# want to be *pushed* each new line instead of polling
|
|
115
|
+
# set {#on_status_line}.
|
|
105
116
|
#
|
|
106
117
|
# Thread safety: a single instance-variable read of a
|
|
107
118
|
# +String+ — safe to read from any thread; readers may
|
|
@@ -112,6 +123,34 @@ module Pikuri
|
|
|
112
123
|
# @return [String]
|
|
113
124
|
attr_reader :status_line
|
|
114
125
|
|
|
126
|
+
# Optional status-line observer, +nil+ by default. When
|
|
127
|
+
# set to a callable, it is invoked with the owning
|
|
128
|
+
# agent's {#id} and the freshly formatted {#status_line}
|
|
129
|
+
# after every {Event::Tokens} — the push counterpart to
|
|
130
|
+
# polling {#status_line}, for hosts that stream the line
|
|
131
|
+
# onward (a Sinatra SSE endpoint pushing it to the
|
|
132
|
+
# browser, a websocket, a TUI redraw trigger). Fires on
|
|
133
|
+
# whatever thread runs the owning agent's loop, so the
|
|
134
|
+
# callable must hand off to its sink thread-safely; and
|
|
135
|
+
# like any listener it sits on the loop's path — an
|
|
136
|
+
# exception it raises propagates into the conversation,
|
|
137
|
+
# so handle dead connections inside the callable.
|
|
138
|
+
#
|
|
139
|
+
# Instances derived via {#for_sub_agent} copy the
|
|
140
|
+
# observer set at derivation time, so one callable
|
|
141
|
+
# receives parent and sub-agent lines alike; the +id+
|
|
142
|
+
# parameter (+""+ for the main agent, the generated id
|
|
143
|
+
# for a sub-agent) is what lets the UI route each line to
|
|
144
|
+
# the right status row — the line itself is unprefixed
|
|
145
|
+
# (see {#status_line}). An observer assigned *after*
|
|
146
|
+
# a sub-agent spawned doesn't reach that sub-agent's
|
|
147
|
+
# instance.
|
|
148
|
+
#
|
|
149
|
+
# @return [Proc, nil] called with +(id, status_line)+ —
|
|
150
|
+
# the owning agent's id +String+ and the unprefixed
|
|
151
|
+
# {#status_line} +String+
|
|
152
|
+
attr_accessor :on_status_line
|
|
153
|
+
|
|
115
154
|
# @param id [String] owning agent's id, prepended to each
|
|
116
155
|
# log line as +[<id>] + when non-empty. Defaults to +""+
|
|
117
156
|
# for the main agent.
|
|
@@ -122,6 +161,7 @@ module Pikuri
|
|
|
122
161
|
@context_window_size = 0
|
|
123
162
|
@context_window_cap = nil
|
|
124
163
|
@status_line = ''
|
|
164
|
+
@on_status_line = nil
|
|
125
165
|
end
|
|
126
166
|
|
|
127
167
|
# Sub-agent variant: a fresh +TokenLog+ with a zeroed
|
|
@@ -132,12 +172,17 @@ module Pikuri
|
|
|
132
172
|
# prefix; defaults to +""+ when absent. The cap is left
|
|
133
173
|
# +nil+ here; the sub-agent's {Agent#initialize} emits a
|
|
134
174
|
# fresh {Event::ContextCap} immediately after construction
|
|
135
|
-
# and this listener picks it up off the stream.
|
|
175
|
+
# and this listener picks it up off the stream. The
|
|
176
|
+
# {#on_status_line} observer carries over by reference, so
|
|
177
|
+
# a host streaming the parent's lines sees the sub-agent's
|
|
178
|
+
# too — distinguished by the +id+ the callable receives.
|
|
136
179
|
#
|
|
137
180
|
# @param id [String] sub-agent's id
|
|
138
181
|
# @return [TokenLog]
|
|
139
182
|
def for_sub_agent(id: '', **)
|
|
140
|
-
self.class.new(id: id)
|
|
183
|
+
derived = self.class.new(id: id)
|
|
184
|
+
derived.on_status_line = @on_status_line
|
|
185
|
+
derived
|
|
141
186
|
end
|
|
142
187
|
|
|
143
188
|
# @param event [Agent::Event]
|
|
@@ -162,8 +207,9 @@ module Pikuri
|
|
|
162
207
|
|
|
163
208
|
private
|
|
164
209
|
|
|
165
|
-
# Update the snapshot
|
|
166
|
-
# subsystem logger
|
|
210
|
+
# Update the snapshot, write one +INFO+ line to the
|
|
211
|
+
# subsystem logger, and push the line to the status-line
|
|
212
|
+
# observer (when one was given at construction).
|
|
167
213
|
#
|
|
168
214
|
# @param tokens [Event::Tokens]
|
|
169
215
|
# @return [void]
|
|
@@ -178,13 +224,14 @@ module Pikuri
|
|
|
178
224
|
delta = @context_window_size - prev_ctx
|
|
179
225
|
|
|
180
226
|
@status_line = format_line(input_now, tokens.output.to_i, delta)
|
|
181
|
-
|
|
227
|
+
prefix = @id.empty? ? '' : "[#{@id}] "
|
|
228
|
+
LOGGER.info("#{prefix}#{@status_line}")
|
|
229
|
+
@on_status_line&.call(@id, @status_line)
|
|
182
230
|
end
|
|
183
231
|
|
|
184
232
|
def format_line(input, output, delta)
|
|
185
233
|
sign = delta.negative? ? '-' : '+'
|
|
186
|
-
|
|
187
|
-
"#{prefix}msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
|
|
234
|
+
"msg ##{@msg}: ctx=#{format_ctx} Δ#{sign}#{format_k(delta.abs)} ↑#{format_k(input)} ↓#{format_k(output)}"
|
|
188
235
|
end
|
|
189
236
|
|
|
190
237
|
# +<used>+ when no cap is set, +<used>/<cap>+ when one is.
|
|
@@ -11,10 +11,12 @@ module Pikuri
|
|
|
11
11
|
# == What lives here, what doesn't
|
|
12
12
|
#
|
|
13
13
|
# The directory holds *pure consumers*: code whose only side
|
|
14
|
-
# effect is to react to events
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# effect is to react to events already emitted. No listener
|
|
15
|
+
# writes back into the stream — emission belongs to the +Agent+
|
|
16
|
+
# (loop narration) and to extensions via
|
|
17
|
+
# {Agent::ExtensionContext#emit_event} (domain events) — and no
|
|
18
|
+
# listener reaches into ruby_llm's chat callbacks (that wiring
|
|
19
|
+
# lives in {Agent}).
|
|
18
20
|
#
|
|
19
21
|
# Host-facing signal holders — step budget, cancellation flag,
|
|
20
22
|
# mid-loop user input queue — are *controls*, not listeners.
|
|
@@ -45,7 +47,12 @@ module Pikuri
|
|
|
45
47
|
# Event::ThinkingDelta, Event::Assistant,
|
|
46
48
|
# Event::AssistantDelta, Event::ToolCall,
|
|
47
49
|
# Event::ToolResult, Event::Tokens, Event::ContextCap,
|
|
48
|
-
# Event::FallbackNotice, Event::Cancelled]
|
|
50
|
+
# Event::FallbackNotice, Event::Cancelled, Object] one of
|
|
51
|
+
# the core loop-narration variants, or a gem-defined
|
|
52
|
+
# domain event emitted via
|
|
53
|
+
# {Agent::ExtensionContext#emit_event} (e.g.
|
|
54
|
+
# +Pikuri::Tasks::ListChanged+) — match the variants you
|
|
55
|
+
# know, let everything else fall through
|
|
49
56
|
# @return [void]
|
|
50
57
|
def on_event(event); end
|
|
51
58
|
end
|
|
@@ -24,10 +24,14 @@ module Pikuri
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Dispatch one event to every listener, in registration order.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
27
|
+
# Exactly two callers: {Agent} (loop-narration {Event}
|
|
28
|
+
# variants) and {ExtensionContext#emit_event} (extension
|
|
29
|
+
# domain events). Listeners themselves never call this; the
|
|
30
|
+
# stream is one-way.
|
|
29
31
|
#
|
|
30
|
-
# @param event [Agent::Event
|
|
32
|
+
# @param event [Object] an {Agent::Event} variant or a
|
|
33
|
+
# gem-defined domain event (an immutable value, by
|
|
34
|
+
# convention a +Data+ instance)
|
|
31
35
|
# @return [void]
|
|
32
36
|
def emit(event)
|
|
33
37
|
@listeners.each { |l| l.on_event(event) }
|
|
@@ -78,20 +82,6 @@ module Pikuri
|
|
|
78
82
|
self.class.new(swapped)
|
|
79
83
|
end
|
|
80
84
|
|
|
81
|
-
# Return a new {ListenerList} containing this list's listeners
|
|
82
|
-
# plus the given extras, in order. Used by {Synthesizer} and
|
|
83
|
-
# other internal consumers to derive a list from an existing
|
|
84
|
-
# one. Returns +self+ when +extras+ is empty so the common
|
|
85
|
-
# no-op case allocates nothing.
|
|
86
|
-
#
|
|
87
|
-
# @param extras [Array<Listener::Base>] listeners to append
|
|
88
|
-
# @return [ListenerList]
|
|
89
|
-
def with(*extras)
|
|
90
|
-
return self if extras.empty?
|
|
91
|
-
|
|
92
|
-
self.class.new(@listeners + extras)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
85
|
# @example
|
|
96
86
|
# list.to_s # => "[Terminal, TokenLog(ctx=0.0k)]"
|
|
97
87
|
#
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
class Agent
|
|
5
|
-
#
|
|
6
|
-
# {Control::StepLimit} trips
|
|
7
|
-
# +
|
|
8
|
-
# the run still produces something
|
|
9
|
-
# assistant turn that answers the user's question
|
|
10
|
-
# whatever evidence the failed agent collected before
|
|
11
|
-
# out of budget.
|
|
5
|
+
# Prompt builder for the step-exhaustion rescue. When an
|
|
6
|
+
# +Agent+'s {Control::StepLimit} trips with the +:synthesize+
|
|
7
|
+
# policy, +Agent#run_loop+ runs this module's prompt on a
|
|
8
|
+
# nested tools-free agent so the run still produces something
|
|
9
|
+
# useful — an assistant turn that answers the user's question
|
|
10
|
+
# from whatever evidence the failed agent collected before
|
|
11
|
+
# running out of budget.
|
|
12
12
|
#
|
|
13
13
|
# == Why this exists
|
|
14
14
|
#
|
|
@@ -22,16 +22,24 @@ module Pikuri
|
|
|
22
22
|
# answer is largely in the messages — it just needs a
|
|
23
23
|
# tools-free pass to synthesize.
|
|
24
24
|
#
|
|
25
|
+
# Salvage is the wrong move for some agents, which is why the
|
|
26
|
+
# policy lives on {Control::StepLimit} and defaults to
|
|
27
|
+
# +:raise+ — a coding agent's half-finished work can't be
|
|
28
|
+
# completed by a tools-free pass, only described. See
|
|
29
|
+
# {Control::StepLimit}'s class header.
|
|
30
|
+
#
|
|
25
31
|
# == Seam discipline
|
|
26
32
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
33
|
+
# This module is pure prompt construction — no chat handling,
|
|
34
|
+
# no +RubyLLM.chat+ call, no event wiring. The execution side
|
|
35
|
+
# (constructing the nested agent, sharing the parent's
|
|
36
|
+
# listener stream and cancellable, capturing the answer) is
|
|
37
|
+
# +Agent#run_synthesizer+'s job: the synth is a regular
|
|
38
|
+
# tools-free +Agent+, the same construction shape the +agent+
|
|
39
|
+
# tool from +pikuri-subagents+ uses for sub-agents. The only
|
|
40
|
+
# +RubyLLM::*+ surface read here is the value-type
|
|
41
|
+
# +RubyLLM::Message+ / +ToolCall+ passthrough (per the
|
|
42
|
+
# value-type rule in CLAUDE.md).
|
|
35
43
|
module Synthesizer
|
|
36
44
|
# The synthesizer's system prompt. Strict and short: use
|
|
37
45
|
# the evidence, don't apologize, admit gaps when present.
|
|
@@ -39,58 +47,6 @@ module Pikuri
|
|
|
39
47
|
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.
|
|
40
48
|
PROMPT
|
|
41
49
|
|
|
42
|
-
# Configure +chat+ for synthesis, run one turn against it,
|
|
43
|
-
# and return the final assistant content. The chat is wired
|
|
44
|
-
# for the event stream via {Agent.wire_chat} so the synth's
|
|
45
|
-
# reasoning and answer flow through the same listener
|
|
46
|
-
# surface the parent agent uses — terminal renders them
|
|
47
|
-
# inline (padded under sub-agent), an in-memory recorder
|
|
48
|
-
# picks them up, a TokenLog tags them with the synth id.
|
|
49
|
-
#
|
|
50
|
-
# @param chat [RubyLLM::Chat] a *fresh* chat with no tools.
|
|
51
|
-
# The caller is responsible for constructing it with the
|
|
52
|
-
# same model/provider configuration the parent used.
|
|
53
|
-
# @param parent_messages [Array<RubyLLM::Message>] the
|
|
54
|
-
# parent chat's full message history at the moment of
|
|
55
|
-
# step exhaustion. Used to build the evidence transcript.
|
|
56
|
-
# @param user_message [String] the user's original question
|
|
57
|
-
# from the parent turn that exhausted.
|
|
58
|
-
# @param listeners [Agent::ListenerList] listeners to wire
|
|
59
|
-
# the synth chat into. Typically the parent agent's list
|
|
60
|
-
# run through {ListenerList#for_sub_agent} with the
|
|
61
|
-
# synth's +id:+ so any +TokenLog+ tags its lines with
|
|
62
|
-
# the synth bracket and any +Terminal+ pads its output.
|
|
63
|
-
# @param step_limit [Control::StepLimit, nil] defensive
|
|
64
|
-
# step budget. The synth has no tools so it should never
|
|
65
|
-
# trip +before_tool_call+, but a buggy provider that
|
|
66
|
-
# somehow returned a tool call would loop without one.
|
|
67
|
-
# Pass +nil+ to skip.
|
|
68
|
-
# @param cancellable [Control::Cancellable, nil]
|
|
69
|
-
# cancellation control. Typically the parent's instance,
|
|
70
|
-
# shared by reference so a user cancel during synthesis
|
|
71
|
-
# still works. Pass +nil+ to skip.
|
|
72
|
-
# @param streaming [Boolean] mirror the parent agent's
|
|
73
|
-
# +streaming+ flag. When +true+, {Agent.streaming_block}
|
|
74
|
-
# is passed to +chat.ask+ so the synth's reasoning and
|
|
75
|
-
# answer flow through the listener stream as deltas in
|
|
76
|
-
# addition to the final {Event::Thinking} / {Event::Assistant}
|
|
77
|
-
# bookends.
|
|
78
|
-
# @return [String, nil] the synth's final assistant
|
|
79
|
-
# content, or +nil+ if the synth somehow produced no
|
|
80
|
-
# assistant message
|
|
81
|
-
def self.run(chat:, parent_messages:, user_message:, listeners:,
|
|
82
|
-
step_limit: nil, cancellable: nil, streaming: false)
|
|
83
|
-
chat.with_instructions(SYSTEM_PROMPT)
|
|
84
|
-
Agent.wire_chat(chat, listeners: listeners, step_limit: step_limit, cancellable: cancellable)
|
|
85
|
-
prompt = build_prompt(parent_messages: parent_messages, user_message: user_message)
|
|
86
|
-
if streaming
|
|
87
|
-
chat.ask(prompt, &Agent.streaming_block(listeners: listeners, cancellable: cancellable))
|
|
88
|
-
else
|
|
89
|
-
chat.ask(prompt)
|
|
90
|
-
end
|
|
91
|
-
chat.messages.reverse.find { |m| m.role == :assistant }&.content
|
|
92
|
-
end
|
|
93
|
-
|
|
94
50
|
# Render the user's question plus an "Evidence gathered"
|
|
95
51
|
# section built from +parent_messages+ as a single prompt
|
|
96
52
|
# string. Pure function — no I/O, safe to test directly
|
|
@@ -140,6 +96,76 @@ module Pikuri
|
|
|
140
96
|
lines.join("\n").rstrip
|
|
141
97
|
end
|
|
142
98
|
private_class_method :format_evidence
|
|
99
|
+
|
|
100
|
+
# The +:synthesize+ arm of the step-exhaustion policy (see the
|
|
101
|
+
# class header). Runs the {Synthesizer} prompt over the
|
|
102
|
+
# exhausted chat's history on a nested tools-free +Agent+ —
|
|
103
|
+
# the same construction shape the +agent+ tool from
|
|
104
|
+
# +pikuri-subagents+ uses for sub-agents, so the synth gets
|
|
105
|
+
# listener propagation, transport / context-window-cap /
|
|
106
|
+
# streaming inheritance, and teardown via +close+ for free.
|
|
107
|
+
# The synth's answer is returned.
|
|
108
|
+
#
|
|
109
|
+
# @param ctx [ExtensionContext]
|
|
110
|
+
# @param chat_messages [Array<RubyLLM::Message>] the
|
|
111
|
+
# exhausted chat's full message history, the evidence
|
|
112
|
+
# {.build_prompt} renders
|
|
113
|
+
# @param user_message [String] the user's original question
|
|
114
|
+
# from the turn that exhausted
|
|
115
|
+
# @raise [Control::Cancellable::Cancelled] when a cancel
|
|
116
|
+
# landed between the budget tripping and this rescue —
|
|
117
|
+
# cancellation wins over salvage
|
|
118
|
+
# @return [String] the synth answer
|
|
119
|
+
def self.run_synthesizer(ctx, chat_messages, user_message)
|
|
120
|
+
# Check the cancel flag *before* constructing the synth: the
|
|
121
|
+
# nested run_loop resets the shared cancellable at its turn
|
|
122
|
+
# boundary, which would erase a cancel requested in this
|
|
123
|
+
# window. The raise propagates without a parent-side
|
|
124
|
+
# {Event::Cancelled} — a cancel *during* synthesis emits it
|
|
125
|
+
# from the synth's own rescue (on the derived listener list)
|
|
126
|
+
# instead, so either way the stream sees at most one.
|
|
127
|
+
ctx.agent.cancellable&.check!
|
|
128
|
+
|
|
129
|
+
ctx.emit_event(Event::FallbackNotice.new(
|
|
130
|
+
reason: "agent exhausted #{ctx.agent.step_limit.max} steps; " \
|
|
131
|
+
'synthesizing answer from gathered evidence'
|
|
132
|
+
))
|
|
133
|
+
|
|
134
|
+
# Synth runs under this agent's identity but with a
|
|
135
|
+
# different system prompt, so it gets a distinct
|
|
136
|
+
# +_synthesizer+ suffix on the id — same +_+ separator the
|
|
137
|
+
# sub-agent generator uses, so main becomes +"synthesizer"+
|
|
138
|
+
# and a sub-agent +"researcher 0"+ becomes
|
|
139
|
+
# +"researcher 0_synthesizer"+. Any +TokenLog+ in the list
|
|
140
|
+
# tags the synth's prompt under that bracket so it's
|
|
141
|
+
# obvious from the log which turns were the rescue rather
|
|
142
|
+
# than the original loop.
|
|
143
|
+
synth_id = ctx.agent.id.empty? ? 'synthesizer' : "#{ctx.agent.id}_synthesizer"
|
|
144
|
+
synth = Agent.new(
|
|
145
|
+
# Carry the parent's resolved cap on the transport so the synth
|
|
146
|
+
# reuses it without a re-probe — the cap rides {ChatTransport}
|
|
147
|
+
# now, not an +Agent.new(context_window:)+ kwarg.
|
|
148
|
+
transport: ctx.agent.transport.with(context_window: ctx.agent.context_window_cap),
|
|
149
|
+
system_prompt: Synthesizer::SYSTEM_PROMPT,
|
|
150
|
+
# Defensive budget with the default :raise policy: the
|
|
151
|
+
# synth has no tools so it should never tick, but a buggy
|
|
152
|
+
# provider that somehow returns a tool call must not loop
|
|
153
|
+
# forever — and a synth that needs its own synth is a bug,
|
|
154
|
+
# not a rescue.
|
|
155
|
+
step_limit: Control::StepLimit.new(max: 1),
|
|
156
|
+
cancellable: ctx.agent.cancellable,
|
|
157
|
+
id: synth_id,
|
|
158
|
+
streaming: ctx.agent.streaming
|
|
159
|
+
) { |c| c.add_listeners(ctx.sub_agent_listeners(id: synth_id)) }
|
|
160
|
+
begin
|
|
161
|
+
synth.run_loop(user_message: Synthesizer.build_prompt(
|
|
162
|
+
parent_messages: chat_messages, user_message: user_message
|
|
163
|
+
))
|
|
164
|
+
synth.last_assistant_content
|
|
165
|
+
ensure
|
|
166
|
+
synth.close
|
|
167
|
+
end
|
|
168
|
+
end
|
|
143
169
|
end
|
|
144
170
|
end
|
|
145
171
|
end
|