pikuri-core 0.0.5 → 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 +30 -37
- 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/extractor/html.rb +303 -0
- data/lib/pikuri/extractor/passthrough.rb +64 -0
- data/lib/pikuri/extractor.rb +314 -0
- data/lib/pikuri/file_type.rb +74 -266
- data/lib/pikuri/sanitizer.rb +179 -0
- data/lib/pikuri/subprocess.rb +73 -2
- data/lib/pikuri/tool/calculator.rb +213 -41
- data/lib/pikuri/tool/fetch.rb +10 -9
- data/lib/pikuri/tool/parameters.rb +65 -2
- data/lib/pikuri/tool/scraper.rb +186 -0
- 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_scrape.rb +5 -5
- data/lib/pikuri/tool/web_search.rb +45 -26
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +11 -10
- metadata +9 -66
- data/lib/pikuri/tool/scraper/fetch_error.rb +0 -16
- data/lib/pikuri/tool/scraper/html.rb +0 -285
- data/lib/pikuri/tool/scraper/pdf.rb +0 -54
- data/lib/pikuri/tool/scraper/simple.rb +0 -183
|
@@ -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
|
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rainbow'
|
|
4
|
-
require 'tty-markdown'
|
|
5
4
|
|
|
6
5
|
module Pikuri
|
|
7
6
|
class Agent
|
|
8
7
|
module Listener
|
|
9
8
|
# Terminal renderer for the normalized event stream: dim grey
|
|
10
|
-
# reasoning,
|
|
11
|
-
# call and tool-result lines, yellow fallback
|
|
12
|
-
# cancelled notice
|
|
9
|
+
# reasoning, assistant content printed raw (Markdown as-is),
|
|
10
|
+
# cyan tool-call and tool-result lines, yellow fallback
|
|
11
|
+
# notice, red cancelled notice, magenta model-switch notice.
|
|
12
|
+
# An {Event::SystemInjected} block (recalled
|
|
13
13
|
# memory / context an extension injected) renders dim grey
|
|
14
14
|
# with a +⊕+ marker. {Event::UserTurn} is intentionally silent
|
|
15
15
|
# (the terminal user just typed the message, so re-rendering
|
|
16
16
|
# it adds nothing); {Event::Tokens} and {Event::ContextCap}
|
|
17
17
|
# are silent too (their consumer is {TokenLog}).
|
|
18
18
|
#
|
|
19
|
+
# Assistant Markdown deliberately prints raw, with no
|
|
20
|
+
# Markdown-to-ANSI rendering. A renderer (+tty-markdown+)
|
|
21
|
+
# used to sit on the non-streaming path; it was dropped:
|
|
22
|
+
# rendering can never apply to the streaming path anyway
|
|
23
|
+
# (half-finished Markdown — broken code fences, half-built
|
|
24
|
+
# tables — doesn't render), the gem hadn't shipped a release
|
|
25
|
+
# since 2023 (its known ANSI-in-table crashes forced a
|
|
26
|
+
# rescue-and-degrade carve-out here), and it pulled seven
|
|
27
|
+
# transitive gems into the audit surface. Raw Markdown is
|
|
28
|
+
# perfectly readable in a terminal; proper rendering belongs
|
|
29
|
+
# to a richer host (the planned pikuri-tui).
|
|
30
|
+
#
|
|
19
31
|
# Optionally prepends a fixed number of leading spaces to
|
|
20
32
|
# every rendered line via the +padding:+ kwarg. Sub-agents
|
|
21
33
|
# get a fresh padded instance through {#for_sub_agent}
|
|
@@ -30,11 +42,8 @@ module Pikuri
|
|
|
30
42
|
# - {Event::ThinkingDelta} fragments print live in the same
|
|
31
43
|
# dim grey as the non-streaming {Event::Thinking}, with no
|
|
32
44
|
# trailing newline so the next fragment continues the line.
|
|
33
|
-
# - {Event::AssistantDelta} fragments print live
|
|
34
|
-
#
|
|
35
|
-
# Markdown (broken code blocks, half-rendered tables), so
|
|
36
|
-
# the live stream gives up formatting in exchange for
|
|
37
|
-
# liveness.
|
|
45
|
+
# - {Event::AssistantDelta} fragments print live the same
|
|
46
|
+
# way, uncolored.
|
|
38
47
|
# - {Event::Thinking} and {Event::Assistant} bookends print
|
|
39
48
|
# a single blank line as a stream terminator, not their
|
|
40
49
|
# content (the content already landed via the deltas). The
|
|
@@ -45,15 +54,6 @@ module Pikuri
|
|
|
45
54
|
# the deltas are silently ignored and the bookend events
|
|
46
55
|
# render the full text the way they always have.
|
|
47
56
|
class Terminal < Base
|
|
48
|
-
# Subsystem logger; set its level with +PIKURI_LOG_TERMINAL+
|
|
49
|
-
# or the global +PIKURI_LOG+. Used for the narrow rescue
|
|
50
|
-
# around third-party rendering (+tty-markdown+ choking on
|
|
51
|
-
# assistant output) — see the CLAUDE.md "secondary to the
|
|
52
|
-
# loop" carve-out.
|
|
53
|
-
#
|
|
54
|
-
# @return [Logger]
|
|
55
|
-
LOGGER = Pikuri.logger_for('Terminal')
|
|
56
|
-
|
|
57
57
|
# Cap, in characters, applied to tool-result content
|
|
58
58
|
# rendered to the terminal. Anything longer is truncated
|
|
59
59
|
# with a marker that reports the original byte size so the
|
|
@@ -119,7 +119,7 @@ module Pikuri
|
|
|
119
119
|
if @streaming
|
|
120
120
|
terminate_stream
|
|
121
121
|
else
|
|
122
|
-
println(indent(
|
|
122
|
+
println(indent(content))
|
|
123
123
|
end
|
|
124
124
|
in Event::ThinkingDelta(content:)
|
|
125
125
|
stream_fragment(Rainbow(content).color(85, 85, 85)) if @streaming
|
|
@@ -132,6 +132,8 @@ module Pikuri
|
|
|
132
132
|
println(indent(Rainbow("→ #{name}(#{args})").cyan))
|
|
133
133
|
in Event::ToolResult(content:)
|
|
134
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))
|
|
135
137
|
in Event::FallbackNotice(reason:)
|
|
136
138
|
println(indent(Rainbow("! #{reason}").yellow))
|
|
137
139
|
in Event::Cancelled
|
|
@@ -228,23 +230,6 @@ module Pikuri
|
|
|
228
230
|
text.to_s.each_line.map { |line| prefix + line }.join
|
|
229
231
|
end
|
|
230
232
|
|
|
231
|
-
# Render assistant Markdown for the terminal, degrading to
|
|
232
|
-
# the raw string when the renderer raises. tty-markdown /
|
|
233
|
-
# strings have known bugs around ANSI inside tables (e.g.
|
|
234
|
-
# +Strings::Wrap.insert_ansi+ raising +IndexError+); we'd
|
|
235
|
-
# rather show ugly Markdown than abort an in-flight
|
|
236
|
-
# conversation.
|
|
237
|
-
#
|
|
238
|
-
# @param content [String] assistant Markdown
|
|
239
|
-
# @return [String] rendered ANSI text, or +content+
|
|
240
|
-
# unchanged on render failure
|
|
241
|
-
def render_markdown(content)
|
|
242
|
-
TTY::Markdown.parse(content)
|
|
243
|
-
rescue StandardError => e
|
|
244
|
-
LOGGER.warn("TTY::Markdown render failed (#{e.class}: #{e.message}); falling back to raw text")
|
|
245
|
-
content
|
|
246
|
-
end
|
|
247
|
-
|
|
248
233
|
# Flatten whitespace and cap to {MAX_TOOL_RESULT_CHARS}. The
|
|
249
234
|
# cap keeps multi-screen dumps (rendered HTML, PDF text)
|
|
250
235
|
# from drowning the terminal stream; the byte-count suffix
|
|
@@ -252,12 +237,20 @@ module Pikuri
|
|
|
252
237
|
# exactly this" from "tool returned much more, you're
|
|
253
238
|
# seeing a slice."
|
|
254
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
|
+
#
|
|
255
248
|
# @param content [String] tool observation
|
|
256
249
|
# @return [String] single-line display form, possibly
|
|
257
250
|
# truncated
|
|
258
251
|
def truncate_tool_result(content)
|
|
259
252
|
original_bytes = content.to_s.bytesize
|
|
260
|
-
flattened = content.to_s.gsub(/\s+/, ' ').strip
|
|
253
|
+
flattened = Sanitizer.sanitize(content.to_s.gsub(/\s+/, ' ').strip).text
|
|
261
254
|
return flattened if flattened.length <= MAX_TOOL_RESULT_CHARS
|
|
262
255
|
|
|
263
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
|
#
|