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
@@ -3,161 +3,183 @@
3
3
  module Pikuri
4
4
  class Agent
5
5
  module Control
6
- # Mid-loop user-input queue. A host (TUI, web client)
7
- # constructs an +Interloper+, hands it to {Agent#initialize}
8
- # via the +interloper:+ kwarg, and calls
9
- # {#inject_user_message} from any thread while the agent
10
- # is running. The +Agent+ drains the queue at the next
11
- # +after_tool_result+ boundary — the only point inside
12
- # ruby_llm's loop where the conversation state is
13
- # consistent — and emits each item into the chat history
14
- # plus the listener stream. The agent's next round-trip
15
- # then sees the injected user message and reacts to it on
16
- # its own.
6
+ # Mid-loop host→agent injection queue. A host (TUI, web client)
7
+ # constructs an +Interloper+, passes it to {Agent#initialize} via
8
+ # +interloper:+, and enqueues from any thread while the agent runs. The
9
+ # +Agent+ drains it once the running tool batch is answered — the only
10
+ # point in ruby_llm's loop where conversation state is consistent — and
11
+ # the next round-trip reacts to what was injected.
17
12
  #
18
- # +Interloper+ is groundwork for downstream TUI/web hosts;
19
- # the bundled +bin/pikuri-*+ entry-point scripts do *not*
20
- # wire one up, since they keep stdin synchronous and have
21
- # no way for a user to type while a turn is in flight.
22
- # Downstream hosts that *do* run the agent on a worker
23
- # thread can wire one in with no other changes to pikuri.
13
+ # == Two kinds
24
14
  #
25
- # == Delivery boundary and side effects
15
+ # Each item carries a {Item#kind}, driving how {Agent#drain_interloper}
16
+ # lands it:
26
17
  #
27
- # When the +Agent+'s +after_tool_result+ wiring fires, it
28
- # calls {#drain!} on the interloper (if any) and, for each
29
- # returned item:
18
+ # - +:user+ ({#inject_user_message}) — a mid-loop user turn: appended
19
+ # verbatim, emits +Event::UserTurn(mid_loop: true)+, and runs the
20
+ # {Extension#on_user_message} dispatch (prefetch/record like an initial
21
+ # turn).
22
+ # - +:system+ ({#inject_system_message}) — host-supplied reference text
23
+ # (a loaded skill, retrieved context): appended wrapped in
24
+ # +<system-reminder>+, emits +Event::SystemInjected+, and runs *no*
25
+ # +on_user_message+ dispatch and no +UserTurn+ (it's not a user turn).
30
26
  #
31
- # 1. Appends a +role: :user+ message to the chat history so
32
- # the next +complete+ round-trip's request includes it.
33
- # 2. Emits +Event::UserTurn(content:, mid_loop: true)+
34
- # through the listener stream so other listeners
35
- # (Terminal renderer, in-memory recorder, future logging)
36
- # see the injection as a normal +UserTurn+ event with the
37
- # +mid_loop:+ flag set.
27
+ # Both reach the wire as +role: :user+ — the kinds differ in dispatch and
28
+ # envelope, not in role. +:system+ names the *role the text plays*, and
29
+ # {Agent#append_reference_block} says why the wire role can't match it.
38
30
  #
39
- # Controls do not respond to events; the +Agent+ pokes
40
- # {Control::StepLimit#reset!} and {Control::Cancellable#reset!}
41
- # only at the start of each turn — never on a mid-loop
42
- # injection — so the "cancel-then-inject" hazard and the
43
- # "refresh-budget-by-injecting" hazard cannot arise.
31
+ # Groundwork for downstream TUI/web hosts: the bundled +bin/pikuri-*+ do
32
+ # not wire one (stdin is synchronous). A host running the agent on a
33
+ # worker thread wires one in with no other changes.
44
34
  #
45
- # == Boundary caveats
35
+ # == Delivery boundary
46
36
  #
