Class: Assistant::ToolHalt
- Inherits:
-
Data
- Object
- Data
- Assistant::ToolHalt
- Defined in:
- app/services/assistant/tool_halt.rb
Overview
Sunny's tool-loop stop signal.
RubyLLM 1.x shipped +RubyLLM::Tool::Halt+: a tool could return it and the
gem's loop stopped instead of feeding the result back to the model. 2.0
removed it — its answer to loop control is "drive the loop yourself"
(+step+ / +complete?+) — so the sentinel is ours now.
How it stops the loop on 2.0, without hand-rolling the whole agentic loop:
ChatService registers an +after_tool_result+ callback that signals the halt
and calls +chat.cancel!+. The gem's next cancellation checkpoint raises
+RubyLLM::CancelledError+ — crucially AFTER the current tool result has been
appended and BEFORE the next model call, which is exactly the old Halt
semantics. Callers rescue that error and ToolHalt.consume! the halt.
Two producers:
- ToolLoopGuard — call-limit / duration / user-cancel stops,
so a cancelled turn stops dead instead of feeding "cancelled" JSON back
to a model that ignores it (conv 1589: 100+ consecutive cancelled tool
results in one turn). - +declare_plan+ (PlanToolBuilder) — hands control to
ChatService's plan orchestration.
Three consumers, all via ChatService#with_tool_halt: +call+,
+complete_only+, and the plan step / assembly executors in
PlanOrchestrator.
The channel is scoped to the CHAT OBJECT, not to a thread. Sunny runs tools
concurrently in production (+SUNNY_TOOL_CONCURRENCY+ → RubyLLM's +:threads+
mode), and +after_tool_result+ fires inside the tool worker thread — so a
thread-local written there is invisible to the thread that called +ask+,
which would consume nil and re-raise the cancellation instead of routing
the plan / duration / user-cancel halt. The chat is the correct scope: one
chat per turn (and per plan step), shared by all of its tool threads.
Constant Summary collapse
- IVAR =
:@sunny_tool_halt
Instance Attribute Summary collapse
-
#content ⇒ Object
readonly
Returns the value of attribute content.
Delegated Instance Attributes collapse
-
#to_json ⇒ Object
Alias for Content#to_json.
-
#to_s ⇒ Object
Alias for Content#to_s.
Class Method Summary collapse
-
.clear!(chat) ⇒ void
Drop any halt left on +chat+.
-
.consume!(chat) ⇒ ToolHalt?
Take the pending halt off +chat+, clearing it.
-
.scoped(chat) { ... } ⇒ Object
Run a block as one halt scope on +chat+, guaranteeing the channel is empty on both entry AND exit.
-
.signal!(chat, halt) ⇒ void
Record a halt against +chat+.
Instance Attribute Details
#content ⇒ Object (readonly)
Returns the value of attribute content
37 38 39 |
# File 'app/services/assistant/tool_halt.rb', line 37 def content @content end |
Class Method Details
.clear!(chat) ⇒ void
This method returns an undefined value.
Drop any halt left on +chat+.
74 75 76 |
# File 'app/services/assistant/tool_halt.rb', line 74 def clear!(chat) chat&.instance_variable_set(IVAR, nil) end |
.consume!(chat) ⇒ ToolHalt?
Take the pending halt off +chat+, clearing it. Nil means the
CancelledError came from a real cancellation (user stop / persisted
cancel request), not a halt — callers must re-raise in that case.
63 64 65 66 67 68 69 |
# File 'app/services/assistant/tool_halt.rb', line 63 def consume!(chat) return nil unless chat&.instance_variable_defined?(IVAR) halt = chat.instance_variable_get(IVAR) chat.instance_variable_set(IVAR, nil) halt end |
.scoped(chat) { ... } ⇒ Object
Run a block as one halt scope on +chat+, guaranteeing the channel is
empty on both entry AND exit. The ensure matters because chats are
memoized and reused across turns: a halt signalled on a path that then
raised something OTHER than CancelledError would linger and be consumed
by a later turn, reporting a stale plan or cancellation as its result.
85 86 87 88 89 90 |
# File 'app/services/assistant/tool_halt.rb', line 85 def scoped(chat) clear!(chat) yield ensure clear!(chat) end |
.signal!(chat, halt) ⇒ void
This method returns an undefined value.
Record a halt against +chat+. Called from the after_tool_result
callback — possibly on a tool worker thread — immediately before
+chat.cancel!+. Concurrent writes are benign: any halt stops the loop,
and the guard/plan halts a single turn can raise are equivalent stops.
54 55 56 |
# File 'app/services/assistant/tool_halt.rb', line 54 def signal!(chat, halt) chat&.instance_variable_set(IVAR, halt) end |
Instance Method Details
#to_json ⇒ Object
Alias for Content#to_json
42 |
# File 'app/services/assistant/tool_halt.rb', line 42 delegate :to_s, :to_json, to: :content |
#to_s ⇒ Object
Alias for Content#to_s
42 |
# File 'app/services/assistant/tool_halt.rb', line 42 delegate :to_s, :to_json, to: :content |