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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  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 +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  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 +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  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 +45 -67
  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 +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  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 +100 -24
  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 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +14 -3
@@ -4,88 +4,63 @@ require 'open3'
4
4
  require 'set'
5
5
 
6
6
  module Pikuri
7
- # Chokepoint for *all* subprocess spawning in pikuri. Forces a new
8
- # process group for each invocation, tracks pgids so descendants of
9
- # the direct child (commands backgrounded with +&+) can be cleaned
10
- # up at process exit. Two front doors: {.spawn} (combined
11
- # stdout+stderr through a single pipe — the shell-command shape) and
12
- # {.run} (stdin fed from a String or streamed from an IO, stdout
13
- # redirected to a file, stderr captured — the filter shape).
7
+ # Chokepoint for *all* subprocess spawning in pikuri. Forces a new process
8
+ # group per invocation and tracks pgids so descendants (commands
9
+ # backgrounded with +&+) can be cleaned up at exit. Two front doors:
10
+ # {.spawn} (combined stdout+stderr through one pipe — the shell-command
11
+ # shape) and {.run} (stdin from a String/IO, stdout to a file, stderr
12
+ # captured — the filter shape).
14
13
  #
15
14
  # == Seam discipline
16
15
  #
17
- # All subprocess spawning in +lib/+ goes through {.spawn}. Direct
18
- # +Process.spawn+ / +Open3.*+ / +system+ / backticks anywhere in
19
- # +lib/+ are bugs. The convention is grep-enforceable:
20
- # +grep -rnE 'Process\.spawn|Open3\.|\bsystem\(' lib/+ should
21
- # only hit this file (plus the comment in
22
- # +pikuri-mcp/lib/pikuri/mcp/servers.rb+ explaining the MCP
23
- # exception).
16
+ # All subprocess spawning in +lib/+ goes through {.spawn}/{.run}; direct
17
+ # +Process.spawn+ / +Open3.*+ / +system+ / backticks are bugs.
18
+ # Grep-enforceable: +grep -rnE 'Process\.spawn|Open3\.|\bsystem\(' lib/+
19
+ # should hit only this file (plus the MCP-exception comment in
20
+ # +pikuri-mcp/lib/pikuri/mcp/servers.rb+).
24
21
  #
25
22
  # == Timeouts are the caller's job
26
23
  #
27
- # {.spawn} does not implement a timeout — Ruby's +Timeout.timeout+
28
- # cannot kill subprocesses cleanly. Callers that need a timeout
29
- # wrap their argv with coreutils' +timeout+ binary:
24
+ # {.spawn} implements no timeout — Ruby's +Timeout.timeout+ can't kill
25
+ # subprocesses cleanly. Callers wrap their argv with coreutils' +timeout+:
30
26
  #
31
27
  # Pikuri::Subprocess.spawn(
32
28
  # 'timeout', '--signal=TERM', '--kill-after=5s', '120s',
33
- # 'bash', '-c', command,
34
- # chdir: workspace.cwd.to_s
35
- # )
29
+ # 'bash', '-c', command, chdir: workspace.cwd.to_s)
36
30
  #
37
- # When +timeout+ and its FD-inheriting children die, the combined
38
- # output pipe closes and {#wait}'s +io.read+ returns. No Ruby-side
39
- # timeout machinery; the +timeout+ binary handles SIGTERM-then-
40
- # SIGKILL race-free.
31
+ # When +timeout+ and its FD-inheriting children die, the output pipe closes
32
+ # and {#wait}'s +io.read+ returns — +timeout+ handles the
33
+ # SIGTERM-then-SIGKILL race-free, no Ruby-side machinery.
41
34
  #
42
35
  # == Backgrounded subprocesses
43
36
  #
44
- # When a shell command backgrounds work with +&+, the resulting
45
- # process stays in our pgroup. {#wait} returns as soon as the
46
- # direct child exits, but {.active} keeps the pgid in the tracked
47
- # set as long as any process in the group is alive (probed with
48
- # +kill(0, -pgid)+). On pikuri exit, {.cleanup!} sends SIGTERM to
49
- # every tracked group. The model can opt out via +nohup cmd &+ or
50
- # +setsid cmd &+ — both detach from our group.
37
+ # A command that backgrounds work with +&+ leaves a process in our pgroup.
38
+ # {#wait} returns when the direct child exits, but {.active} keeps the pgid
39
+ # tracked while any group member is alive (+kill(0, -pgid)+); {.cleanup!}
40
+ # SIGTERMs every tracked group at exit. The model opts out with +nohup+ /
41
+ # +setsid+ (both detach from our group).
51
42
  #