47
- # The delivery point is +after_tool_result+, *not* the LLM
48
- # HTTP call. Injections placed while the model is
49
- # mid-response take effect on the *next* round-trip — by
50
- # the time the queue drains, the model has already
51
- # committed to whichever tool calls were in that response.
52
- # The agent therefore typically observes an injection at
53
- # the tool-batch boundary *after* the one during which the
54
- # host called {#inject_user_message}. This is the same
55
- # "gentle" semantic that {Control::Cancellable} promises
56
- # and is the cleanest cross-provider point: no in-flight
57
- # subprocess, no half-applied write, no half-built response.
37
+ # Delivery is at the end of a tool batch, *not* the LLM HTTP call: an
38
+ # injection made mid-response takes effect on the *next* round-trip, once
39
+ # the model has committed to the current response's tool calls. Never
40
+ # *inside* a batch — an assistant message carrying tool_calls and the
41
+ # +:tool+ messages answering it are one unit on the wire, and a message
42
+ # appended between them is rejected or mangled (see
43
+ # {Agent#tool_batch_complete?}). Same
44
+ # "gentle" semantic {Control::Cancellable} promises — the cleanest
45
+ # cross-provider point (no in-flight subprocess, no half-built response).
46
+ # Controls are reset only at turn start, never on a mid-loop injection,
47
+ # so "cancel-then-inject" and "refresh-budget-by-injecting" can't arise.
58
48
  #
59
- # == Thread safety
49
+ # == Change notification
60
50
  #
61
- # {#inject_user_message}, {#peek}, {#pending?}, and
62
- # {#drain!} are safe to call from any thread; the internal
63
- # queue is a +Mutex+-guarded +Array+. The +Agent+'s drain
64
- # runs on the run thread (whatever thread invoked
65
- # +Chat#ask+). +Mutex+ was chosen over +Thread::Queue+
66
- # because +Thread::Queue+ exposes no snapshot read, and
67
- # {#peek} is part of the surface (the host wants to render
68
- # "feedback received, will deliver shortly" in its UI
69
- # before the agent actually consumes the injection).
51
+ # A host rendering the pending count reactively (a "3 waiting" badge)
52
+ # sets {#on_change} instead of polling {#size}. It fires whenever the
53
+ # count changes — once per {#inject_user_message}, once per {#drain!}
54
+ # that empties a non-empty queue (an empty-queue drain, the hot path,
55
+ # stays silent) — with the new count, outside the lock. A host-side hook,
56
+ # not an {Agent} listener event: the count changes on the injecting
57
+ # thread, off the agent-thread-confined stream. Mirrors
58
+ # {Pikuri::Tasks::List#on_change} but pushes the count (the interloper
59
+ # exposes no race-free snapshot to re-read).
70
60
  #
71
61
  # == Sub-agent semantics
72
62
  #
73
- # Sub-agents are private to the parent agent; the host has
74
- # no handle to them, so a child +Interloper+ would be
75
- # unreachable. The +agent+ tool from +pikuri-subagents+
76
- # therefore omits the kwarg when spawning a child, leaving
77
- # the sub-agent's +interloper+ at its default +nil+. The
78
- # behavior contrasts with {Control::Cancellable}, which is
79
- # shared by reference so the parent's signal propagates to
80
- # children — cancellation is a global "stop the whole tree"
81
- # event, whereas injection is a directed "talk to the main
82
- # agent" event.
63
+ # Sub-agents are private to the parent; the host has no handle to them,
64
+ # so the +agent+ tool omits the kwarg, leaving a child's +interloper+
65
+ # +nil+. Contrast {Control::Cancellable}, shared by reference so the
66
+ # parent's stop propagates to the whole tree — cancellation is global,
67
+ # injection is directed at the main agent.
68
+ #
69
+ # Thread-safe: {#inject_user_message}, {#inject_system_message}, {#peek},
70
+ # {#pending?}, {#size}, and {#drain!} are a +Mutex+-guarded +Array+
71
+ # (chosen over +Thread::Queue+, which exposes no snapshot for {#peek}).
72
+ # {#on_change} fires outside the lock so a re-entrant or blocking callback
73
+ # can't deadlock.
83
74
  class Interloper
