power-loop 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. llm_client/__init__.py +0 -0
  2. llm_client/capabilities.py +162 -0
  3. llm_client/interface.py +470 -0
  4. llm_client/llm_factory.py +981 -0
  5. llm_client/llm_tooling.py +645 -0
  6. llm_client/llm_utils.py +205 -0
  7. llm_client/multimodal.py +237 -0
  8. llm_client/qwen_image.py +576 -0
  9. llm_client/web_search.py +149 -0
  10. power_loop/__init__.py +326 -0
  11. power_loop/agent/__init__.py +6 -0
  12. power_loop/agent/sink.py +247 -0
  13. power_loop/agent/stateful_loop.py +363 -0
  14. power_loop/agent/system_prompt.py +396 -0
  15. power_loop/agent/types.py +41 -0
  16. power_loop/contracts/__init__.py +132 -0
  17. power_loop/contracts/errors.py +140 -0
  18. power_loop/contracts/event_payloads.py +278 -0
  19. power_loop/contracts/events.py +86 -0
  20. power_loop/contracts/handlers.py +45 -0
  21. power_loop/contracts/hook_contexts.py +265 -0
  22. power_loop/contracts/hooks.py +64 -0
  23. power_loop/contracts/messages.py +90 -0
  24. power_loop/contracts/protocols.py +48 -0
  25. power_loop/contracts/tools.py +56 -0
  26. power_loop/core/agent_context.py +94 -0
  27. power_loop/core/events.py +124 -0
  28. power_loop/core/hooks.py +122 -0
  29. power_loop/core/phase.py +217 -0
  30. power_loop/core/pipeline.py +880 -0
  31. power_loop/core/runner.py +60 -0
  32. power_loop/core/state.py +208 -0
  33. power_loop/runtime/budget.py +179 -0
  34. power_loop/runtime/cancellation.py +127 -0
  35. power_loop/runtime/compact.py +300 -0
  36. power_loop/runtime/env.py +103 -0
  37. power_loop/runtime/memory.py +107 -0
  38. power_loop/runtime/provider.py +176 -0
  39. power_loop/runtime/retry.py +182 -0
  40. power_loop/runtime/session_store.py +636 -0
  41. power_loop/runtime/skills.py +201 -0
  42. power_loop/runtime/spec.py +233 -0
  43. power_loop/runtime/structured.py +225 -0
  44. power_loop/tools/__init__.py +51 -0
  45. power_loop/tools/default_manifest.py +244 -0
  46. power_loop/tools/default_tools.py +766 -0
  47. power_loop/tools/registry.py +162 -0
  48. power_loop/tools/spawn_agent.py +173 -0
  49. power_loop-0.2.0.dist-info/METADATA +632 -0
  50. power_loop-0.2.0.dist-info/RECORD +53 -0
  51. power_loop-0.2.0.dist-info/WHEEL +5 -0
  52. power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. power_loop-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,247 @@
