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/subprocess.rb
CHANGED
|
@@ -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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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}.
|
|
18
|
-
# +Process.spawn+ / +Open3.*+ / +system+ / backticks
|
|
19
|
-
# +
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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}
|
|
28
|
-
#
|
|
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
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# +
|
|
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
|
|
55
|
-
# {Pikuri::Finalizers}
|
|
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
|
|
73
|
-
#
|
|
51
|
+
# Spawn +argv+ in a new process group with stderr on stdout. Tracked for
|
|
52
|
+
# cleanup.
|
|
74
53
|
#
|
|
75
|
-
# @param argv [Array<String>] command
|
|
76
|
-
#
|
|
77
|
-
# interpretation is wanted
|
|
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
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
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
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
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
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
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
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
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
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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
|
-
#
|
|
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,
|
|
186
|
-
#
|
|
187
|
-
#
|
|
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
|
-
#
|
|
201
|
-
# {
|
|
202
|
-
#
|
|
203
|
-
#
|
|
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
|
-
#
|
|
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}
|
|
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
|
|
265
|
-
# construction
|
|
266
|
-
#
|
|
267
|
-
#
|
|
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
|