75
+ # A queued injection: +kind+ (+:user+ or +:system+) selects the drain
76
+ # path; +content+ is the message text. Producers are the two
77
+ # +inject_*+ methods; the consumer is {Agent#drain_interloper}.
78
+ Item = Data.define(:kind, :content)
79
+
84
80
  def initialize
85
81
  @mutex = Mutex.new
86
82
  @items = []
83
+ @on_change = nil
87
84
  end
88
85
 
89
- # Push +content+ onto the delivery queue. Safe from any
90
- # thread; the queue is +Mutex+-guarded.
86
+ # Optional host callback invoked with the new pending count whenever it
87
+ # changes (after {#inject_user_message}, and after a {#drain!} that
88
+ # empties a non-empty queue). +nil+ (default) disables it. Runs
89
+ # *outside* the queue lock, on the mutating thread, so a callback
90
+ # feeding another thread should hand off only immutable data.
91
+ #
92
+ # @return [Proc, nil]
93
+ attr_accessor :on_change
94
+
95
+ # Enqueue a +:user+ mid-loop turn (any thread; +Mutex+-guarded). Fires
96
+ # {#on_change} with the new count, outside the lock.
91
97
  #
92
98
  # @param content [String] non-blank user-supplied text
93
- # @raise [ArgumentError] if +content+ is +nil+, empty, or
94
- # whitespace-only — same rule as {Agent#run_loop}'s
95
- # +user_message:+ argument, since an empty injection
96
- # would poison the chat history just as a blank turn
97
- # would
99
+ # @raise [ArgumentError] if +content+ is +nil+/blank — a blank
100
+ # injection would poison the chat history like a blank turn
98
101
  # @return [void]
99
102
  def inject_user_message(content)
100
- raise ArgumentError, "content must not be blank, got #{content.inspect}" \
101
- if content.nil? || content.to_s.strip.empty?
103
+ enqueue(:user, content)
104
+ end
102
105
 
103
- @mutex.synchronize { @items << content }
104
- nil
106
+ # Enqueue a +:system+ reference block — host-loaded skill text or
107
+ # other retrieved context injected on the host's own initiative, not a
108
+ # user turn (any thread; +Mutex+-guarded). Fires {#on_change}.
109
+ #
110
+ # @param content [String] non-blank reference text (e.g. a rendered
111
+ # skill block); an own semantic tag is kept, the +<system-reminder>+
112
+ # envelope nests around it
113
+ # @raise [ArgumentError] if +content+ is +nil+/blank
114
+ # @return [void]
115
+ def inject_system_message(content)
116
+ enqueue(:system, content)
105
117
  end
106
118
 
107
- # Non-destructive snapshot of the queue, in delivery
108
- # order. Intended for hosts that want to render an
109
- # "ongoing / pending" UI affordance ("3 messages waiting
110
- # to deliver") in parallel with the agent's progress
111
- # stream. Safe to call from any thread.
119
+ # Non-destructive snapshot of the queue in delivery order, for a host
120
+ # rendering a "pending" affordance. Safe from any thread.
112
121
  #
113
- # @return [Array<String>] copy of the pending items;
114
- # never shares state with the internal buffer
122
+ # @return [Array<Item>] copy of the pending items; never shares state
123
+ # with the internal buffer
115
124
  def peek
116
125
  @mutex.synchronize { @items.dup }
117
126
  end
118
127
 
119
- # @return [Boolean] whether the queue currently holds at
120
- # least one pending injection; observable from any
121
- # thread
128
+ # @return [Boolean] whether the queue holds a pending injection;
129
+ # observable from any thread
122
130
  def pending?
123
131
  @mutex.synchronize { !@items.empty? }
124
132
  end
125
133
 
