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.

Defined Under Namespace

Classes: TurnState

Constant Summary collapse

MAX_STEPS =
25

Instance Method Summary collapse

Instance Method Details

#agent(id) ⇒ Object

Live-agent lookup for interfaces (§9.2); nil when never spawned.



156
# File 'lib/terret/loop.rb', line 156

def agent(id) = @agents[id]

#agent_for_session(session_id) ⇒ Object

Session -> live agent, for services that learn a session id from an event and need the agent (approvals flips status through this).



160
# File 'lib/terret/loop.rb', line 160

def agent_for_session(session_id) = @by_session[session_id]

#dispose_agent(id) ⇒ Object

Tear an idle agent down: its forked context disposes (listeners and effects die with it) and both registry slots free. Mid-turn agents refuse — cancel or resolve first.



165
166
167
168
169
170
171
172
173
# File 'lib/terret/loop.rb', line 165

def dispose_agent(id)
  agent = @agents.fetch(id)
  unless agent.status == :idle
    raise TurnAlreadyRunning, "agent #{id} is #{agent.status}; dispose only idle agents"
  end

  tear_down_agent(agent)
  agent
end

#reconfigure(config) ⇒ Object



129
130
131
# File 'lib/terret/loop.rb', line 129

def reconfigure(config)
  @max_agents = config[:max_agents] || 128
end

#resumable?(session_id) ⇒ Boolean

The log has a turn/start after its last turn/end.

Returns:

  • (Boolean)


227
228
229
230
231
232
233
# File 'lib/terret/loop.rb', line 227

def resumable?(session_id)
  events = @ctx[:sessions].fetch(session_id).events
  opened = events.rindex { |e| e.type == "turn/start" }
  return false unless opened

  events[opened..].none? { |e| e.type == "turn/end" }
end

#resume_turn(agent) ⇒ Object

Continue a turn the log left open (a process death mid-park, plan §6.3/§12 M6). No second turn/start — the open one is already durable. The open step completes first: tool calls owed by the last assistant message that lack a tool/result re-execute through the pipeline, where the approvals gate reads verdicts from the log — an approved call runs, an unresolved one parks again on its standing request. Then stepping continues as normal.

Raises:

  • (ArgumentError)


216
217
218
219
220
221
222
223
224
# File 'lib/terret/loop.rb', line 216

def resume_turn(agent)
  raise ArgumentError, "session #{agent.session_id} has no open turn" unless resumable?(agent.session_id)

  # A transient failure here (an LLM outage) must leave the turn open for
  # the next stimulus: closing it would strand the owed tool call for good.
  turning(agent, close_on_failure: false) do |state, _sessions, _sid|
    step_loop(agent, state, pending: [], steps: complete_dangling(agent))
  end
end

#run_turn(agent, input) ⇒ Object

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



194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/terret/loop.rb', line 194

def run_turn(agent, input)
  # Only an idle agent is asked this: while it is mid-turn the log's open
  # turn is its own, TurnAlreadyRunning is the accurate answer, and the
  # socket's raced-wake requeue is written against it.
  if agent.status == :idle && resumable?(agent.session_id)
    raise TurnOpenInLog,
          "session #{agent.session_id} has an open turn; resume_turn it"
  end

  turning(agent) do |state, sessions, sid|
    sessions.append(sid, "turn/start", { agent: agent.id })
    step_loop(agent, state, pending: input.nil? ? [] : [["user/message", input]], steps: 0)
  end
end

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

parent: is the context the agent's own scope forks from. It defaults to this service's root exactly as it always did, so every interface spawning a top-level agent keeps its call site; the subagent provider is the one caller that passes something else — the CALLING agent's fork, which is what makes a child inherit that agent's roster and policy floor instead of the root's (docs/subagents.md §3).

Raises:



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/terret/loop.rb', line 139

def spawn_agent(session_id:, id: "agent-#{session_id}", parent: @ctx)
  raise AgentExists, "agent #{id} already exists" if @agents.key?(id)
  if (live = @by_session[session_id])
    raise AgentExists, "session #{session_id} already has agent #{live.id}"
  end
  if @agents.size >= @max_agents
    raise AgentCapExceeded,
          "#{@agents.size} agents live; max_agents is #{@max_agents}"
  end

  agent = Agent.new(id:, session_id:, ctx: parent.fork)
  @agents[id] = agent
  @by_session[session_id] = agent
  agent
end

#start(ctx) ⇒ Object



122
123
124
125
126
127
# File 'lib/terret/loop.rb', line 122

def start(ctx)
  @ctx = ctx
  @agents = {}
  @by_session = {}
  @max_agents = config[:max_agents] || 128
end

#stop(_ctx) ⇒ Object

Lifecycle teardown: the loader calls this when the loop row unloads (Boot.shutdown unloads every row through unload!, which calls stop). The loop is a plugin like any other, and its agents are state it holds — an idle fork left mounted keeps its per-agent policy listeners and forked tool registrations alive past the shutdown meant to end them. Every agent goes, whatever its status, because the process is coming down; best-effort and idempotent, so one fork whose disposal raises cannot strand the rest and a second call finds nothing left to do.



183
184
185
186
187
188
189
# File 'lib/terret/loop.rb', line 183

def stop(_ctx)
  @agents.values.each do |agent|
    tear_down_agent(agent)
  rescue StandardError => e
    warn "terret: loop shutdown: agent #{agent.id} would not dispose: #{e.class}: #{e.message}"
  end
end