1
+ """MessageSink: persistence hook the pipeline calls on every state change.
2
+
3
+ The pipeline stays storage-agnostic. It calls these methods at well-defined
4
+ moments; a sink turns them into rows in the :class:`SessionStore`, or into
5
+ no-ops for an in-memory run.
6
+
7
+ Three concrete sinks ship here:
8
+
9
+ * :class:`NullSink` — the default, used when no persistence is wanted.
10
+ * :class:`SQLiteSink` — wraps a :class:`SessionStore` + ``session_id``.
11
+ * (Subagent sink, added in PR-3, also reuses :class:`SQLiteSink`.)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Protocol, runtime_checkable
17
+
18
+ from power_loop.agent.types import LoopMessage
19
+ from power_loop.runtime.session_store import SessionStore
20
+
21
+
22
+ @runtime_checkable
23
+ class MessageSink(Protocol):
24
+ """Persistence callbacks invoked by :class:`AgentPipeline`.
25
+
26
+ Every method MUST be safe to call multiple times and MUST NOT raise on
27
+ normal paths — sinks degrade gracefully and log internally if needed.
28
+ """
29
+
30
+ def on_round_started(self, round_index: int) -> None: ...
31
+ def on_message_appended(self, message: LoopMessage, *, round_index: int | None) -> None: ...
32
+ def on_assistant_tool_calls(
33
+ self, *, assistant_seq: int, tool_calls: list[dict[str, Any]], round_index: int
34
+ ) -> None: ...
35
+ def on_compaction(
36
+ self,
37
+ *,
38
+ fold_start_idx: int,
39
+ fold_end_idx: int,
40
+ summary_text: str,
41
+ before_tokens: int,
42
+ after_tokens: int,
43
+ round_index: int,
44
+ ) -> None: ...
45
+ def on_round_ended(
46
+ self, round_index: int, *, usage: dict[str, Any] | None = None
47
+ ) -> None: ...
48
+
49
+
50
+ class NullSink:
51
+ """No-op sink. Used when the pipeline runs without persistence."""
52
+
53
+ def on_round_started(self, round_index: int) -> None: ...
54
+ def on_message_appended(self, message: LoopMessage, *, round_index: int | None) -> None: ...
55
+ def on_assistant_tool_calls(
56
+ self, *, assistant_seq: int, tool_calls: list[dict[str, Any]], round_index: int
57
+ ) -> None: ...
58
+ def on_compaction(
59
+ self,
60
+ *,
61
+ fold_start_idx: int,
62
+ fold_end_idx: int,
63
+ summary_text: str,
64
+ before_tokens: int,
65
+ after_tokens: int,
66
+ round_index: int,
67
+ ) -> None: ...
68
+ def on_round_ended(
69
+ self, round_index: int, *, usage: dict[str, Any] | None = None
70
+ ) -> None: ...
71
+
72
+
73
+ class SQLiteSink:
74
+ """Persist messages + pending-state to a :class:`SessionStore` row.
75
+
76
+ Pending state machine
77
+ ---------------------
78
+ ``session_state.pending_json`` is set the moment the assistant emits
79
+ ``tool_calls`` and is cleared once every matching ``tool`` message has
80
+ been appended. Crash anywhere in between leaves the session in a
81
+ *pending* state that the next :meth:`StatefulAgentLoop.send` will refuse
82
+ until the caller picks resume/abort.
83
+ """
84
+
85
+ def __init__(self, store: SessionStore, session_id: str) -> None:
86
+ self.store = store
87
+ self.session_id = session_id
88
+ self._unresolved: set[str] = set()
89
+ self._assistant_seq: int | None = None
90
+ # Ordered seqs mirroring the pipeline's in-memory history. Initialized
91
+ # by StatefulAgentLoop from the loaded active messages; appended to as
92
+ # the pipeline emits new messages.
93
+ self._history_seqs: list[int] = []
94
+
95
+ def init_history_seqs(self, seqs: list[int]) -> None:
96
+ """Called by :class:`StatefulAgentLoop` with the seqs of the loaded
97
+ active messages, in the same order they sit in pipeline.history."""
98
+ self._history_seqs = list(seqs)
99
+
100
+ # ── messages ───────────────────────────────────────────────
101
+
102
+ def on_round_started(self, round_index: int) -> None:
103
+ self.store.set_round_index(self.session_id, round_index)
104
+
105
+ def on_message_appended(
106
+ self, message: LoopMessage, *, round_index: int | None
107
+ ) -> None:
108
+ role = message.get("role")
109
+ if role == "tool":
110
+ tool_call_id = str(message.get("tool_call_id") or "")
111
+ seq = self.store.append_message(
112
+ self.session_id,
113
+ role="tool",
114
+ content=_as_text(message.get("content")),
115
+ tool_call_id=tool_call_id,
116
+ name=message.get("name"),
117
+ round_index=round_index,
118
+ )
119
+ self._history_seqs.append(seq)
120
+ # Auto-resolve pending: when the matching tool message lands,
121
+ # drop it from the unresolved set and clear pending once empty.
122
+ if tool_call_id and tool_call_id in self._unresolved:
123
+ self._unresolved.discard(tool_call_id)
124
+ if self._unresolved:
125
+ self.store.set_pending(
126
+ self.session_id,
127
+ {
128
+ "assistant_seq": self._assistant_seq,
129
+ "round_index": round_index,
130
+ "tool_call_ids": sorted(self._unresolved),
131
+ },
132
+ )
133
+ else:
134
+ self.store.set_pending(self.session_id, None)
135
+ self._assistant_seq = None
136
+ return
137
+ if role == "assistant":
138
+ tool_calls = message.get("tool_calls")
139
+ seq = self.store.append_message(
140
+ self.session_id,
141
+ role="assistant",
142
+ content=_as_text(message.get("content")),
143
+ tool_calls=list(tool_calls) if tool_calls else None,
144
+ round_index=round_index,
145
+ )
146
+ self._history_seqs.append(seq)
147
+ if tool_calls:
148
+ self._assistant_seq = seq
149
+ return
150
+ # user / system / anything else
151
+ seq = self.store.append_message(
152
+ self.session_id,
153
+ role=str(role or "user"),
154
+ content=_as_text(message.get("content")),
155
+ name=message.get("name"),
156
+ round_index=round_index,
157
+ )
158
+ self._history_seqs.append(seq)
159
+
160
+ # ── pending state machine ──────────────────────────────────
161
+
162
+ def on_assistant_tool_calls(
163
+ self, *, assistant_seq: int, tool_calls: list[dict[str, Any]], round_index: int
164
+ ) -> None:
165
+ ids = [str(tc.get("id") or "") for tc in tool_calls if tc.get("id")]
166
+ self._unresolved = set(ids)
167
+ self._assistant_seq = assistant_seq
168
+ self.store.set_pending(
169
+ self.session_id,
170
+ {
171
+ "assistant_seq": assistant_seq,
172
+ "round_index": round_index,
173
+ "tool_call_ids": ids,
174
+ "tool_calls": list(tool_calls),
175
+ },
176
+ )
177
+
178
+ def on_compaction(
179
+ self,
180
+ *,
181
+ fold_start_idx: int,
182
+ fold_end_idx: int,
183
+ summary_text: str,
184
+ before_tokens: int,
185
+ after_tokens: int,
186
+ round_index: int,
187
+ ) -> None:
188
+ """Persist a compaction: mark messages [fold_start_idx, fold_end_idx]
189
+ in the in-memory history as ``compacted_out`` in the store, append the
190
+ ``compact_note`` row, and rewrite ``_history_seqs`` to mirror the
191
+ post-compaction in-memory history (so future appends keep the index
192
+ invariant).
193
+ """
194
+ if not (0 <= fold_start_idx <= fold_end_idx < len(self._history_seqs)):
195
+ return # defensive: out-of-range indices → no-op
196
+ from_seq = self._history_seqs[fold_start_idx]
197
+ to_seq = self._history_seqs[fold_end_idx]
198
+ _, note_seq = self.store.record_compaction(
199
+ self.session_id,
200
+ from_seq=from_seq,
201
+ to_seq=to_seq,
202
+ note_content=summary_text,
203
+ before_tokens=before_tokens,
204
+ after_tokens=after_tokens,
205
+ round_index=round_index,
206
+ )
207
+ # In the in-memory history the cut range is replaced by ONE note
208
+ # message; mirror that here.
209
+ self._history_seqs = (
210
+ self._history_seqs[:fold_start_idx]
211
+ + [note_seq]
212
+ + self._history_seqs[fold_end_idx + 1 :]
213
+ )
214
+
215
+ def on_round_ended(
216
+ self, round_index: int, *, usage: dict[str, Any] | None = None
217
+ ) -> None:
218
+ if usage:
219
+ self.store.record_usage(
220
+ self.session_id,
221
+ round_index=round_index,
222
+ prompt_tokens=_int_or_none(usage.get("prompt_tokens") or usage.get("input")),
223
+ completion_tokens=_int_or_none(
224
+ usage.get("completion_tokens") or usage.get("output")
225
+ ),
226
+ total_tokens=_int_or_none(usage.get("total_tokens")),
227
+ )
228
+
229
+
230
+ def _as_text(content: Any) -> str | None:
231
+ if content is None:
232
+ return None
233
+ if isinstance(content, str):
234
+ return content
235
+ # multimodal lists / dicts — preserve as JSON-ish string
236
+ import json
237
+
238
+ return json.dumps(content, ensure_ascii=False)
239
+
240
+
241
+ def _int_or_none(v: Any) -> int | None:
242
+ if v is None:
243
+ return None
244
+ try:
245
+ return int(v)
246
+ except (TypeError, ValueError):
247
+ return None
@@ -0,0 +1,363 @@
1
+ """StatefulAgentLoop — the single public entry point for power-loop.
2
+
3
+ Owns a :class:`SessionStore` and gives callers a stateful, ``send(user_input,
4
+ session_id=...)`` interface. Everything else — pipeline orchestration, hooks,
5
+ events, tool invocation, persistence, pending-state machine — is wired up
6
+ internally.
7
+
8
+ Failure model
9
+ -------------
10
+ * If a session has unresolved tool_calls from a previous run, :meth:`send`
11
+ raises :class:`SessionPendingError`. Caller decides:
12
+ - :meth:`resume` to finish executing those tool_calls and continue, or
13
+ - :meth:`abort_pending` to synthesize ``<aborted>`` tool messages and
14
+ proceed with the new input.
15
+ * :meth:`close_session` physically deletes the session and its data.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ from llm_client.interface import LLMService
26
+ from power_loop.agent.sink import SQLiteSink
27
+ from power_loop.agent.types import AgentLoopConfig, AgentLoopResult, LoopMessage
28
+ from power_loop.contracts.errors import SessionNotFoundError, SessionPendingError
29
+ from power_loop.core.agent_context import reset_current_loop, set_current_loop
30
+ from power_loop.core.events import AgentEventBus
31
+ from power_loop.core.hooks import AgentHooks
32
+ from power_loop.core.pipeline import (
33
+ AgentPipeline,
34
+ _tool_call_args,
35
+ _tool_call_name,
36
+ _truncate_result,
37
+ )
38
+ from power_loop.core.runner import AgentRunner
39
+ from power_loop.core.state import ContextManager
40
+ from power_loop.runtime.cancellation import CancellationLike
41
+ from power_loop.runtime.session_store import (
42
+ DEFAULT_DB_PATH,
43
+ MessageRow,
44
+ MessageState,
45
+ SessionStore,
46
+ SubagentLifecycle,
47
+ )
48
+ from power_loop.tools.registry import ToolRegistry
49
+
50
+
51
+ @dataclass
52
+ class StatefulResult:
53
+ """Result of a single :meth:`StatefulAgentLoop.send` call."""
54
+
55
+ session_id: str
56
+ status: str
57
+ final_text: str = ""
58
+ rounds: int = 0
59
+ pending_tool_calls: list[dict[str, Any]] = field(default_factory=list)
60
+
61
+
62
+ class StatefulAgentLoop:
63
+ """The only public entry point for running an agent loop.
64
+
65
+ A single instance can drive any number of sessions concurrently (one
66
+ session never blocks another beyond SQLite's row-level locking). The
67
+ store is owned by the loop; callers may share it across multiple
68
+ StatefulAgentLoop instances if they need different configs.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ llm: LLMService,
75
+ store: SessionStore | None = None,
76
+ db_path: str = DEFAULT_DB_PATH,
77
+ config: AgentLoopConfig | None = None,
78
+ tool_registry: ToolRegistry | None = None,
79
+ hooks: AgentHooks | None = None,
80
+ event_bus: AgentEventBus | None = None,
81
+ ) -> None:
82
+ self.llm = llm
83
+ self.store = store if store is not None else SessionStore.open(db_path)
84
+ self._owns_store = store is None
85
+ self.config = config if config is not None else AgentLoopConfig()
86
+ self.tool_registry = tool_registry
87
+ self._runner = AgentRunner(event_bus=event_bus, hooks=hooks)
88
+ self._locks: dict[str, asyncio.Lock] = {}
89
+
90
+ # ── lifecycle ─────────────────────────────────────────────────────────
91
+
92
+ def close(self) -> None:
93
+ """Close the underlying store (if owned). Does NOT delete sessions."""
94
+ if self._owns_store:
95
+ self.store.close()
96
+
97
+ def close_session(self, session_id: str, *, cascade: bool = True) -> int:
98
+ """Physically delete the session and (by default) its LINKED subtree."""
99
+ return self.store.close_session(session_id, cascade=cascade)
100
+
101
+ # ── primary API ───────────────────────────────────────────────────────
102
+
103
+ async def send(
104
+ self,
105
+ user_input: str | LoopMessage,
106
+ session_id: str | None = None,
107
+ *,
108
+ metadata: dict[str, Any] | None = None,
109
+ stop_event: CancellationLike = None,
110
+ ) -> StatefulResult:
111
+ """Append one user input to the session and run the loop.
112
+
113
+ Creates a new session if ``session_id`` is ``None``.
114
+
115
+ Raises :class:`SessionPendingError` if the session has unresolved
116
+ tool_calls; the caller must call :meth:`resume` or
117
+ :meth:`abort_pending` first.
118
+ """
119
+ sid = session_id or self._create_session(metadata=metadata)
120
+ async with self._lock_for(sid):
121
+ self._ensure_session_or_raise(sid)
122
+ self._raise_if_pending(sid)
123
+ self._persist_user_input(sid, user_input)
124
+ return await self._run_loop(sid, stop_event=stop_event)
125
+
126
+ def send_sync(
127
+ self,
128
+ user_input: str | LoopMessage,
129
+ session_id: str | None = None,
130
+ *,
131
+ metadata: dict[str, Any] | None = None,
132
+ stop_event: CancellationLike = None,
133
+ ) -> StatefulResult:
134
+ return asyncio.run(
135
+ self.send(user_input, session_id, metadata=metadata, stop_event=stop_event)
136
+ )
137
+
138
+ async def resume(
139
+ self,
140
+ session_id: str,
141
+ *,
142
+ stop_event: CancellationLike = None,
143
+ ) -> StatefulResult:
144
+ """Finish executing pending tool_calls, then continue the loop.
145
+
146
+ No-op (but still valid) if the session has no pending state — equivalent
147
+ to "run one more round with no new user input".
148
+ """
149
+ async with self._lock_for(session_id):
150
+ self._ensure_session_or_raise(session_id)
151
+ sink = SQLiteSink(self.store, session_id)
152
+ await self._execute_pending(session_id, sink)
153
+ return await self._run_loop(session_id, stop_event=stop_event, sink=sink)
154
+
155
+ def abort_pending(self, session_id: str, *, reason: str = "aborted") -> int:
156
+ """Synthesize ``<aborted: reason>`` tool messages for every unresolved
157
+ tool_call, restoring message-protocol validity. Returns the number of
158
+ aborted tool_calls.
159
+ """
160
+ self._ensure_session_or_raise(session_id)
161
+ state = self.store.get_state(session_id)
162
+ if state is None or not state.pending:
163
+ return 0
164
+ pending = state.pending
165
+ round_index = int(pending.get("round_index") or 0)
166
+ tool_calls = pending.get("tool_calls") or [
167
+ {"id": cid} for cid in pending.get("tool_call_ids", [])
168
+ ]
169
+ sink = SQLiteSink(self.store, session_id)
170
+ sink._unresolved = {str(tc.get("id") or "") for tc in tool_calls}
171
+ sink._assistant_seq = pending.get("assistant_seq")
172
+ for tc in tool_calls:
173
+ cid = str(tc.get("id") or "")
174
+ name = _tool_call_name(tc) if "function" in tc or "name" in tc else None
175
+ sink.on_message_appended(
176
+ {
177
+ "role": "tool",
178
+ "tool_call_id": cid,
179
+ "name": name,
180
+ "content": f"<aborted: {reason}>",
181
+ },
182
+ round_index=round_index,
183
+ )
184
+ return len(tool_calls)
185
+
186
+ # ── inspection ────────────────────────────────────────────────────────
187
+
188
+ def get_messages(self, session_id: str, *, include_compacted: bool = False) -> list[LoopMessage]:
189
+ rows = (
190
+ self.store.load_all_messages(session_id)
191
+ if include_compacted
192
+ else self.store.load_active_messages(session_id)
193
+ )
194
+ return [_row_to_loop_message(r) for r in rows]
195
+
196
+ def get_pending(self, session_id: str) -> dict[str, Any] | None:
197
+ state = self.store.get_state(session_id)
198
+ return state.pending if state else None
199
+
200
+ # ── internals ─────────────────────────────────────────────────────────
201
+
202
+ def _create_session(
203
+ self,
204
+ *,
205
+ metadata: dict[str, Any] | None,
206
+ parent_session_id: str | None = None,
207
+ spawn_tool_call_id: str | None = None,
208
+ lifecycle: SubagentLifecycle = SubagentLifecycle.EPHEMERAL,
209
+ system_prompt: str | None = None,
210
+ ) -> str:
211
+ return self.store.create_session(
212
+ system_prompt=system_prompt or self.config.system_prompt,
213
+ config={
214
+ "max_rounds": self.config.max_rounds,
215
+ "max_tokens": self.config.max_tokens,
216
+ "temperature": self.config.temperature,
217
+ },
218
+ parent_session_id=parent_session_id,
219
+ spawn_tool_call_id=spawn_tool_call_id,
220
+ lifecycle=lifecycle,
221
+ metadata=metadata,
222
+ )
223
+
224
+ def _lock_for(self, sid: str) -> asyncio.Lock:
225
+ lock = self._locks.get(sid)
226
+ if lock is None:
227
+ lock = asyncio.Lock()
228
+ self._locks[sid] = lock
229
+ return lock
230
+
231
+ def _ensure_session_or_raise(self, sid: str) -> None:
232
+ if self.store.get_session(sid) is None:
233
+ raise SessionNotFoundError(sid)
234
+
235
+ def _raise_if_pending(self, sid: str) -> None:
236
+ state = self.store.get_state(sid)
237
+ if state is not None and state.pending:
238
+ pending = state.pending
239
+ raise SessionPendingError(
240
+ sid,
241
+ assistant_seq=int(pending.get("assistant_seq") or 0),
242
+ pending_tool_calls=pending.get("tool_calls", []),
243
+ )
244
+
245
+ def _persist_user_input(self, sid: str, user_input: str | LoopMessage) -> None:
246
+ if isinstance(user_input, str):
247
+ self.store.append_message(sid, role="user", content=user_input)
248
+ return
249
+ role = user_input.get("role", "user")
250
+ self.store.append_message(
251
+ sid,
252
+ role=str(role),
253
+ content=_as_text(user_input.get("content")),
254
+ name=user_input.get("name"),
255
+ )
256
+
257
+ async def _execute_pending(self, sid: str, sink: SQLiteSink) -> None:
258
+ """Replay leftover tool_calls. Idempotent if there is no pending."""
259
+ state = self.store.get_state(sid)
260
+ if state is None or not state.pending:
261
+ return
262
+ pending = state.pending
263
+ round_index = int(pending.get("round_index") or 0)
264
+ tool_calls = pending.get("tool_calls") or []
265
+ if not tool_calls:
266
+ return
267
+ # Initialize sink's in-memory unresolved set so auto-resolve works.
268
+ sink._unresolved = {str(tc.get("id") or "") for tc in tool_calls}
269
+ sink._assistant_seq = pending.get("assistant_seq")
270
+ for tc in tool_calls:
271
+ cid = str(tc.get("id") or "")
272
+ name = _tool_call_name(tc)
273
+ args = _tool_call_args(tc)
274
+ if self.tool_registry is None:
275
+ output, failed = (
276
+ f"Error: tool '{name}' has no registry on resume",
277
+ True,
278
+ )
279
+ else:
280
+ try:
281
+ raw = await self.tool_registry.invoke_async(name, args)
282
+ if not isinstance(raw, str):
283
+ raw = json.dumps(raw, ensure_ascii=False)
284
+ output, failed = str(raw), False
285
+ except Exception as exc:
286
+ output, failed = f"Error on resume: {exc}", True
287
+ sink.on_message_appended(
288
+ {
289
+ "role": "tool",
290
+ "tool_call_id": cid,
291
+ "name": name,
292
+ "content": _truncate_result(output),
293
+ },
294
+ round_index=round_index,
295
+ )
296
+ if failed:
297
+ # Still resolved from the protocol's POV — the tool message
298
+ # exists. Surface failure via content text.
299
+ pass
300
+
301
+ async def _run_loop(
302
+ self,
303
+ sid: str,
304
+ *,
305
+ stop_event: CancellationLike,
306
+ sink: SQLiteSink | None = None,
307
+ ) -> StatefulResult:
308
+ sink = sink if sink is not None else SQLiteSink(self.store, sid)
309
+ active_rows = self.store.load_active_messages(sid)
310
+ history = [_row_to_loop_message(r) for r in active_rows]
311
+ # Mirror loaded seqs into the sink so the compactor can translate
312
+ # in-memory indices back to store rows when it folds messages.
313
+ sink.init_history_seqs([r.seq for r in active_rows])
314
+
315
+ async with self._runner.session_async(session_id=sid):
316
+ loop_token = set_current_loop(self)
317
+ try:
318
+ pipeline = AgentPipeline(
319
+ llm=self.llm,
320
+ config=self.config,
321
+ tool_registry=self.tool_registry,
322
+ hooks=self._runner.hooks,
323
+ bus=self._runner.event_bus,
324
+ ctx=ContextManager(role="main"),
325
+ session_id=sid,
326
+ stop_event=stop_event,
327
+ sink=sink,
328
+ )
329
+ result: AgentLoopResult = await pipeline.run(history)
330
+ finally:
331
+ reset_current_loop(loop_token)
332
+ return StatefulResult(
333
+ session_id=sid,
334
+ status=result.status,
335
+ final_text=result.final_text,
336
+ rounds=result.rounds,
337
+ pending_tool_calls=result.pending_tool_calls,
338
+ )
339
+
340
+
341
+ # ── helpers ──────────────────────────────────────────────────────────────
342
+
343
+
344
+ def _row_to_loop_message(row: MessageRow) -> LoopMessage:
345
+ msg: LoopMessage = {"role": row.role}
346
+ if row.content is not None:
347
+ msg["content"] = row.content
348
+ if row.tool_calls:
349
+ msg["tool_calls"] = list(row.tool_calls)
350
+ if row.tool_call_id:
351
+ msg["tool_call_id"] = row.tool_call_id
352
+ if row.name:
353
+ msg["name"] = row.name
354
+ return msg
355
+
356
+
357
+ def _as_text(content: Any) -> str | None:
358
+ if content is None or isinstance(content, str):
359
+ return content
360
+ return json.dumps(content, ensure_ascii=False)
361
+
362
+
363
+ __all__ = ["StatefulAgentLoop", "StatefulResult", "MessageState"]