126
- # @return [Integer] number of pending injections; like
127
- # {#pending?} and {#peek}, a snapshot observable from
128
- # any thread — by the time the caller reads it the
129
- # queue may already have drained
134
+ # @return [Integer] number of pending injections; a snapshot from any
135
+ # thread (may have drained by the time the caller reads it)
130
136
  def size
131
137
  @mutex.synchronize { @items.size }
132
138
  end
133
139
 
134
- # Atomically take and remove all pending items. Called by
135
- # {Agent}'s +after_tool_result+ wiring; the +Agent+ then
136
- # appends each item to the chat history and emits an
137
- # {Event::UserTurn} with +mid_loop: true+ for each.
140
+ # Atomically take and remove all pending items. Called by {Agent}'s
141
+ # +after_message+ wiring, once a tool batch is complete. Returns +[]+ when empty (the hot path) —
142
+ # an empty drain does *not* fire {#on_change}; a drain that empties a
143
+ # non-empty queue fires it with +0+, outside the lock.
138
144
  #
139
- # Returns +[]+ when the queue is empty (the hot path —
140
- # every +after_tool_result+ calls this).
141
- #
142
- # @return [Array<String>] items in delivery order; empty
143
- # when the queue is empty
145
+ # @return [Array<Item>] items in delivery order; empty when the queue
146
+ # is empty
144
147
  def drain!
145
- @mutex.synchronize do
148
+ items = @mutex.synchronize do
146
149
  next [] if @items.empty?
147
150
 
148
- items = @items.dup
151
+ taken = @items.dup
149
152
  @items.clear
150
- items
153
+ taken
151
154
  end
155
+ @on_change&.call(0) unless items.empty?
156
+ items
152
157
  end
153
158
 
154
- # @return [String] short label for {Agent#to_s}; reflects
155
- # the pending-count so a debug print or banner can tell
156
- # an idle interloper apart from one with queued items
159
+ # @return [String] short label for {Agent#to_s}, reflecting the
160
+ # pending count.
157
161
  def to_s
158
162
  pending = size
159
163
  pending.zero? ? 'Interloper' : "Interloper(#{pending} pending)"
160
164
  end
165
+
166
+ private
167
+
168
+ # Shared enqueue for both +inject_*+ methods: validate, push a typed
169
+ # {Item}, fire {#on_change} outside the lock.
170
+ #
171
+ # @param kind [Symbol] +:user+ or +:system+
172
+ # @param content [String]
173
+ # @raise [ArgumentError] if +content+ is +nil+/blank
174
+ # @return [void]
175
+ def enqueue(kind, content)
176
+ raise ArgumentError, "content must not be blank, got #{content.inspect}" \
177
+ if content.nil? || content.to_s.strip.empty?
178
+
179
+ count = @mutex.synchronize { @items << Item.new(kind: kind, content: content); @items.size }
180
+ @on_change&.call(count)
181
+ nil
182
+ end
161
183
  end
162
184
  end
163
185
  end
@@ -3,35 +3,27 @@
3
3
  module Pikuri
4
4
  class Agent
5
5
  module Control
6
- # Caps the number of tool calls per {Agent#run_loop}
7
- # invocation. ruby_llm has no built-in step budget; the
8
- # +Agent+ pokes {#tick!} on every +before_tool_call+
9
- # callback and {#reset!} at the start of each turn. Once the
10
- # counter exceeds the configured cap, {#tick!} raises
11
- # {Exceeded} and the +Agent+ applies the {#on_exhausted}
12
- # policy: re-raise to the host (the default), or run the
13
- # step-exhaustion synthesizer to salvage a partial answer.
6
+ # Caps the number of tool calls per {Agent#run_loop}. ruby_llm has no
7
+ # step budget; the +Agent+ pokes {#tick!} on every +before_tool_call+ and
8
+ # {#reset!} at turn start. Once the count exceeds the cap, {#tick!} raises
9
+ # {Exceeded} and the +Agent+ applies {#on_exhausted}: re-raise to the host
10
+ # (default), or run the synthesizer to salvage a partial answer.
14
11
  #
