Class: Insika::TurnOutput
- Inherits:
-
Object
- Object
- Insika::TurnOutput
- Defined in:
- lib/insika/turn_output.rb
Overview
WHAT REACHES THE CUSTOMER — the turn's publishing rule.
A turn is not one assistant message. Between the user's message and the answer
the model may narrate the loop ("deixa eu buscar isso pra você"), apologise for
a tool that failed, or — when it has no tool to call — reason in prose. All of
it arrives as ordinary content chunks, indistinguishable at the token level
from the answer, so streaming every chunk straight to :content published all
of it. Running a real store's prompt proved what that costs: 132 deltas of an
English monologue ("Let me check the tools I actually have… Actually, let me
reconsider.") went out as response.output_text.delta — the bytes a WhatsApp
customer would have read.
The rule, enforced here: :content carries the ANSWER — the text of the
assistant message that ENDS the turn. Everything else rides :intermediate,
which the Studio and the trace render (that is the operator's window into what
the model narrated) and which /v1/responses deliberately does not translate,
exactly like :thinking.
Which message is the last one is only knowable when it ends — a message that
carries tool calls is never the answer — so text is buffered per message and
published at the boundary (RubyLLM's after_message). Two consequences, both
deliberate:
· The customer-visible stream is per MESSAGE, not per token. ttft_ms still
measures the provider's first token, so item 34's baselines stay comparable;
what moved is when the customer can read it. For the WhatsApp edge this
changes nothing — the dispatcher accumulated the deltas into one message
anyway — and the Studio keeps its live typing off :intermediate.
· A turn that dies mid-message publishes nothing. Half a sentence was never an
answer; the fragment is still on the stream as :intermediate for whoever is
debugging it.
halt_when (PR #130) is the one case where narration IS the turn: the tool
already answered the customer, and the model's lead-in ("vou te inscrever
agora") is all the turn is worth. That text is retained and published by the
Executor's halt branch — see #halt_text.
Instance Attribute Summary collapse
-
#candidate ⇒ Object
readonly
The text of the message that ended the turn, or nil if no boundary said so.
Instance Method Summary collapse
-
#flush ⇒ Object
Releases the redactor's retained tail (the sliding buffer holds back a value that might still be growing into a match) into the current message.
-
#halt_text ⇒ Object
The narration of the message that halted the turn.
-
#initialize(filter:, emit:, public_intermediate: false) ⇒ TurnOutput
constructor
filter: Safety::OutputFilter | nil (RFC-0009 §3.2 — nil = stream untouched).
-
#message_ended(message) ⇒ Object
A message ended (RubyLLM
after_message). -
#publish(text) ⇒ Object
Emits the answer and returns it.
-
#push(text) ⇒ Object
One provider chunk.
Constructor Details
#initialize(filter:, emit:, public_intermediate: false) ⇒ TurnOutput
filter: Safety::OutputFilter | nil (RFC-0009 §3.2 — nil = stream untouched).
emit: ->(type, data) — the Executor's emitter, already bound to the task.
public_intermediate: the agent opted this channel in (edge_stream), so the
narration is TAGGED and /v1/responses gives it its own frame. Default false:
an internal event stays internal unless someone said otherwise.
52 53 54 55 56 57 58 59 |
# File 'lib/insika/turn_output.rb', line 52 def initialize(filter:, emit:, public_intermediate: false) @filter = filter @emit = emit @public_intermediate = public_intermediate @pending = +"" # text of the message currently streaming @last_intermediate = +"" # text of the last message that turned out NOT to be the answer @candidate = nil end |
Instance Attribute Details
#candidate ⇒ Object (readonly)
The text of the message that ended the turn, or nil if no boundary said so.
It is a CANDIDATE, not the published answer: the :agent after-hook runs
after the message ends and may replace the whole response, so the Executor
decides and publishes once, at the end of the stage.
45 46 47 |
# File 'lib/insika/turn_output.rb', line 45 def candidate @candidate end |
Instance Method Details
#flush ⇒ Object
Releases the redactor's retained tail (the sliding buffer holds back a value that might still be growing into a match) into the current message.
87 88 89 90 91 92 93 94 95 |
# File 'lib/insika/turn_output.rb', line 87 def flush return unless @filter tail = @filter.flush.to_s return if tail.empty? @pending << tail emit_intermediate(tail) end |
#halt_text ⇒ Object
The narration of the message that halted the turn. Normally the message
boundary already moved it aside (@last_intermediate); a transport that does
not report boundaries leaves it in @pending, and the lead-in is worth the
same either way.
109 |
# File 'lib/insika/turn_output.rb', line 109 def halt_text = @last_intermediate.empty? ? @pending : @last_intermediate |
#message_ended(message) ⇒ Object
A message ended (RubyLLM after_message). Only an assistant message decides
anything — a role: tool result is the gem's bookkeeping. A message carrying
tool calls is intermediate by definition: the model asked for something, so
it was not done talking.
76 77 78 79 80 81 82 83 |
# File 'lib/insika/turn_output.rb', line 76 def () return unless assistant?() flush text = @pending @pending = +"" tool_calls?() ? @last_intermediate = text : @candidate = text end |
#publish(text) ⇒ Object
Emits the answer and returns it. Empty text emits no event — an empty turn is what the consumer suppresses.
99 100 101 102 103 |
# File 'lib/insika/turn_output.rb', line 99 def publish(text) answer = text.to_s @emit.call(:content, { delta: answer }) unless answer.empty? answer end |
#push(text) ⇒ Object
One provider chunk. Publishes the redacted slice as :intermediate — live,
chunk by chunk, because the operator surfaces want to watch it happen — and
holds it until the message boundary decides what it was.
64 65 66 67 68 69 70 |
# File 'lib/insika/turn_output.rb', line 64 def push(text) slice = @filter ? @filter.push(text) : text.to_s return if slice.empty? @pending << slice emit_intermediate(slice) end |