Class: Terret::Loop
- Inherits:
-
Hames::Service
- Object
- Hames::Service
- Terret::Loop
- 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
-
#agent(id) ⇒ Object
Live-agent lookup for interfaces (§9.2); nil when never spawned.
-
#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).
-
#dispose_agent(id) ⇒ Object
Tear an idle agent down: its forked context disposes (listeners and effects die with it) and both registry slots free.
- #reconfigure(config) ⇒ Object
-
#resumable?(session_id) ⇒ Boolean
The log has a turn/start after its last turn/end.
-
#resume_turn(agent) ⇒ Object
Continue a turn the log left open (a process death mid-park, plan §6.3/§12 M6).
-
#run_turn(agent, input) ⇒ Object
Runs one turn for
input. -
#spawn_agent(session_id:, id: "agent-#{session_id}", parent: @ctx) ⇒ Object
parent:is the context the agent's own scope forks from. - #start(ctx) ⇒ Object
-
#stop(_ctx) ⇒ Object
Lifecycle teardown: the loader calls this when the loop row unloads (Boot.shutdown unloads every row through unload!, which calls stop).
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.
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.
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).
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 |