15
12
  # == Why the policy lives here, not on +Agent+
16
13
  #
17
- # Synthesis can only ever fire off a tripped step limit, so
18
- # an +Agent.new(synthesize: ...)+ kwarg would be meaningless
19
- # whenever +step_limit:+ is +nil+ — an invalid combination
20
- # the API would have to document away. Attaching the policy
21
- # to the budget makes "what happens when the budget runs
22
- # out" travel with the budget, and the nonsense state is
23
- # unrepresentable. The host picks per wiring: a Q&A REPL
24
- # wants +:synthesize+ (salvage an answer from the evidence
25
- # gathered so far); a coding agent wants the default
26
- # +:raise+ (a tools-free pass can't finish writing code —
27
- # stop, let the user say "continue"; {#reset!} at the next
28
- # turn boundary refreshes the budget).
14
+ # Synthesis can only fire off a tripped step limit, so an
15
+ # +Agent.new(synthesize: ...)+ kwarg would be meaningless when
16
+ # +step_limit:+ is +nil+ — an invalid combination. Attaching the policy to
17
+ # the budget makes "what happens when it runs out" travel with the budget,
18
+ # and that nonsense state unrepresentable. Hosts pick per wiring: a Q&A
19
+ # REPL wants +:synthesize+ (salvage from evidence so far); a coding agent
20
+ # wants +:raise+ (a tools-free pass can't finish code — stop, let the user
21
+ # say "continue"; {#reset!} refreshes the budget next turn).
29
22
  class StepLimit
30
23
  # Valid {#on_exhausted} policies.
31
24
  ON_EXHAUSTED = %i[raise synthesize].freeze
32
- # Raised by {#tick!} once tool-call count exceeds +max+.
33
- # Carries the budget that was tripped so rescue clauses
34
- # can include it in user-facing messages.
25
+ # Raised by {#tick!} once the tool-call count exceeds +max+; carries the
26
+ # tripped budget for user-facing messages.
35
27
  class Exceeded < StandardError
36
28
  # @return [Integer]
37
29
  attr_reader :max_steps
@@ -68,39 +60,31 @@ module Pikuri
68
60
  @step = 0
69
61
  end
70
62
 
71
- # Increment the tool-call counter; raise {Exceeded} once
72
- # it crosses {#max}. Called by {Agent} from its
73
- # +before_tool_call+ wiring.
63
+ # Increment the counter; raise {Exceeded} once it crosses {#max}. Called
64
+ # from {Agent}'s +before_tool_call+ wiring.
74
65
  #
75
66
  # @return [void]
76
- # @raise [Exceeded] when the counter has now exceeded
77
- # {#max}
67
+ # @raise [Exceeded] when the counter now exceeds {#max}
78
68
  def tick!
79
69
  @step += 1
80
70
  raise Exceeded, @max if @step > @max
81
71
  end
82
72
 
83
- # Reset the counter back to zero. Called by {Agent} at the
84
- # start of each turn (in {Agent#run_loop} before forwarding
85
- # the user message to the chat) so the same instance can
86
- # govern many turns across a long-running REPL. Mid-loop
87
- # {Control::Interloper} injections deliberately do *not*
88
- # trigger a reset — those are additional context for the
89
- # same turn, not a fresh one, and a chatty user could
90
- # otherwise refresh the budget forever by injecting.
73
+ # Reset the counter to zero. Called by {Agent} at each turn start so one
74
+ # instance governs many turns. Mid-loop {Control::Interloper} injections
75
+ # deliberately do *not* reset — they're context for the same turn, and a
76
+ # chatty user could otherwise refresh the budget forever by injecting.
91
77
  #
92
78
  # @return [void]
93
79
  def reset!
94
80
  @step = 0
95
81
  end
96
82
 
