Class: Assistant::ToolHalt

Inherits:
Data
  • Object
show all
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

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Attribute Details

#contentObject (readonly)

Returns the value of attribute content

Returns:

  • (Object)

    the current value of 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+.

Parameters:

  • chat (RubyLLM::Chat, nil)


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.

Parameters:

  • chat (RubyLLM::Chat, nil)

Returns:



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.

Parameters:

  • chat (RubyLLM::Chat, nil)

Yields:

  • the LLM call



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.

Parameters:

  • chat (RubyLLM::Chat, nil)
  • halt (ToolHalt)


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_jsonObject

Alias for Content#to_json

Returns:

  • (Object)

    Content#to_json

See Also:



42
# File 'app/services/assistant/tool_halt.rb', line 42

delegate :to_s, :to_json, to: :content

#to_sObject

Alias for Content#to_s

Returns:

  • (Object)

    Content#to_s

See Also:



42
# File 'app/services/assistant/tool_halt.rb', line 42

delegate :to_s, :to_json, to: :content