Class: Terret::Loop

Inherits:
Hames::Service
  • Object
show all
Defined in:
lib/terret/loop.rb

Overview

ctx.loop — the default driver. A step is one model request plus the tool calls it makes; a turn is zero or more steps and closes once nothing is owed. The driver is itself a plugin: replace this row in config and every tool, adapter, and UI keeps working.

Constant Summary collapse

MAX_STEPS =
25

Instance Method Summary collapse

Instance Method Details

#run_turn(agent, input) ⇒ Object

Runs one turn for input. Returns the turn status symbol.



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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/terret/loop.rb', line 47

def run_turn(agent, input)
  ctx      = agent.ctx
  sessions = ctx[:sessions]
  sid      = agent.session_id
  agent.status = :running

  sessions.append(sid, "turn/start", { agent: agent.id })
  status = :completed
  steps = 0

  # claim next-step input plus anything waiting in the inbox
  pending = [input, *agent.drain_inbox].compact

  loop do
    claim = ctx.waterfall("agent/pre_step", Claim.of(pending)) { |c| c }
    if claim.rejected || (steps.zero? && claim.messages.empty? && pending.empty?)
      # a rejected or empty first claim still closes a durable turn that
      # spent no step, so the log records the attempt
      status = claim.rejected ? :rejected : :empty
      break
    end

    steps += 1
    raise "runaway turn" if steps > MAX_STEPS

    sessions.append(sid, "step/start", { n: steps })
    claim.messages.each { |t| sessions.append(sid, "user/message", { text: t }) }
    pending = []

    history = sessions.derive_messages(sid)
    request = LLM::Request.new(model: nil, system: ctx[:prompt].render(agent:),
                               messages: history, tools: ctx[:tools].schemas)
    request = ctx.waterfall("agent/request", request)
    sessions.assert_log_invariant!(sid, request.messages)

    message = ctx[:llm].stream(ctx, role: :main, request: request) do |ev|
      case ev
      when LLM::TextDelta
        sessions.append(sid, "assistant/chunk", { text: ev.text })
      end
    end
    sessions.append(sid, "assistant/message", { parts: message.parts })

    calls = message.tool_calls
    if calls.empty?
      sessions.append(sid, "step/end", { n: steps })
      break # nothing owed
    end

    calls.each do |tc|
      sessions.append(sid, "tool/call", { id: tc.id, name: tc.name, args: tc.args })
      result = ctx[:tools].execute(
        Tools::Call.new(id: tc.id, name: tc.name, args: tc.args, session_id: sid)
      )
      sessions.append(sid, "tool/result",
                      { id: result.id, content: result.content, error: result.error })
    end
    sessions.append(sid, "step/end", { n: steps })
    # tools owe another request -> next step
  end

  ctx.serial("agent/turn_stopping", agent)
  sessions.append(sid, "turn/end", { status: status })
  agent.status = :idle
  status
end

#spawn_agent(session_id:, id: "agent-#{session_id}") ⇒ Object



42
43
44
# File 'lib/terret/loop.rb', line 42

def spawn_agent(session_id:, id: "agent-#{session_id}")
  Agent.new(id:, session_id:, ctx: @ctx.fork)
end

#start(ctx) ⇒ Object



38
39
40
# File 'lib/terret/loop.rb', line 38

def start(ctx)
  @ctx = ctx
end