97
- # @return [Integer] current step count; exposed so callers
98
- # can introspect it (and so tests can assert it)
83
+ # @return [Integer] current step count.
99
84
  attr_reader :step
100
85
 
101
- # @return [String] short config dump for {Agent#to_s}.
102
- # The policy only renders when it's the non-default
103
- # +:synthesize+, so existing banner output is unchanged.
86
+ # @return [String] short config dump for {Agent#to_s}; the policy
87
+ # renders only when the non-default +:synthesize+.
104
88
  def to_s
105
89
  policy = @on_exhausted == :raise ? '' : ", on_exhausted=#{@on_exhausted}"
106
90
  "StepLimit(max=#{@max}#{policy})"
@@ -2,43 +2,23 @@
2
2
 
3
3
  module Pikuri
4
4
  class Agent
5
- # Namespace for the +Agent+'s host-facing controls:
6
- # {StepLimit}, {Cancellable}, and {Interloper}. Each is a small
7
- # value-holder that the +Agent+ reads from (or pokes into the
8
- # event stream from) at well-defined points in ruby_llm's
9
- # chat-callback cycle. They are *not* listeners — they receive
10
- # no events and never appear in a {ListenerList}.
5
+ # Namespace for the +Agent+'s host-facing controls: {StepLimit},
6
+ # {Cancellable}, and {Interloper}. Each is a small value-holder the +Agent+
7
+ # reads from (or pokes into the event stream from) at fixed points in
8
+ # ruby_llm's callback cycle. They are *not* listeners — they receive no
9
+ # events and never appear in a {ListenerList}.
11
10
  #
12
- # == Why they're separated
13
- #
14
- # Listeners are pure consumers of the event stream; controls
15
- # are host-facing signal holders that the +Agent+ reads from.
16
- # The +Agent+ is the only entity that emits events, the only
17
- # entity that ticks the step counter, the only entity that
18
- # checks the cancellation flag, and the only entity that drains
19
- # the interloper queue. "What fires when" is a single grep for
11
+ # The split: listeners are pure consumers of the event stream; controls are
12
+ # host-facing signal holders the +Agent+ reads. The +Agent+ is the only
13
+ # entity that emits events, ticks the step counter, checks the cancel flag,
14
+ # and drains the interloper queue — "what fires when" is one grep for
20
15
  # +@listeners.emit+ in +agent.rb+.
21
16
  #
22
- # == What each control does
23
- #
24
- # * {StepLimit} — caps the number of tool calls per
25
- # {Agent#run_loop}. The +Agent+ calls {StepLimit#tick!} on
26
- # every +before_tool_call+ (raising {StepLimit::Exceeded}
27
- # when over budget) and {StepLimit#reset!} at the start of
28
- # each turn. Sub-agents get their own counter.
29
- # * {Cancellable} — cooperative cancellation flag. The host
30
- # calls {Cancellable#cancel!} from any thread; the +Agent+
31
- # calls {Cancellable#check!} at +before_tool_call+ (raising
32
- # {Cancellable::Cancelled} when the flag is set) and
33
- # {Cancellable#reset!} at the start of each turn. The same
34
- # instance is shared by reference across the parent, every
35
- # sub-agent, and the synthesizer rescue.
36
- # * {Interloper} — mid-loop user-input queue. The host calls
37
- # {Interloper#inject_user_message} from any thread; the
38
- # +Agent+ drains the queue at +after_tool_result+,
39
- # appending each item as a user-role message and emitting
40
- # {Event::UserTurn} with +mid_loop: true+. Not propagated to
41
- # sub-agents (the host has no handle to them).
17
+ # * {StepLimit} — caps tool calls per {Agent#run_loop}; sub-agents get their
18
+ # own counter.
19
+ # * {Cancellable} — cooperative cancel flag, shared by reference across the
20
+ # parent, sub-agents, and the synthesizer.
21
+ # * {Interloper} — mid-loop user-input queue; not propagated to sub-agents.
42
22
  module Control
43
23
  end
44
24
  end