52
43
  # == State is process-global
53
44
  #
54
- # One +@active+ Set for the whole process, swept once at exit via
55
- # {Pikuri::Finalizers} (see the registration at the bottom of this
56
- # file). A +Mutex+ guards register/prune/cleanup; v1 is
57
- # single-threaded, so this is more for the exit-sweep/register race
58
- # than for current callers.
59
- #
60
- # == Why +Pikuri::Subprocess+, not top-level
61
- #
62
- # First class actually under the +Pikuri::+ namespace. Domain
63
- # classes (+Tool+, +Agent+, +URLCache+) are top-level as a legacy
64
- # convention — they predate the namespacing decision and an
65
- # eventual refactor moves them too. For now: library-level
66
- # infrastructure under +Pikuri::+; domain objects flat. See
67
- # +CLAUDE.md+ for the convention.
45
+ # One +@active+ Set for the process, swept once at exit via
46
+ # {Pikuri::Finalizers}. A +Mutex+ guards register/prune/cleanup.
68
47
  class Subprocess
69
48
  # Combined output + exit status, returned from {#wait}.
70
49
  Result = Data.define(:output, :status)
71
50
 
72
- # Spawn +argv+ in a new process group, redirecting stderr onto
73
- # stdout. Tracked for cleanup.
51
+ # Spawn +argv+ in a new process group with stderr on stdout. Tracked for
52
+ # cleanup.
74
53
  #
75
- # @param argv [Array<String>] command and arguments. Caller does
76
- # any shell wrapping (e.g. +'bash', '-c', cmd+) when shell
77
- # interpretation is wanted; +argv+ is passed to +exec+
78
- # directly, so no implicit shell expansion happens here.
54
+ # @param argv [Array<String>] command + arguments, passed to +exec+
55
+ # directly — no implicit shell (caller wraps in +'bash', '-c', cmd+ when
56
+ # shell interpretation is wanted).
79
57
  # @param chdir [String, Pathname] working directory
80
- # @param env [Hash{String=>String}] extra environment variables to
81
- # set in the child process. The child otherwise inherits the
82
- # parent's full environment; entries in +env+ override or add to
83
- # it. Default +{}+ (pure inheritance). Used by {Code::Bash} to
84
- # thread {Pikuri::Workspace::Filesystem#env} (host git identity,
85
- # etc.) into a bash subprocess whose sandbox would otherwise
86
- # strip the host's config files.
87
- # @return [Subprocess] handle — call {#wait} to block for the
88
- # direct child to exit and read the captured output
58
+ # @param env [Hash{String=>(String,nil)}] extra env vars for the child; it
59
+ # otherwise inherits the parent's full environment (default +{}+, pure
60
+ # inheritance). A +nil+ value *unsets* that variable, which is how
61
+ # {Pikuri::BundlerEnv} hands back the environment pikuri's own boot
62
+ # rewrote.
63
+ # @return [Subprocess] handle — call {#wait} to block and read output.
89
64
  def self.spawn(*argv, chdir:, env: {})
90
65
  stdin, io, wait_thr = Open3.popen2e(env, *argv, chdir: chdir.to_s, pgroup: true)
91
66
  stdin.close
@@ -93,43 +68,32 @@ module Pikuri
93
68
  new(io: io, wait_thr: wait_thr)
94
69
  end
95
70
 
