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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -4
  3. data/lib/pikuri/agent/chat_transport.rb +128 -24
  4. data/lib/pikuri/agent/configurator.rb +47 -107
  5. data/lib/pikuri/agent/context_window_detector.rb +80 -70
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +46 -30
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +125 -163
  11. data/lib/pikuri/agent/extension.rb +121 -83
  12. data/lib/pikuri/agent/extension_context.rb +120 -0
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +147 -110
  16. data/lib/pikuri/agent/listener/token_log.rb +116 -108
  17. data/lib/pikuri/agent/listener.rb +23 -36
  18. data/lib/pikuri/agent/listener_list.rb +26 -57
  19. data/lib/pikuri/agent/synthesizer.rb +76 -92
  20. data/lib/pikuri/agent.rb +939 -642
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +157 -0
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +70 -15
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +70 -84
  38. data/lib/pikuri/tool/search/duckduckgo.rb +65 -86
  39. data/lib/pikuri/tool/search/engines.rb +249 -93
  40. data/lib/pikuri/tool/search/exa.rb +75 -97
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +121 -26
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -86
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +18 -8
@@ -2,36 +2,24 @@
2
2
 
3
3
  module Pikuri
4
4
  class Agent
5
- # Step-exhaustion rescue. When an +Agent+'s
6
- # {Control::StepLimit} trips, +Agent#run_loop+ catches the
7
- # +Exceeded+ exception and hands off to {Synthesizer.run} so
8
- # the run still produces something useful — a tools-free
9
- # assistant turn that answers the user's question from
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
- # == Why this exists
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
- # Without a rescue, a step-exhausted run just raises a stack
16
- # trace past +bin/pikuri-chat+ and the user gets nothing
17
- # despite the agent having gathered useful information in the
18
- # first N-1 steps. The observed failure mode is the "wait,
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
- # 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
- # 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 message history and produce a paired
108
- # "Tool call:" / "Tool result:" log, preserving order. Tool
109
- # calls that have no matching +:tool+ message are dropped —
110
- # the call that tripped the step limit never executed, so
111
- # including it would mislead the synth into citing
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