pikuri-core 0.0.7 → 0.1.0
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 +1 -1
- data/lib/pikuri/agent/chat_transport.rb +73 -93
- data/lib/pikuri/agent/configurator.rb +46 -106
- data/lib/pikuri/agent/context_window_detector.rb +44 -85
- data/lib/pikuri/agent/control/cancellable.rb +87 -66
- data/lib/pikuri/agent/control/interloper.rb +127 -105
- data/lib/pikuri/agent/control/step_limit.rb +25 -41
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +123 -188
- data/lib/pikuri/agent/extension.rb +118 -94
- data/lib/pikuri/agent/extension_context.rb +50 -77
- data/lib/pikuri/agent/history.rb +653 -0
- data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
- data/lib/pikuri/agent/listener/terminal.rb +143 -117
- data/lib/pikuri/agent/listener/token_log.rb +101 -140
- data/lib/pikuri/agent/listener.rb +23 -43
- data/lib/pikuri/agent/listener_list.rb +26 -47
- data/lib/pikuri/agent/synthesizer.rb +45 -87
- data/lib/pikuri/agent.rb +816 -474
- data/lib/pikuri/bundler_env.rb +68 -0
- data/lib/pikuri/extractor/html.rb +63 -110
- data/lib/pikuri/extractor/passthrough.rb +20 -30
- data/lib/pikuri/extractor.rb +93 -154
- data/lib/pikuri/file_type.rb +63 -135
- data/lib/pikuri/finalizers.rb +32 -47
- data/lib/pikuri/paths.rb +104 -13
- data/lib/pikuri/ruby_llm_patches.rb +106 -0
- data/lib/pikuri/sanitizer.rb +45 -67
- data/lib/pikuri/subprocess.rb +75 -119
- data/lib/pikuri/testing.rb +296 -0
- data/lib/pikuri/tool/calculator.rb +56 -66
- data/lib/pikuri/tool/execute_context.rb +42 -0
- data/lib/pikuri/tool/fetch.rb +51 -77
- data/lib/pikuri/tool/parameters.rb +21 -29
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +52 -80
- data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
- data/lib/pikuri/tool/search/engines.rb +230 -97
- data/lib/pikuri/tool/search/exa.rb +56 -90
- data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
- data/lib/pikuri/tool/search/result.rb +10 -15
- data/lib/pikuri/tool/trifecta_legs.rb +217 -0
- data/lib/pikuri/tool/web_scrape.rb +38 -54
- data/lib/pikuri/tool/web_search.rb +100 -24
- data/lib/pikuri/tool.rb +140 -65
- data/lib/pikuri/trifecta/contribution.rb +43 -0
- data/lib/pikuri/trifecta/node.rb +47 -0
- data/lib/pikuri/trifecta/report.rb +230 -0
- data/lib/pikuri/trifecta.rb +127 -0
- data/lib/pikuri/url_cache.rb +33 -49
- data/lib/pikuri/version.rb +1 -1
- data/lib/pikuri-core.rb +72 -88
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +14 -3
data/lib/pikuri/agent/event.rb
CHANGED
|
@@ -2,231 +2,166 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
class Agent
|
|
5
|
-
# Sealed value-object hierarchy
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# the variant.
|
|
5
|
+
# Sealed value-object hierarchy for the +Agent+'s normalized event
|
|
6
|
+
# stream. Listeners consume every variant through one
|
|
7
|
+
# {Listener::Base#on_event} and +case+-match on it.
|
|
9
8
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# context-window cap rides as a one-shot {ContextCap} emitted by
|
|
17
|
-
# {Agent#initialize}; everything else maps to a turn-or-tool-call
|
|
18
|
-
# variant. Listeners override a single +on_event+ method and
|
|
19
|
-
# +case+-match on the variant they care about. The per-variant
|
|
20
|
-
# docs below name the emission site for each (which {Agent}
|
|
21
|
-
# callback wires it and what payload it carries).
|
|
22
|
-
#
|
|
23
|
-
# == Sealed for loop narration; gems add domain events
|
|
24
|
-
#
|
|
25
|
-
# "Sealed" applies to the *loop-narration* vocabulary: the
|
|
26
|
-
# variants below are the complete set, all emitted by {Agent},
|
|
27
|
-
# and new chat-loop observability belongs here, not in a gem.
|
|
28
|
-
# Gems may define their own *domain* events in their own
|
|
29
|
-
# namespace (e.g. +Pikuri::Tasks::ListChanged+) and emit them
|
|
30
|
-
# via {ExtensionContext#emit_event}; they ride the same stream.
|
|
31
|
-
# Listeners must no-op on variants they don't recognize —
|
|
32
|
-
# {Listener::Base#on_event}'s default plus +case+-fallthrough
|
|
33
|
-
# give that for free.
|
|
9
|
+
# "Sealed" scopes the *loop-narration* vocabulary: the variants below
|
|
10
|
+
# are the complete set, all emitted by {Agent}, and new chat-loop
|
|
11
|
+
# observability belongs here. Gems define their own *domain* events in
|
|
12
|
+
# their own namespace (e.g. +Pikuri::Tasks::ListChanged+) and emit
|
|
13
|
+
# them via {ExtensionContext#emit_event} onto the same stream;
|
|
14
|
+
# listeners must no-op on variants they don't recognize.
|
|
34
15
|
module Event
|
|
35
|
-
# User
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# one). Controls themselves no longer see this event — the
|
|
42
|
-
# +Agent+ pokes their +reset!+ / +tick!+ entry points directly
|
|
43
|
-
# at the right boundaries.
|
|
44
|
-
#
|
|
45
|
-
# Emitted in two places: by {Agent#run_loop} at the start of
|
|
46
|
-
# each turn (with +mid_loop: false+), and by {Agent}'s
|
|
47
|
-
# +after_tool_result+ wiring when a queued
|
|
48
|
-
# {Control::Interloper} item drains into the chat history
|
|
49
|
-
# (with +mid_loop: true+).
|
|
16
|
+
# User input for a turn (+mid_loop: false+, the default) or a host
|
|
17
|
+
# injection delivered mid-loop (+mid_loop: true+, drained from
|
|
18
|
+
# {Control::Interloper}). The flag lets a listener treating
|
|
19
|
+
# +UserTurn+ as a turn boundary tell a fresh turn from an in-loop
|
|
20
|
+
# injection. Emitted by {Agent#run_loop} at turn start, and when an
|
|
21
|
+
# interloper item drains in after a tool batch.
|
|
50
22
|
UserTurn = Data.define(:content, :mid_loop) do
|
|
51
23
|
# @param content [String] user-supplied text
|
|
52
|
-
# @param mid_loop [Boolean] +
|
|
53
|
-
#
|
|
54
|
-
# {Control::Interloper}
|
|
24
|
+
# @param mid_loop [Boolean] +true+ when drained from
|
|
25
|
+
# {Control::Interloper}; default +false+
|
|
55
26
|
def initialize(content:, mid_loop: false)
|
|
56
27
|
super
|
|
57
28
|
end
|
|
58
29
|
end
|
|
59
30
|
|
|
60
|
-
# A
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
# the
|
|
31
|
+
# A reference block injected into the chat log — recalled memory, a
|
|
32
|
+
# host-loaded skill, a path-activation promotion, retrieved snippets.
|
|
33
|
+
# Carries the block *unwrapped*, without the +<system-reminder>+
|
|
34
|
+
# envelope {Agent#append_reference_block} sends it under, so a listener
|
|
35
|
+
# renders what the host wrote rather than pikuri's framing.
|
|
65
36
|
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# faithful mirror of what the model actually sees. Without it
|
|
70
|
-
# an injection is invisible: it never surfaces in the stream,
|
|
71
|
-
# only as a secondary echo in the assistant's later reasoning.
|
|
72
|
-
# {Listener::Terminal} renders it dim grey with a +⊕+ marker.
|
|
37
|
+
# Named for the *role it plays* (background reference, not new user
|
|
38
|
+
# input), not the wire role it lands under — which is +:user+, because a
|
|
39
|
+
# mid-conversation +:system+ message is unportable.
|
|
73
40
|
SystemInjected = Data.define(:content)
|
|
74
41
|
|
|
75
|
-
# Assistant reasoning ("thinking") block,
|
|
76
|
-
# +
|
|
77
|
-
#
|
|
78
|
-
# empty +thinking.text+ is filtered at the dispatch site so
|
|
79
|
-
# listeners never see vacuous events.
|
|
42
|
+
# Assistant reasoning ("thinking") block, from +thinking.text+ on a
|
|
43
|
+
# +RubyLLM::Message+. Emitted by +after_message+; empty text is
|
|
44
|
+
# filtered at the dispatch site.
|
|
80
45
|
Thinking = Data.define(:content)
|
|
81
46
|
|
|
82
|
-
# Assistant Markdown content,
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
# (pure tool-call turns surface {Tokens} only, no +Assistant+).
|
|
47
|
+
# Assistant Markdown content, from a +RubyLLM::Message+. Emitted by
|
|
48
|
+
# +after_message+; empty content is filtered (pure tool-call turns
|
|
49
|
+
# surface {Tokens} only).
|
|
86
50
|
Assistant = Data.define(:content)
|
|
87
51
|
|
|
88
|
-
# Streaming fragment of
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# passes to +Chat#complete+ when the agent's +streaming:+
|
|
92
|
-
# flag is on; empty fragments are filtered at the dispatch
|
|
93
|
-
# site.
|
|
52
|
+
# Streaming fragment of a reasoning block, off a +RubyLLM::Chunk+
|
|
53
|
+
# when +streaming:+ is on. Emitted per-chunk by {Agent#run_loop};
|
|
54
|
+
# empty fragments filtered.
|
|
94
55
|
#
|
|
95
|
-
# Preview-only
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
# == Ordering
|
|
103
|
-
#
|
|
104
|
-
# Per round-trip: all {ThinkingDelta}s (and {AssistantDelta}s)
|
|
105
|
-
# for a round arrive before that round's {Thinking} /
|
|
106
|
-
# {Assistant} / {Tokens} bookend, because the streaming block
|
|
107
|
-
# fires synchronously inside +Chat#ask+'s SSE read and
|
|
108
|
-
# +after_message+ fires once the message is complete. Within
|
|
109
|
-
# the delta stream itself, ordering between {ThinkingDelta}
|
|
110
|
-
# and {AssistantDelta} is provider-dependent (in practice
|
|
111
|
-
# non-interleaved on Anthropic and OpenAI reasoning models,
|
|
112
|
-
# but pikuri does not enforce it).
|
|
56
|
+
# Preview-only: the {Thinking} from +after_message+ is
|
|
57
|
+
# authoritative. +concat(deltas) == final+ is not guaranteed
|
|
58
|
+
# (whitespace normalization, Anthropic signature blocks). All of a
|
|
59
|
+
# round's deltas arrive before that round's
|
|
60
|
+
# {Thinking}/{Assistant}/{Tokens} bookend; {ThinkingDelta} vs
|
|
61
|
+
# {AssistantDelta} interleave order is provider-dependent.
|
|
113
62
|
ThinkingDelta = Data.define(:content)
|
|
114
63
|
|
|
115
|
-
# Streaming fragment of
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
# {Agent#run_loop} passes to +Chat#complete+ when the
|
|
119
|
-
# agent's +streaming:+ flag is on; empty fragments are
|
|
120
|
-
# filtered at the dispatch site.
|
|
121
|
-
#
|
|
122
|
-
# Preview-only, same semantics as {ThinkingDelta}: the
|
|
123
|
-
# {Assistant} event emitted from +after_message+ at the end
|
|
124
|
-
# of the round-trip is the authoritative final text;
|
|
125
|
-
# listeners that need an exact concat of fragments should
|
|
126
|
-
# consume {Assistant} instead. Per-round-trip ordering is
|
|
127
|
-
# guaranteed; per-modality ordering within the delta stream
|
|
128
|
-
# is best-effort.
|
|
64
|
+
# Streaming fragment of assistant Markdown, off a +RubyLLM::Chunk+
|
|
65
|
+
# when +streaming:+ is on. Same preview-only semantics as
|
|
66
|
+
# {ThinkingDelta}; consume {Assistant} for the authoritative text.
|
|
129
67
|
AssistantDelta = Data.define(:content)
|
|
130
68
|
|
|
131
|
-
# A tool
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
# {Agent}'s +before_tool_call+ wiring.
|
|
69
|
+
# A tool the LLM requested but hasn't observed yet. +arguments+ is
|
|
70
|
+
# the raw hash ruby_llm parsed from the model's +tool_calls+ JSON —
|
|
71
|
+
# unvalidated. Emitted by +before_tool_call+.
|
|
135
72
|
ToolCall = Data.define(:name, :arguments)
|
|
136
73
|
|
|
137
|
-
# The observation a tool produced
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
# Emitted by {Agent}'s +after_tool_result+ wiring.
|
|
74
|
+
# The observation a tool produced ({Tool#run}'s return). Recoverable
|
|
75
|
+
# failures arrive as +"Error: ..."+ strings, not exceptions. Emitted
|
|
76
|
+
# by +after_tool_result+.
|
|
141
77
|
ToolResult = Data.define(:content)
|
|
142
78
|
|
|
143
|
-
# Provider-reported token usage for
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
# been filtered for empty content (those are exactly the turns
|
|
148
|
-
# where context-window growth matters most).
|
|
149
|
-
#
|
|
150
|
-
# All counts are +Integer, nil+. +nil+ means the provider did not
|
|
151
|
-
# report that field — common with local llama.cpp / Ollama
|
|
152
|
-
# servers that leave parts of the OpenAI +usage+ block empty.
|
|
153
|
-
# Listeners treat +nil+ as zero.
|
|
79
|
+
# Provider-reported token usage for one assistant turn, off a
|
|
80
|
+
# +RubyLLM::Message+'s +tokens+ block. Emitted by +after_message+ on
|
|
81
|
+
# every assistant turn (including pure tool-call turns, where
|
|
82
|
+
# context growth matters most).
|
|
154
83
|
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
84
|
+
# Every count is +Integer, nil+; +nil+ means the provider omitted
|
|
85
|
+
# the field (common on local llama.cpp/Ollama) and is treated as
|
|
86
|
+
# zero. +input+/+cached+/+cache_creation+ are *exclusive* slices of
|
|
87
|
+
# this turn's prompt and sum to its total size (llama.cpp +
|
|
88
|
+
# Anthropic shape; OpenAI nests +cached+ inside +input+, so revisit
|
|
89
|
+
# the sum if pikuri ever targets it directly). +cached+ still counts
|
|
90
|
+
# against the context window.
|
|
162
91
|
#
|
|
163
92
|
# - +input+ — newly-processed (uncached) prompt tokens this turn.
|
|
164
|
-
# - +output+ — tokens in this
|
|
165
|
-
# - +cached+ —
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
# - +
|
|
170
|
-
# into the prompt cache. Anthropic-specific; usually +nil+ on
|
|
171
|
-
# OpenAI-compatible local servers.
|
|
172
|
-
# - +thinking+ — extended-thinking (Anthropic) or reasoning
|
|
173
|
-
# (OpenAI o-series) tokens produced on this turn. +nil+ on
|
|
174
|
-
# providers without a reasoning channel.
|
|
175
|
-
# - +model_id+ — provider-side model name as reported on the
|
|
176
|
-
# response; useful when a process targets multiple models.
|
|
177
|
-
#
|
|
178
|
-
# == Computing "current context window size"
|
|
179
|
-
#
|
|
180
|
-
# +input + cached + cache_creation+ is the size of the prompt
|
|
181
|
-
# processed on this turn. Add +output+ to get tokens consumed by
|
|
182
|
-
# the conversation *through* this turn — this turn's prompt plus
|
|
183
|
-
# its reply, both of which the model will re-process on the next
|
|
184
|
-
# turn. That's what climbs toward
|
|
185
|
-
# +RubyLLM::ContextLengthExceededError+ and is the snapshot
|
|
186
|
-
# {Listener::TokenLog#context_window_size} tracks.
|
|
93
|
+
# - +output+ — tokens in this reply.
|
|
94
|
+
# - +cached+ — prompt tokens served from the provider's cache.
|
|
95
|
+
# - +cache_creation+ — prompt tokens written to the cache (Anthropic).
|
|
96
|
+
# - +thinking+ — reasoning tokens (Anthropic thinking / OpenAI
|
|
97
|
+
# o-series); +nil+ without a reasoning channel.
|
|
98
|
+
# - +model_id+ — provider-side model name on the response.
|
|
187
99
|
Tokens = Data.define(:input, :output, :cached, :cache_creation, :thinking, :model_id)
|
|
188
100
|
|
|
189
|
-
# Model's resolved context-window cap. Emitted at
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
# property of the model. Carries +nil+ when no source produced
|
|
193
|
-
# a value (a non-llama server with no explicit cap). Listeners
|
|
194
|
-
# that care — {Listener::TokenLog} renders +ctx=<used>/<cap>+
|
|
195
|
-
# when set, +ctx=<used>+ when +nil+ — pick the value off this
|
|
196
|
-
# event and cache it; non-caring listeners ignore. A second
|
|
197
|
-
# ContextCap simply overwrites the first; the conversation is
|
|
198
|
-
# not re-baselined (a switch keeps the running context size).
|
|
101
|
+
# Model's resolved context-window cap (+nil+ if unknown). Emitted at
|
|
102
|
+
# construction and after each model switch. A later ContextCap
|
|
103
|
+
# overwrites the prior; the running context size is not re-baselined.
|
|
199
104
|
ContextCap = Data.define(:cap)
|
|
200
105
|
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
# adds ANSI off +.model+, a web client adds CSS). The cap rides
|
|
207
|
-
# on the paired {ContextCap}, not here, so {Listener::TokenLog}
|
|
208
|
-
# needs no awareness of this event (its existing {ContextCap}
|
|
209
|
-
# arm picks up the new cap); a renderer wanting "switched to X
|
|
210
|
-
# (128k)" on one line correlates the two.
|
|
106
|
+
# Agent switched model mid-conversation. Emitted by {Agent#run_loop}
|
|
107
|
+
# just before the new model's {ContextCap}. Carries both
|
|
108
|
+
# {Agent::ChatTransport}s verbatim and unformatted — each chrome
|
|
109
|
+
# presents them its own way. The new cap rides on the paired
|
|
110
|
+
# {ContextCap}, not here.
|
|
211
111
|
ModelSwitched = Data.define(:from, :to)
|
|
212
112
|
|
|
213
|
-
# Out-of-band notice that
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
# the
|
|
217
|
-
# web UI) surface the divergence to the user before the
|
|
218
|
-
# synthesizer's own assistant output flows through.
|
|
113
|
+
# Out-of-band notice that a rescue path was taken: emitted by
|
|
114
|
+
# {Agent#run_loop} when {Control::StepLimit} trips and the
|
|
115
|
+
# {Synthesizer} fallback runs. Carries the reason to surface before
|
|
116
|
+
# the synthesizer's own output flows through.
|
|
219
117
|
FallbackNotice = Data.define(:reason)
|
|
220
118
|
|
|
221
|
-
# Out-of-band notice that the user cancelled the in-flight turn
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
# the
|
|
225
|
-
# recorders) can mark the turn as user-aborted. Unlike
|
|
226
|
-
# {FallbackNotice}, no recovery follows — the exception is
|
|
227
|
-
# re-raised and the caller is expected to return control to the
|
|
228
|
-
# user (typically the REPL prompt).
|
|
119
|
+
# Out-of-band notice that the user cancelled the in-flight turn via
|
|
120
|
+
# {Control::Cancellable}. Emitted by {Agent#run_loop} just before the
|
|
121
|
+
# +Cancelled+ exception re-raises — unlike {FallbackNotice}, no
|
|
122
|
+
# recovery follows; the caller returns control to the user.
|
|
229
123
|
Cancelled = Data.define
|
|
124
|
+
|
|
125
|
+
# Marks a *domain* event whose successor **replaces** it rather than adding
|
|
126
|
+
# to a log: an indexing bar, a download percentage. A chrome that can
|
|
127
|
+
# rewrite a line does; one that cannot shows only the final state.
|
|
128
|
+
#
|
|
129
|
+
# Progress = Data.define(:title, :done) do
|
|
130
|
+
# include Pikuri::Agent::Event::Transient
|
|
131
|
+
# def to_s = done ? "#{title} — done" : title
|
|
132
|
+
# end
|
|
133
|
+
#
|
|
134
|
+
# The contract is two methods, both of which a +Data.define+ already
|
|
135
|
+
# answers: +#to_s+ is the whole rendering — a listener never inspects the
|
|
136
|
+
# fields, which is what keeps core ignorant of a gem's payload — and
|
|
137
|
+
# +#done+ says the sequence is over and its line may go away.
|
|
138
|
+
module Transient
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The conversation was cleared by {Agent#clear_conversation}: history
|
|
142
|
+
# is back to the system prompt, controls are reset, and each
|
|
143
|
+
# extension's {Extension#on_conversation_reset} has run. Emitted
|
|
144
|
+
# once, *before* the extension sweep, so pure-consumer listeners can
|
|
145
|
+
# reset display state; extension-owned state is signalled by the
|
|
146
|
+
# extensions' own domain events, not here. No payload.
|
|
147
|
+
Reset = Data.define
|
|
148
|
+
|
|
149
|
+
# A saved conversation was restored by {Agent#load_history!}. Always
|
|
150
|
+
# follows a {Reset}, which is the part that clears display state — this
|
|
151
|
+
# one says the conversation is *not* empty after all, and carries what a
|
|
152
|
+
# listener needs to stop lying about it.
|
|
153
|
+
#
|
|
154
|
+
# +tokens+ is the last assistant message's usage as recorded when the
|
|
155
|
+
# conversation was exported, so a running context readout can pick up
|
|
156
|
+
# where it left off instead of showing +0+ against 40k of restored
|
|
157
|
+
# history. +nil+ when the history holds no assistant turn, or was
|
|
158
|
+
# exported without usage.
|
|
159
|
+
#
|
|
160
|
+
# @!attribute [r] messages
|
|
161
|
+
# @return [Integer] how many messages were restored
|
|
162
|
+
# @!attribute [r] tokens
|
|
163
|
+
# @return [Tokens, nil] usage of the last restored assistant message
|
|
164
|
+
HistoryLoaded = Data.define(:messages, :tokens)
|
|
230
165
|
end
|
|
231
166
|
end
|
|
232
167
|
end
|
|
@@ -3,120 +3,144 @@
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
class Agent
|
|
5
5
|
# The Extension protocol — how hosts bolt extra capabilities
|
|
6
|
-
# (system-prompt
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
6
|
+
# (system-prompt sections, tools, lifecycle hooks) onto an {Agent}.
|
|
7
|
+
# Added via {Configurator#add_extension} inside the +Agent.new+ block;
|
|
8
|
+
# the Agent then drives a fixed hook set: {#configure} during the block
|
|
9
|
+
# (gets the {Configurator}), {#bind} once fully constructed,
|
|
10
|
+
# {#on_user_message} per user turn, {#on_conversation_reset} per
|
|
11
|
+
# {Agent#clear_conversation} (these three get the runtime
|
|
12
|
+
# {ExtensionContext}), and {#system_prompt_snippets} whenever the prompt
|
|
13
|
+
# is (re)assembled (no argument — reads state set in +configure+).
|
|
12
14
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# (domain-event emission, raw tool registration, sub-agent
|
|
18
|
-
# listener derivation), with the agent itself readable via
|
|
19
|
-
# {ExtensionContext#agent}.
|
|
15
|
+
# Mix this module in to inherit no-op defaults for every hook, and
|
|
16
|
+
# override what you need. Not including it also works *if the class
|
|
17
|
+
# defines every hook* — the Agent calls them by name with no
|
|
18
|
+
# +respond_to?+ guard, so a missing one raises.
|
|
20
19
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# you need. Extensions that don't +include+ this module still
|
|
24
|
-
# work *if they define all three methods themselves* — the Agent
|
|
25
|
-
# and Configurator call them by name with no +respond_to?+ guard,
|
|
26
|
-
# so a missing one raises. The module exists to make the protocol
|
|
27
|
-
# *explicit* and to give "I want to implement just +configure+"
|
|
28
|
-
# extensions free no-op +bind+ / +on_user_message+ defaults (and
|
|
29
|
-
# any other combination).
|
|
20
|
+
# The whole surface fires on the *parent agent only* — sub-agents do not
|
|
21
|
+
# inherit extensions (each persona owns its toolset and prompt verbatim).
|
|
30
22
|
#
|
|
31
23
|
# == Example
|
|
32
24
|
#
|
|
33
25
|
# class MyExtension
|
|
34
26
|
# include Pikuri::Agent::Extension
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
# c.append_system_prompt("Always be polite.")
|
|
38
|
-
# end
|
|
39
|
-
#
|
|
40
|
-
# # bind not overridden — inherits the empty default
|
|
27
|
+
# def system_prompt_snippets = ["Always be polite."]
|
|
28
|
+
# # configure / bind not overridden — inherit the empty defaults
|
|
41
29
|
# end
|
|
42
30
|
#
|
|
43
|
-
# See +Pikuri::Mcp::Extension+
|
|
44
|
-
#
|
|
45
|
-
# IDEAS.md §"Extension protocol design") for the canonical
|
|
46
|
-
# worked implementations.
|
|
31
|
+
# See +Pikuri::Mcp::Extension+ / +Pikuri::Skill::Extension+ for worked
|
|
32
|
+
# implementations.
|
|
47
33
|
module Extension
|
|
48
34
|
# Called immediately by {Configurator#add_extension} during the
|
|
49
|
-
# +Agent.new+ block, with the parent
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# *
|
|
56
|
-
#
|
|
57
|
-
# * register tools via {Configurator#add_tool}
|
|
58
|
-
# * register listeners via {Configurator#add_listener}
|
|
59
|
-
# * register parent-only +on_close+ handlers via
|
|
60
|
-
# {Configurator#on_close} (for cleanup of resources the
|
|
61
|
-
# extension created in +configure+)
|
|
62
|
-
# * read the agent's transport / cancellable / etc. via the
|
|
63
|
-
# Configurator's +attr_reader+s
|
|
35
|
+
# +Agent.new+ block, once per instance, with the parent's
|
|
36
|
+
# {Configurator}. Default no-op; override to install *agent-agnostic*
|
|
37
|
+
# state — typically:
|
|
38
|
+
#
|
|
39
|
+
# * tools via {Configurator#add_tool}, listeners via
|
|
40
|
+
# {Configurator#add_listener}
|
|
41
|
+
# * parent-only +on_close+ cleanup via {Configurator#on_close}
|
|
42
|
+
# * read transport / cancellable / etc. off the Configurator
|
|
64
43
|
#
|
|
65
44
|
# @param c [Configurator] the parent agent's Configurator
|
|
66
45
|
# @return [void]
|
|
67
46
|
def configure(c); end
|
|
68
47
|
|
|
69
|
-
# Called by {Agent#initialize} after the block returns and the
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
# (used by {Pikuri::Mcp::Extension} for +mcp_connect+,
|
|
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
|
|
89
|
-
#
|
|
90
|
-
# @param ctx [ExtensionContext] capability facade for the
|
|
91
|
-
# live, fully wired agent
|
|
48
|
+
# Called by {Agent#initialize} after the block returns and the chat is
|
|
49
|
+
# fully wired, with the agent's {ExtensionContext}. Default no-op;
|
|
50
|
+
# override to install state keyed to the live agent — typically:
|
|
51
|
+
#
|
|
52
|
+
# * dynamic tools via {ExtensionContext#add_raw_tool}
|
|
53
|
+
# * domain-event wiring via {ExtensionContext#emit_event}
|
|
54
|
+
# * per-agent +on_close+ via {ExtensionContext#on_close}
|
|
55
|
+
# * stash +ctx+ if the extension's tools act on this agent later
|
|
56
|
+
#
|
|
57
|
+
# @param ctx [ExtensionContext] capability facade for the live agent
|
|
92
58
|
# @return [void]
|
|
93
59
|
def bind(ctx); end
|
|
94
60
|
|
|
95
|
-
# Optional per-turn hook fired
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
# never prefetched or recorded by the parent's memory.
|
|
112
|
-
#
|
|
113
|
-
# @param ctx [ExtensionContext] capability facade for the live
|
|
114
|
-
# agent whose turn this is — same instance +bind+ received
|
|
115
|
-
# @param content [String] the user message (initial or interloper) about
|
|
116
|
-
# to be sent to the model
|
|
117
|
-
# @return [String, nil] an optional block of text to be injected verbatim as
|
|
118
|
-
# a system-role message (after the user message), or +nil+ to inject nothing
|
|
61
|
+
# Optional per-turn hook fired after a user message is added to the
|
|
62
|
+
# chat. Default no-op returning +nil+; override and return a {String} to
|
|
63
|
+
# have it appended after the user turn as a +<system-reminder>+ reference
|
|
64
|
+
# block (see {Agent#append_reference_block} for the envelope and why it
|
|
65
|
+
# is not a +:system+ message).
|
|
66
|
+
#
|
|
67
|
+
# The Agent only ever *appends* the returned block at the tail — never
|
|
68
|
+
# rewrites or removes an earlier one, which would bust the provider
|
|
69
|
+
# prefix cache for everything after the edit. Stale blocks ride the
|
|
70
|
+
# existing context-window machinery, not a per-turn rewrite.
|
|
71
|
+
#
|
|
72
|
+
# @param ctx [ExtensionContext] the live agent whose turn this is (same
|
|
73
|
+
# instance +bind+ received)
|
|
74
|
+
# @param content [String] the user message (initial or interloper)
|
|
75
|
+
# @return [String, nil] text to inject verbatim as a system-role message
|
|
76
|
+
# after the user message, or +nil+ to inject nothing
|
|
119
77
|
def on_user_message(ctx, content); end
|
|
78
|
+
|
|
79
|
+
# Optional hook fired by {Agent#clear_conversation} (a "/clear").
|
|
80
|
+
# Override to drop *conversation-scoped* state; default no-op, so an
|
|
81
|
+
# extension holding only process/infrastructure state opts out by not
|
|
82
|
+
# defining it.
|
|
83
|
+
#
|
|
84
|
+
# That distinction is the point: reset state that only makes sense
|
|
85
|
+
# within one conversation (+Pikuri::Tasks::Extension+ clears its list,
|
|
86
|
+
# the workspace clears its read-record); leave infrastructure alone
|
|
87
|
+
# (MCP subprocesses, a docker-backed server, the memory recorder's
|
|
88
|
+
# queue — a clear is not a quit).
|
|
89
|
+
#
|
|
90
|
+
# Do *not* touch the system prompt here — that refreshes automatically
|
|
91
|
+
# via {#system_prompt_snippets}, which the clear re-pulls. This hook is
|
|
92
|
+
# only for imperative state the prompt machinery can't express. An
|
|
93
|
+
# extension owning a domain event should emit it here so UI listeners
|
|
94
|
+
# see the reset (Tasks fires +Tasks::ListChanged+ with an empty list),
|
|
95
|
+
# synchronously after the Agent's {Event::Reset}.
|
|
96
|
+
#
|
|
97
|
+
# @param ctx [ExtensionContext] the live agent being cleared (same
|
|
98
|
+
# instance +bind+ received)
|
|
99
|
+
# @return [void]
|
|
100
|
+
def on_conversation_reset(ctx); end
|
|
101
|
+
|
|
102
|
+
# This extension's system-prompt contributions, as text sections.
|
|
103
|
+
# Default none (+[]+); override to contribute one or more.
|
|
104
|
+
#
|
|
105
|
+
# Pull, not push: the {Agent} assembles its prompt by concatenating the
|
|
106
|
+
# base with what every extension returns here — at construction and
|
|
107
|
+
# again on every {Agent#clear_conversation}. So a section computed from
|
|
108
|
+
# live state (a resident memory persona, +MACHINE.md+ from disk) is
|
|
109
|
+
# *recomputed* on clear and stays current; a static section returns the
|
|
110
|
+
# same constant.
|
|
111
|
+
#
|
|
112
|
+
# No argument: read instance state populated in {#configure} (which runs
|
|
113
|
+
# first). Producing that state (starting servers, probing a model)
|
|
114
|
+
# belongs in +configure+; this only *reads* it, so it stays cheap enough
|
|
115
|
+
# for every clear (memoize an expensive one-shot at its source —
|
|
116
|
+
# +Pikuri::Os::SystemInfo#prompt_section+). Sections are joined by the
|
|
117
|
+
# Agent (blank/nil dropped), in registration order.
|
|
118
|
+
#
|
|
119
|
+
# @return [Array<String>] zero or more prompt sections; +[]+ for none
|
|
120
|
+
def system_prompt_snippets = []
|
|
121
|
+
|
|
122
|
+
# What this extension adds to the lethal-trifecta tree
|
|
123
|
+
# ({Pikuri::Trifecta}): legs the wired agent holds beyond its own tools,
|
|
124
|
+
# and child nodes for any sub-agents it introduces. Default none.
|
|
125
|
+
#
|
|
126
|
+
# Only two kinds of extension owe an answer, and both are things per-tool
|
|
127
|
+
# tagging structurally cannot see: one that introduces **sub-agents** (a
|
|
128
|
+
# whole node, plus the gate on the delegation edge) and one that mounts a
|
|
129
|
+
# **foreign tool surface** whose tools carry no legs of their own.
|
|
130
|
+
#
|
|
131
|
+
# +tools+ is the wired agent's tools plus its sub-agent tools, so an
|
|
132
|
+
# extension resolving a persona's +tool_names+ can select the same
|
|
133
|
+
# objects the sub-agent will actually receive — the resolution and the
|
|
134
|
+
# node it produces stay with whoever owns them.
|
|
135
|
+
#
|
|
136
|
+
# Unlike every other hook here, the Agent calls this **guarded by
|
|
137
|
+
# +respond_to?+**: this protocol permits a class that defines all hooks
|
|
138
|
+
# without including the module, and an unguarded new hook would break
|
|
139
|
+
# those. So a non-including extension may simply not have it.
|
|
140
|
+
#
|
|
141
|
+
# @param tools [Array<Pikuri::Tool>] the agent's tools + sub-agent tools
|
|
142
|
+
# @return [Pikuri::Trifecta::Contribution, nil] +nil+ for none
|
|
143
|
+
def trifecta_contribution(tools) = nil
|
|
120
144
|
end
|
|
121
145
|
end
|
|
122
146
|
end
|