96
- # Run +argv+ as a one-shot *filter*: feed it +stdin_data+, redirect
97
- # its stdout straight to +stdout+ (an open +File+), capture stderr
98
- # through a pipe, and block until it exits. Built for the
99
- # stdin→markdown document converters (pikuri-extractors), where
100
- # {.spawn}'s shape is wrong twice over: it closes the child's stdin
101
- # immediately, and it merges stderr onto stdout — fatal when stdout
102
- # *is* the payload and a converter's warnings would corrupt it.
103
- #
104
- # Redirecting stdout to a file (not a pipe) is also what makes the
105
- # I/O deadlock-free with one writer thread: the child never blocks
106
- # writing output, so it keeps draining stdin, while the parent
107
- # drains the (low-volume) stderr pipe. The returned
108
- # {Result#output} is the captured *stderr* — the diagnostics — not
109
- # the payload; the payload is in +stdout+, whose file offset is
110
- # shared with the child, so rewind before reading it back.
71
+ # Run +argv+ as a one-shot *filter*: feed it +stdin_data+, redirect stdout
72
+ # to +stdout+ (an open +File+), capture stderr through a pipe, block until
73
+ # it exits. Built for the stdin→markdown converters (pikuri-extractors),
74
+ # where {.spawn}'s shape is wrong twice: it closes stdin immediately and
75
+ # merges stderr onto stdout — fatal when stdout *is* the payload.
111
76
  #
112
- # Same discipline as {.spawn}: new process group, registered for
113
- # the exit sweep, no built-in timeout (wrap +argv+ with coreutils'
114
- # +timeout+ — see the class docs).
77
+ # Redirecting stdout to a file (not a pipe) is what makes the I/O
78
+ # deadlock-free with one writer thread: the child never blocks on output,
79
+ # so it keeps draining stdin while the parent drains the low-volume stderr
80
+ # pipe. {Result#output} is the captured *stderr*, not the payload — the
81
+ # payload is in +stdout+, whose file offset is shared with the child, so
82
+ # rewind before reading it back. Same discipline as {.spawn} (new group,
83
+ # exit-sweep tracked, no built-in timeout).
115
84
  #
116
- # @param argv [Array<String>] command and arguments, passed to
117
- # +exec+ directly — no implicit shell.
118
- # @param stdin_data [String, IO, StringIO] the child's stdin: a
119
- # String is written as-is, an IO is streamed through
120
- # +IO.copy_stream+ (so a large source file never materialises in
121
- # the Ruby heap) and read from its current position. Either way
122
- # stdin is closed (EOF) afterwards. May be empty.
123
- # @param stdout [File] open writable file the child's stdout is
124
- # redirected to.
85
+ # @param argv [Array<String>] command + arguments; no implicit shell.
86
+ # @param stdin_data [String, IO, StringIO] the child's stdin: a String is
87
+ # written as-is, an IO is streamed via +IO.copy_stream+ from its current
88
+ # position (so a large file never hits the Ruby heap). Closed (EOF)
89
+ # after. May be empty.
90
+ # @param stdout [File] open writable file stdout is redirected to.
125
91
  # @param chdir [String, Pathname] working directory.
126
- # @param env [Hash{String=>String}] extra environment variables,
127
- # as for {.spawn}.
128
- # @return [Result] +output+ is the captured stderr; +status+ the
129
- # child's exit status.
130
- # @raise [SystemCallError] whatever an IO +stdin_data+ raises
131
- # mid-stream (disk error, closed handle) — re-raised here after
132
- # the child has been reaped.
92
+ # @param env [Hash{String=>String}] extra env vars, as for {.spawn}.
93
+ # @return [Result] +output+ is the captured stderr; +status+ the exit
94
+ # status.
95
+ # @raise [SystemCallError] whatever an IO +stdin_data+ raises mid-stream,
96
+ # re-raised after the child is reaped.
133
97
  def self.run(*argv, stdin_data:, stdout:, chdir:, env: {})
134
98
  in_r, in_w = IO.pipe
135
99
  err_r, err_w = IO.pipe
@@ -169,9 +133,8 @@ module Pikuri
169
133
  # child was spawned with +pgroup: true+ (it's the group leader).
170
134
  attr_reader :pgid
171
135
 
172
- # @return [IO] read end of the combined stdout+stderr pipe.
173
- # Exposed for future live-streaming consumers; v1 callers go
174
- # straight to {#wait}, which drains it.
136
+ # @return [IO] read end of the combined stdout+stderr pipe. Callers
137
+ # normally go straight to {#wait}, which drains it.
175
138
  attr_reader :io
176
139
 
177
140
  # @api private — call {.spawn}, not the constructor.
@@ -182,10 +145,9 @@ module Pikuri
182
145
  @pgid = wait_thr.pid # pgroup:true → pgid == pid
183
146
  end
184
147
 
