Class: SolidLoop::ToolMiddlewares::ResponseCreation

Inherits:
Object
  • Object
show all
Defined in:
app/services/solid_loop/tool_middlewares/response_creation.rb

Instance Method Summary collapse

Constructor Details

#initialize(app) ⇒ ResponseCreation

Returns a new instance of ResponseCreation.



4
5
6
# File 'app/services/solid_loop/tool_middlewares/response_creation.rb', line 4

def initialize(app)
  @app = app
end

Instance Method Details

#call(env) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'app/services/solid_loop/tool_middlewares/response_creation.rb', line 8

def call(env)
  created_message = nil

  # LOCK ORDER (the tool canonical-write fence): loop row → tool_call → message,
  # continuing the order ToolExecution began. The LOOP row lock is held
  # across BOTH the both-token validation AND the canonical response +
  # continuation, so a pause/resume/stop (which only takes the loop lock)
  # cannot slip between validation and the write: with the loop FOR
  # UPDATE-locked, `loop.execution_token` and `tool_call.lease_token` are
  # frozen for the whole write. A stale/revoked worker writes NOTHING —
  # no tool response message, no completion transition, no successor job.
  #
  # Response persistence, state advancement and successor-job dispatch
  # commit atomically inside this transaction (SolidLoop requires a
  # same-database transactional job backend): a crash or rollback leaves
  # neither the state change nor a dangling job.
  env.loop.with_lock do
    env.tool_call.with_lock do
    env.message.with_lock do
    # Finding 9 — the canonical tool RESPONSE message + continuation is the
    # second half of the tool completion write; fence it with BOTH tokens
    # too. A worker whose generation was rotated or whose per-tool lease was
    # revoked between ToolExecution and here must write no canonical
    # response and dispatch no successor. The `executed_at` checkpoint
    # (written under the tool_call lock in ToolExecution, itself fenced)
    # remains the terminal exactly-once guard; this guards the message +
    # continuation half against the same stale-worker race.
    return unless canonical_continuation_owned?(env)

    unless env.tool_call.response_message
      created_message = env.loop.messages.create!(
        role: "tool",
        content: env.result,
        tool_call_id: env.tool_call.tool_call_id,
        status: "success"
      )
    end

    # Explicit ordered scope — never rely on association default order.
    tool_calls = env.message.tool_calls.ordered.to_a
    response_ids = env.loop.messages
      .where(role: :tool, tool_call_id: tool_calls.map(&:tool_call_id))
      .where("id > ?", env.message.id)
      .distinct
      .pluck(:tool_call_id)

    next_tool_call = tool_calls.find { |tool_call| !response_ids.include?(tool_call.tool_call_id) }
    all_resolved = next_tool_call.nil?

    # Sequential agents chain the next unresolved call here; parallel
    # agents already enqueued every call up front, so each job only
    # advances the loop once *all* results have landed. The completion
    # gate is identical in both modes and fenced by the execution token,
    # so whichever job commits the final result queues the next LLM turn.
    sequential = (env.agent || env.loop.agent)&.sequential_tool_calls?

    if sequential && next_tool_call
      SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, next_tool_call.id, env.execution_token)
    elsif all_resolved && env.loop.transition_status(
      from: :running,
      to: :queued,
      expected_execution_token: env.execution_token,
      execution_token: nil,
      lease_expires_at: nil
    )
      SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, env.loop.id)
    end
    end # env.message.with_lock
    end # env.tool_call.with_lock
  end # env.loop.with_lock

  if created_message && env.loop.observe_enabled?
    SolidLoop::ObserveBroadcastJob.perform_later(created_message.id)
  end

  @app.call(env)
end