Class: Pikuri::Agent::Control::Interloper

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/agent/control/interloper.rb

Overview

Mid-loop user-input queue. A host (TUI, web client) constructs an Interloper, hands it to Pikuri::Agent#initialize via the interloper: kwarg, and calls #inject_user_message from any thread while the agent is running. The Agent drains the queue at the next after_tool_result boundary — the only point inside ruby_llm’s loop where the conversation state is consistent — and emits each item into the chat history plus the listener stream. The agent’s next round-trip then sees the injected user message and reacts to it on its own.

Interloper is groundwork for downstream TUI/web hosts; the bundled bin/pikuri-* entry-point scripts do not wire one up, since they keep stdin synchronous and have no way for a user to type while a turn is in flight. Downstream hosts that do run the agent on a worker thread can wire one in with no other changes to pikuri.

Delivery boundary and side effects

When the Agent‘s after_tool_result wiring fires, it calls #drain! on the interloper (if any) and, for each returned item:

  1. Appends a role: :user message to the chat history so the next complete round-trip’s request includes it.

  2. Emits Event::UserTurn(content:, mid_loop: true) through the listener stream so other listeners (Terminal renderer, in-memory recorder, future logging) see the injection as a normal UserTurn event with the mid_loop: flag set.

Controls do not respond to events; the Agent pokes StepLimit#reset! and Cancellable#reset! only at the start of each turn — never on a mid-loop injection — so the “cancel-then-inject” hazard and the “refresh-budget-by-injecting” hazard cannot arise.

Boundary caveats

The delivery point is after_tool_result, not the LLM HTTP call. Injections placed while the model is mid-response take effect on the next round-trip — by the time the queue drains, the model has already committed to whichever tool calls were in that response. The agent therefore typically observes an injection at the tool-batch boundary after the one during which the host called #inject_user_message. This is the same “gentle” semantic that Cancellable promises and is the cleanest cross-provider point: no in-flight subprocess, no half-applied write, no half-built response.

Thread safety

#inject_user_message, #peek, #pending?, and #drain! are safe to call from any thread; the internal queue is a Mutex-guarded Array. The Agent‘s drain runs on the run thread (whatever thread invoked Chat#ask). Mutex was chosen over Thread::Queue because Thread::Queue exposes no snapshot read, and #peek is part of the surface (the host wants to render “feedback received, will deliver shortly” in its UI before the agent actually consumes the injection).

Sub-agent semantics

#for_sub_agent returns nil. Sub-agents are private to the parent agent; the host has no handle to them, so a child Interloper would be unreachable. The sub-agent’s Pikuri::Agent#initialize simply receives interloper: nil from Tool::SubAgent, which is its default. The behavior contrasts with Cancellable, which shares its instance by reference so the parent’s signal propagates to children — cancellation is a global “stop the whole tree” event, whereas injection is a directed “talk to the main agent” event.

Instance Method Summary collapse

Constructor Details

#initializeInterloper

Returns a new instance of Interloper.



84
85
86
87
# File 'lib/pikuri/agent/control/interloper.rb', line 84

def initialize
  @mutex = Mutex.new
  @items = []
end

Instance Method Details

#drain!Array<String>

Atomically take and remove all pending items. Called by Pikuri::Agent‘s after_tool_result wiring; the Agent then appends each item to the chat history and emits an Event::UserTurn with mid_loop: true for each.

Returns [] when the queue is empty (the hot path —every after_tool_result calls this).

Returns:

  • (Array<String>)

    items in delivery order; empty when the queue is empty



136
137
138
139
140
141
142
143
144
# File 'lib/pikuri/agent/control/interloper.rb', line 136

def drain!
  @mutex.synchronize do
    next [] if @items.empty?

    items = @items.dup
    @items.clear
    items
  end
end

#for_sub_agentnil

Sub-agent variant: nil, signalling to Pikuri::Agent (and transitively to Tool::SubAgent) that no Interloper should be wired on a spawned sub-agent. See the class header for the “host has no handle to sub-agents” rationale.

Returns:

  • (nil)


153
154
155
# File 'lib/pikuri/agent/control/interloper.rb', line 153

def for_sub_agent(**)
  nil
end

#inject_user_message(content) ⇒ void

This method returns an undefined value.

Push content onto the delivery queue. Safe from any thread; the queue is Mutex-guarded.

Parameters:

  • content (String)

    non-blank user-supplied text

Raises:

  • (ArgumentError)

    if content is nil, empty, or whitespace-only — same rule as Pikuri::Agent#run_loop‘s user_message: argument, since an empty injection would poison the chat history just as a blank turn would



99
100
101
102
103
104
105
# File 'lib/pikuri/agent/control/interloper.rb', line 99

def inject_user_message(content)
  raise ArgumentError, "content must not be blank, got #{content.inspect}" \
    if content.nil? || content.to_s.strip.empty?

  @mutex.synchronize { @items << content }
  nil
end

#peekArray<String>

Non-destructive snapshot of the queue, in delivery order. Intended for hosts that want to render an “ongoing / pending” UI affordance (“3 messages waiting to deliver”) in parallel with the agent’s progress stream. Safe to call from any thread.

Returns:

  • (Array<String>)

    copy of the pending items; never shares state with the internal buffer



115
116
117
# File 'lib/pikuri/agent/control/interloper.rb', line 115

def peek
  @mutex.synchronize { @items.dup }
end

#pending?Boolean

Returns whether the queue currently holds at least one pending injection; observable from any thread.

Returns:

  • (Boolean)

    whether the queue currently holds at least one pending injection; observable from any thread



122
123
124
# File 'lib/pikuri/agent/control/interloper.rb', line 122

def pending?
  @mutex.synchronize { !@items.empty? }
end

#to_sString

Returns short label for Pikuri::Agent#to_s; reflects the pending-count so a debug print or banner can tell an idle interloper apart from one with queued items.

Returns:

  • (String)

    short label for Pikuri::Agent#to_s; reflects the pending-count so a debug print or banner can tell an idle interloper apart from one with queued items



160
161
162
163
# File 'lib/pikuri/agent/control/interloper.rb', line 160

def to_s
  size = @mutex.synchronize { @items.size }
  size.zero? ? 'Interloper' : "Interloper(#{size} pending)"
end