185
- # Block until the direct child exits, read whatever remains on
186
- # the combined-output pipe, return a {Result}. The pgid stays
187
- # tracked if the group still has live members (backgrounded
188
- # children); pruned if everything's gone.
148
+ # Block until the direct child exits, drain the combined-output pipe,
149
+ # return a {Result}. The pgid stays tracked if the group still has live
150
+ # members (backgrounded children); pruned if everything's gone.
189
151
  #
190
152
  # @return [Result]
191
153
  def wait
@@ -196,13 +158,11 @@ module Pikuri
196
158
  self.class.send(:prune, @pgid)
197
159
  end
198
160
 
199
- # SIGTERM the whole process group without blocking for output —
200
- # the stop button for a *daemon* child (one the caller never
201
- # {#wait}s on, e.g. {Pikuri::Memory::Mem0Server}'s socat relay).
202
- # Best-effort and idempotent: an already-dead group is a no-op.
203
- # The group stays in the exit-sweep set until it actually dies,
204
- # so a child that ignores SIGTERM is still re-signalled by
205
- # {.cleanup!} at process exit.
161
+ # SIGTERM the whole process group without blocking for output — the stop
162
+ # button for a *daemon* child (one the caller never {#wait}s on, e.g.
163
+ # {Pikuri::Memory::Mem0Server}'s socat relay). Best-effort and idempotent;
164
+ # the group stays in the exit-sweep set until it dies, so a child ignoring
165
+ # SIGTERM is re-signalled by {.cleanup!} at exit.
206
166
  #
207
167
  # @return [void]
208
168
  def terminate
@@ -214,9 +174,8 @@ module Pikuri
214
174
  end
215
175
 
216
176
  class << self
217
- # Currently-tracked process groups, with dead ones pruned as a
218
- # side effect. Useful for a future +/bg+ REPL command or a
219
- # between-turn status line.
177
+ # Currently-tracked process groups, with dead ones pruned as a side
178
+ # effect.
220
179
  #
221
180
  # @return [Array<Integer>]
222
181
  def active
@@ -227,8 +186,7 @@ module Pikuri
227
186
  end
228
187
 
229
188
  # SIGTERM every tracked process group. Run at process exit via
230
- # {Pikuri::Finalizers} (production) and from +after+ blocks
231
- # (specs). Best-effort — ignores errors from already-dead groups.
189
+ # {Pikuri::Finalizers}, and from specs' +after+ blocks. Best-effort.
232
190
  #
233
191
  # @return [void]
234
192
  def cleanup!
@@ -261,10 +219,8 @@ module Pikuri
261
219
  end
262
220
  end
263
221
 
