pikuri-core 0.0.6 → 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 +6 -4
- data/lib/pikuri/agent/chat_transport.rb +128 -24
- data/lib/pikuri/agent/configurator.rb +47 -107
- data/lib/pikuri/agent/context_window_detector.rb +80 -70
- 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 +46 -30
- data/lib/pikuri/agent/control.rb +14 -34
- data/lib/pikuri/agent/event.rb +125 -163
- data/lib/pikuri/agent/extension.rb +121 -83
- data/lib/pikuri/agent/extension_context.rb +120 -0
- 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 +147 -110
- data/lib/pikuri/agent/listener/token_log.rb +116 -108
- data/lib/pikuri/agent/listener.rb +23 -36
- data/lib/pikuri/agent/listener_list.rb +26 -57
- data/lib/pikuri/agent/synthesizer.rb +76 -92
- data/lib/pikuri/agent.rb +939 -642
- 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 +157 -0
- 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 +70 -15
- data/lib/pikuri/tool/scraper.rb +55 -97
- data/lib/pikuri/tool/search/brave.rb +70 -84
- data/lib/pikuri/tool/search/duckduckgo.rb +65 -86
- data/lib/pikuri/tool/search/engines.rb +249 -93
- data/lib/pikuri/tool/search/exa.rb +75 -97
- 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 +121 -26
- 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 -86
- data/prompts/agent-loop.txt +5 -0
- data/prompts/pikuri-chat.txt +3 -12
- metadata +18 -8
|
@@ -2,36 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
class Agent
|
|
5
|
-
#
|
|
6
|
-
# {Control::StepLimit} trips, +Agent#run_loop+
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# whatever evidence the failed agent collected before running
|
|
11
|
-
# out of budget.
|
|
5
|
+
# Prompt builder for the step-exhaustion rescue. When an +Agent+'s
|
|
6
|
+
# {Control::StepLimit} trips with the +:synthesize+ policy, +Agent#run_loop+
|
|
7
|
+
# runs this prompt on a nested tools-free agent so the run still produces an
|
|
8
|
+
# answer from whatever evidence the failed agent gathered before running out
|
|
9
|
+
# of budget.
|
|
12
10
|
#
|
|
13
|
-
#
|
|
11
|
+
# The failure mode it salvages is the "wait, but what about X?" death-loop:
|
|
12
|
+
# the agent collects sound evidence in the first few rounds, then burns the
|
|
13
|
+
# rest of the budget second-guessing — by the cap, the answer is largely in
|
|
14
|
+
# the messages and just needs a tools-free pass to synthesize. Salvage is
|
|
15
|
+
# wrong for some agents (a coding agent's half-finished work can only be
|
|
16
|
+
# described, not completed), which is why the policy lives on
|
|
17
|
+
# {Control::StepLimit} and defaults to +:raise+.
|
|
14
18
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# but what about X?" death-loop: the agent collects sound
|
|
20
|
-
# evidence in the first few rounds, then spends the rest of
|
|
21
|
-
# the budget second-guessing. By the time the cap trips, the
|
|
22
|
-
# answer is largely in the messages — it just needs a
|
|
23
|
-
# tools-free pass to synthesize.
|
|
24
|
-
#
|
|
25
|
-
# == Seam discipline
|
|
26
|
-
#
|
|
27
|
-
# {Synthesizer.run} does not reference +RubyLLM::*+. +Agent+
|
|
28
|
-
# constructs the synth chat itself (the one +RubyLLM.chat+
|
|
29
|
-
# call lives in +lib/agent.rb+, same as the parent chat) and
|
|
30
|
-
# passes it in. +Synthesizer+ only calls instance methods on
|
|
31
|
-
# whatever +chat+ it receives — +#with_instructions+,
|
|
32
|
-
# +#ask+, +#messages+ — and uses {Agent.wire_chat} for the
|
|
33
|
-
# event-stream wiring so the synth chat emits events with
|
|
34
|
-
# the same shape as the main chat.
|
|
19
|
+
# Pure prompt construction — no chat handling or +RubyLLM.chat+ call.
|
|
20
|
+
# {.run_synthesizer} owns the execution (a regular tools-free +Agent+, the
|
|
21
|
+
# same shape sub-agents use). The only +RubyLLM::*+ surface read here is the
|
|
22
|
+
# +RubyLLM::Message+/+ToolCall+ value-type passthrough.
|
|
35
23
|
module Synthesizer
|
|
36
24
|
# The synthesizer's system prompt. Strict and short: use
|
|
37
25
|
# the evidence, don't apologize, admit gaps when present.
|
|
@@ -39,62 +27,8 @@ module Pikuri
|
|
|
39
27
|
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
28
|
PROMPT
|
|
41
29
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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
|
-
# Render the user's question plus an "Evidence gathered"
|
|
95
|
-
# section built from +parent_messages+ as a single prompt
|
|
96
|
-
# string. Pure function — no I/O, safe to test directly
|
|
97
|
-
# with fixture messages.
|
|
30
|
+
# Render the question plus an "Evidence gathered" section from
|
|
31
|
+
# +parent_messages+. Pure — no I/O.
|
|
98
32
|
#
|
|
99
33
|
# @param parent_messages [Array<RubyLLM::Message>]
|
|
100
34
|
# @param user_message [String]
|
|
@@ -104,14 +38,11 @@ module Pikuri
|
|
|
104
38
|
"Question: #{user_message}\n\nEvidence gathered:\n#{transcript}"
|
|
105
39
|
end
|
|
106
40
|
|
|
107
|
-
# Walk the parent's
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# nonexistent results. Non-empty assistant text content is
|
|
113
|
-
# preserved as a "Note:" line, since the parent may have
|
|
114
|
-
# summarized progress between tool calls.
|
|
41
|
+
# Walk the parent's history into a paired "Tool call:" / "Tool result:"
|
|
42
|
+
# log, in order. Tool calls with no matching +:tool+ message are dropped —
|
|
43
|
+
# the call that tripped the limit never executed, so citing its
|
|
44
|
+
# nonexistent result would mislead the synth. Non-empty assistant text
|
|
45
|
+
# becomes a "Note:" line.
|
|
115
46
|
#
|
|
116
47
|
# @param messages [Array<RubyLLM::Message>]
|
|
117
48
|
# @return [String]
|
|
@@ -140,6 +71,59 @@ module Pikuri
|
|
|
140
71
|
lines.join("\n").rstrip
|
|
141
72
|
end
|
|
142
73
|
private_class_method :format_evidence
|
|
74
|
+
|
|
75
|
+
# The +:synthesize+ arm of the step-exhaustion policy. Runs the
|
|
76
|
+
# {Synthesizer} prompt over the exhausted chat's history on a nested
|
|
77
|
+
# tools-free +Agent+ (the sub-agent construction shape, so it inherits
|
|
78
|
+
# listener propagation, transport/cap/streaming, and +close+ teardown).
|
|
79
|
+
#
|
|
80
|
+
# @param ctx [ExtensionContext]
|
|
81
|
+
# @param chat_messages [Array<RubyLLM::Message>] the exhausted chat's
|
|
82
|
+
# history, the evidence {.build_prompt} renders
|
|
83
|
+
# @param user_message [String] the user's original question
|
|
84
|
+
# @raise [Control::Cancellable::Cancelled] when a cancel landed between
|
|
85
|
+
# the budget tripping and this rescue — cancellation wins over salvage
|
|
86
|
+
# @return [String] the synth answer
|
|
87
|
+
def self.run_synthesizer(ctx, chat_messages, user_message)
|
|
88
|
+
# Check the cancel flag *before* constructing the synth: the nested
|
|
89
|
+
# run_loop resets the shared cancellable at its turn boundary, which
|
|
90
|
+
# would erase a cancel requested in this window. The raise propagates
|
|
91
|
+
# without a parent-side {Event::Cancelled} — a cancel *during* synthesis
|
|
92
|
+
# emits it from the synth's own rescue instead, so the stream sees at
|
|
93
|
+
# most one.
|
|
94
|
+
ctx.agent.cancellable&.check!
|
|
95
|
+
|
|
96
|
+
ctx.emit_event(Event::FallbackNotice.new(
|
|
97
|
+
reason: "agent exhausted #{ctx.agent.step_limit.max} steps; " \
|
|
98
|
+
'synthesizing answer from gathered evidence'
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
# Synth runs under this agent's identity with a distinct +_synthesizer+
|
|
102
|
+
# id suffix (same +_+ separator the sub-agent generator uses), so a
|
|
103
|
+
# +TokenLog+ tags its turns as the rescue, not the loop.
|
|
104
|
+
synth_id = ctx.agent.id.empty? ? 'synthesizer' : "#{ctx.agent.id}_synthesizer"
|
|
105
|
+
synth = Agent.new(
|
|
106
|
+
# Carry the parent's resolved cap on the transport so the synth reuses
|
|
107
|
+
# it without a re-probe (the cap rides {ChatTransport}).
|
|
108
|
+
transport: ctx.agent.transport.with(context_window: ctx.agent.context_window_cap),
|
|
109
|
+
system_prompt: Synthesizer::SYSTEM_PROMPT,
|
|
110
|
+
# Defensive :raise budget: the synth has no tools so should never
|
|
111
|
+
# tick, but a buggy provider returning a tool call must not loop — a
|
|
112
|
+
# synth that needs its own synth is a bug, not a rescue.
|
|
113
|
+
step_limit: Control::StepLimit.new(max: 1),
|
|
114
|
+
cancellable: ctx.agent.cancellable,
|
|
115
|
+
id: synth_id,
|
|
116
|
+
streaming: ctx.agent.streaming
|
|
117
|
+
) { |c| c.add_listeners(ctx.sub_agent_listeners(id: synth_id)) }
|
|
118
|
+
begin
|
|
119
|
+
synth.run_loop(user_message: Synthesizer.build_prompt(
|
|
120
|
+
parent_messages: chat_messages, user_message: user_message
|
|
121
|
+
))
|
|
122
|
+
synth.last_assistant_content
|
|
123
|
+
ensure
|
|
124
|
+
synth.close
|
|
125
|
+
end
|
|
126
|
+
end
|
|
143
127
|
end
|
|
144
128
|
end
|
|
145
129
|
end
|