264
- # Registered at load (boot) — before any agent or server registers at
265
- # construction time — so in {Pikuri::Finalizers}' LIFO sweep this runs
266
- # LAST: graceful +#close+ on agents and servers first, then SIGTERM
267
- # whatever child groups are still alive. The reaper, not a peer. (Was a
268
- # standalone +at_exit+; routed through Finalizers so the order relative
269
- # to every other teardown is controlled, not left to file load order.)
222
+ # Registered at load (boot) — before any agent/server registers at
223
+ # construction — so in {Pikuri::Finalizers}' LIFO sweep this runs LAST:
224
+ # graceful +#close+ on agents and servers first, then SIGTERM whatever child
225
+ # groups remain. The reaper, not a peer.
270
226
  Pikuri::Finalizers.register { Pikuri::Subprocess.cleanup! }
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'faraday'
5
+
6
+ module Pikuri
7
+ # Test support: run the **real** {Pikuri::Agent} (real +run_loop+, real
8
+ # listeners, real tools/extensions, real +Event+ stream, real ruby_llm loop)
9
+ # against a **scripted HTTP backend** — no server, no network, no mock agent.
10
+ #
11
+ # Not loaded by default: +require 'pikuri/testing'+ from a spec. It lives off
12
+ # the production load path (the core Zeitwerk loader ignores it).
13
+ #
14
+ # == How it works
15
+ #
16
+ # {.fake_transport} returns an ordinary {Agent::ChatTransport} whose
17
+ # +faraday_adapter+ is an anonymous +Faraday::Adapter+ serving the responses
18
+ # you scripted. Because {Agent#build_context} installs that adapter on the
19
+ # agent's *per-chat* +RubyLLM::Context+ (never the global +RubyLLM.config+),
20
+ # ruby_llm's own loop runs for real — it parses tool calls, invokes your real
21
+ # registered tools, appends observations, and re-requests — and each scripted
22
+ # entry is the model's next turn. No hand-rolled agent loop, nothing to drift
23
+ # against a ruby_llm upgrade.
24
+ #
25
+ # require 'pikuri/testing'
26
+ #
27
+ # transport = Pikuri::Testing.fake_transport do |llm|
28
+ # llm.tool_call('calculator', expression: '2 + 2') # turn 1: call a real tool
29
+ # llm.reply('The answer is 4.') # turn 2: final answer
30
+ # end
31
+ #
32
+ # agent = Pikuri::Agent.new(transport:, system_prompt: 'test') do |c|
33
+ # c.add_listener capture
34
+ # c.add_tool Pikuri::Tool::CALCULATOR
35
+ # end
36
+ # agent.run_loop(user_message: 'What is 2 + 2?')
37
+ # # the real loop ran the real calculator; capture saw ToolCall/ToolResult/Assistant
38
+ #
39
+ # == Limitations
40
+ #
41
+ # A fake transport feeds **one ordered response list**. Propagation to
42
+ # sub-agents / the synthesizer works (they derive their transport via
43
+ # +parent.transport.with(...)+, and +Data#with+ preserves the adapter), but
44
+ # they *share* that one list — fine for a single linear script, not for
45
+ # targeting a specific sub-agent's replies. Per-sub-agent scripting is a
46
+ # future upgrade (dispatch by model id / distinct fake +api_base+).
47
+ # Tool-call *streaming* (fragmented +tool_calls+ deltas) is not modelled;
48
+ # {Script#stream} streams text only.
49
+ module Testing
50
+ # Build a fake transport from a scripted conversation. Pass a block that
51
+ # calls {Script} verbs in the order the model should "reply"; each verb
52
+ # queues one HTTP response.
53
+ #
54
+ # @example a two-turn tool loop
55
+ # Pikuri::Testing.fake_transport do |llm|
56
+ # llm.tool_call('calculator', expression: '2 + 2')
57
+ # llm.reply('The answer is 4.')
58
+ # end
59
+ #
60
+ # @param model [String] model id the fake reports (any string; the fake
61
+ # server "knows" it)
62
+ # @param context_window [Integer] baked onto the transport so
63
+ # {Agent#detect_and_emit_context_cap!} skips the real +/props+ HTTP probe
64
+ # @param api_base [String] the transport's base URL. Never dialed (the
65
+ # adapter answers), but a host may key display/config off it — pass the
66
+ # base of the connection this fake impersonates so a UI resolves its name.
67
+ # @param gate [Gate, nil] a latch to hold turns open mid-flight. When
68
+ # engaged ({Gate#block!}), the adapter parks before serving each response
69
+ # until {Gate#release!} — so a test can observe spinner / queued-injection
70
+ # / Ctrl+C paths through the real turn lifecycle. Default +nil+ (no gating).
71
+ # @yieldparam script [Script] the conversation builder
72
+ # @return [Agent::ChatTransport] carrying the scripted adapter; pass it as
73
+ # +Agent.new(transport:)+
74
+ def self.fake_transport(model: 'fake-model', context_window: 8192,
75
+ api_base: 'http://fake.invalid/v1', gate: nil)
76
+ script = Script.new
77
+ yield script if block_given?
78
+ Agent::ChatTransport.new(
79
+ model: model,
80
+ provider: :openai,
81
+ assume_model_exists: true,
82
+ # An api_base forces the connection-override path (Context#chat), the
83
+ # only branch that honours faraday_adapter; the host is never dialed.
84
+ api_base: api_base,
85
+ api_key: 'not-needed',
86
+ context_window: context_window,
87
+ faraday_adapter: build_adapter(script.responses, gate)
88
+ )
89
+ end
90
+
91
+ # An anonymous +Faraday::Adapter+ subclass that serves +responses+ in order,
92
+ # one per HTTP request. Closes over the (mutable) list, so the queue drains
93
+ # across the several requests of one conversation regardless of how Faraday
94
+ # reuses adapter instances.
95
+ #
96
+ # @param responses [Array<Hash>] entries shaped +{json: Hash}+ (non-stream)
97
+ # or +{sse: Array<String>}+ (stream), as {Script} produces
98
+ # @param gate [Gate, nil] awaited before each response when non-nil
99
+ # @return [Class] a +Faraday::Adapter+ subclass
100
+ def self.build_adapter(responses, gate = nil)
101
+ queue = responses # captured by reference; #call shifts from it
102
+ Class.new(Faraday::Adapter) do
103
+ # +define_method+ (not +def+) so the body closes over +queue+/+gate+;
104
+ # +super+ must be explicit here (implicit super is illegal in a
105
+ # define_method).
106
+ define_method(:call) do |env|
107
+ super(env) # base builds env.response; skipping it → "finish for nil"
108
+ gate&.await # park here while the test holds the turn open
109
+ entry = queue.shift or raise "Pikuri::Testing: no scripted response left for #{env.url}"
110
+
111
+ if env.request.stream_response? # ruby_llm set req.options.on_data
112
+ # v2 on_data routes to the chunk handler only at status 200; set it
113
+ # before feeding, mirroring how a real adapter reads the status line
114
+ # ahead of the body.
115
+ env.status = 200
116
+ on_data = env.request.on_data
117
+ size = 0
118
+ entry.fetch(:sse).each do |chunk|
119
+ size += chunk.bytesize
120
+ on_data.call(chunk, size, env)
121
+ end
122
+ save_response(env, 200, '', { 'Content-Type' => 'text/event-stream' }, nil)
123
+ else
124
+ save_response(env, 200, JSON.generate(entry.fetch(:json)),
125
+ { 'Content-Type' => 'application/json' }, nil)
126
+ end
127
+ @app.call(env)
128
+ end
129
+ end
130
+ end
131
+
132
+ # Collects the scripted conversation: each verb appends one OpenAI-wire
133
+ # response body to {#responses}, in call order. You don't build these
134
+ # directly — {Testing.fake_transport} yields one to your block.
135
+ #
136
+ # fake_transport do |llm|
137
+ # llm.reply('hi') # plain answer
138
+ # llm.tool_call('calculator', expression: '2+2') # a tool turn
139
+ # llm.stream('The ', 'answer ', 'is ', '4.') # a streamed answer
140
+ # end
141
+ class Script
142
+ # @return [Array<Hash>] queued response entries in call order
143
+ attr_reader :responses
144
+
145
+ def initialize
146
+ @responses = []
147
+ @tool_call_seq = 0
148
+ end
149
+
150
+ # Queue a plain assistant-text turn (the model's final answer for that
151
+ # step).
152
+ #
153
+ # @param text [String] the assistant message content
154
+ # @return [self]
155
+ def reply(text)
156
+ @responses << { json: assistant_body(content: text) }
157
+ self
158
+ end
159
+
160
+ # Queue an assistant turn that calls one tool. The real ruby_llm loop
161
+ # parses it, runs the real registered tool, appends the observation, and
162
+ # re-requests — so the *next* queued entry is the model's follow-up.
163
+ #
164
+ # @example
165
+ # llm.tool_call('calculator', expression: '2 + 2')
166
+ #
167
+ # @param name [String] registered tool name (e.g. +"calculator"+)
168
+ # @param arguments [Hash{Symbol => Object}] tool arguments, JSON-encoded
169
+ # into the wire call
170
+ # @return [self]
171
+ def tool_call(name, **arguments)
172
+ tool_batch([name, arguments])
173
+ end
174
+
175
+ # Queue an assistant turn that calls several tools at once — one
176
+ # +tool_calls+ array, the shape a provider sends when the model batches.
177
+ # ruby_llm runs them in order, each answered by its own +:tool+ message.
178
+ #
179
+ # llm.tool_batch(['calculator', { expression: '1 + 1' }],
180
+ # ['calculator', { expression: '2 + 2' }])
181
+ #
182
+ # What a spec needs to exercise anything treating a batch as a unit — the
183
+ # interloper's drain boundary, or which call a mid-batch unwind blames.
184
+ #
185
+ # @param calls [Array<Array(String, Hash)>] +[name, arguments]+ pairs, in
186
+ # the order the model asked for them
187
+ # @return [self]
188
+ def tool_batch(*calls)
189
+ wire = calls.map do |name, arguments|
190
+ @tool_call_seq += 1
191
+ { 'id' => "call_#{@tool_call_seq}", 'type' => 'function',
192
+ 'function' => { 'name' => name, 'arguments' => JSON.generate(arguments || {}) } }
193
+ end
194
+ @responses << { json: assistant_body(content: nil, tool_calls: wire) }
195
+ self
196
+ end
197
+
198
+ # Queue a *streamed* assistant-text turn: each +delta+ becomes one SSE
199
+ # chunk, so a +streaming: true+ agent emits a matching sequence of
200
+ # {Agent::Event::AssistantDelta}s and a final assembled
201
+ # {Agent::Event::Assistant}. Text only — tool-call streaming is not
202
+ # modelled.
203
+ #
204
+ # @example
205
+ # llm.stream('The ', 'answer ', 'is ', '4.')
206
+ #
207
+ # @param deltas [Array<String>] content fragments, streamed in order
208
+ # @return [self]
209
+ def stream(*deltas)
210
+ chunks = deltas.map do |d|
211
+ "data: #{JSON.generate('model' => 'fake', 'choices' => [{ 'delta' => { 'content' => d } }])}\n\n"
212
+ end
213
+ chunks << "data: #{JSON.generate('model' => 'fake', 'choices' => [{ 'delta' => {} }],
214
+ 'usage' => usage)}\n\n"
215
+ chunks << "data: [DONE]\n\n"
216
+ @responses << { sse: chunks }
217
+ self
218
+ end
219
+
220
+ private
221
+
222
+ # One OpenAI chat-completion body: an assistant message, optionally with
223
+ # +tool_calls+.
224
+ #
225
+ # @param content [String, nil]
226
+ # @param tool_calls [Array<Hash>, nil]
227
+ # @return [Hash]
228
+ def assistant_body(content:, tool_calls: nil)
229
+ message = { 'role' => 'assistant', 'content' => content }
230
+ message['tool_calls'] = tool_calls if tool_calls
231
+ { 'model' => 'fake', 'choices' => [{ 'message' => message }], 'usage' => usage }
232
+ end
233
+
234
+ # Nominal token counts so a {Agent::Event::Tokens} still fires.
235
+ #
236
+ # @return [Hash{String => Integer}]
237
+ def usage
238
+ { 'prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15 }
239
+ end
240
+ end
241
+
242
+ # A latch that holds a fake turn open mid-flight. Pass one to
243
+ # {Testing.fake_transport}; the adapter parks (inside the HTTP call, on the
244
+ # agent's worker thread) at every response while the gate is engaged, so a
245
+ # test can observe the turn-in-progress state — spinner, queued injections,
246
+ # a Ctrl+C landing mid-turn — then let it finish.
247
+ #
248
+ # gate = Pikuri::Testing::Gate.new
249
+ # transport = Pikuri::Testing.fake_transport(gate:) { |llm| llm.stream('done') }
250
+ # gate.block!
251
+ # session.submit('work') # the real turn parks in the adapter
252
+ # # ... assert spinner / press Ctrl+C ...
253
+ # gate.release! # the turn proceeds and completes
254
+ #
255
+ # A test that engages the gate is responsible for releasing it —
256
+ # {#await} blocks with no timeout, mirroring the old stubs' +Queue#pop+.
257
+ #
258
+ # Thread-safe.
259
+ class Gate
260
+ def initialize
261
+ @mutex = Mutex.new
262
+ @cv = ConditionVariable.new
263
+ @blocked = false
264
+ end
265
+
266
+ # Engage the latch: the *next* (and every subsequent) response parks in
267
+ # {#await} until {#release!}.
268
+ #
269
+ # @return [void]
270
+ def block!
271
+ @mutex.synchronize { @blocked = true }
272
+ nil
273
+ end
274
+
275
+ # Disengage the latch and wake every parked response.
276
+ #
277
+ # @return [void]
278
+ def release!
279
+ @mutex.synchronize do
280
+ @blocked = false
281
+ @cv.broadcast
282
+ end
283
+ nil
284
+ end
285
+
286
+ # Block the calling thread while the latch is engaged. Called by the
287
+ # adapter before serving each response; a no-op when not blocked.
288
+ #
289
+ # @return [void]
290
+ def await
291
+ @mutex.synchronize { @cv.wait(@mutex) while @blocked }
292
+ nil
293
+ end
294
+ end
295
+ end
296
+ end