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.
- llm_client/__init__.py +0 -0
- llm_client/capabilities.py +162 -0
- llm_client/interface.py +470 -0
- llm_client/llm_factory.py +981 -0
- llm_client/llm_tooling.py +645 -0
- llm_client/llm_utils.py +205 -0
- llm_client/multimodal.py +237 -0
- llm_client/qwen_image.py +576 -0
- llm_client/web_search.py +149 -0
- power_loop/__init__.py +326 -0
- power_loop/agent/__init__.py +6 -0
- power_loop/agent/sink.py +247 -0
- power_loop/agent/stateful_loop.py +363 -0
- power_loop/agent/system_prompt.py +396 -0
- power_loop/agent/types.py +41 -0
- power_loop/contracts/__init__.py +132 -0
- power_loop/contracts/errors.py +140 -0
- power_loop/contracts/event_payloads.py +278 -0
- power_loop/contracts/events.py +86 -0
- power_loop/contracts/handlers.py +45 -0
- power_loop/contracts/hook_contexts.py +265 -0
- power_loop/contracts/hooks.py +64 -0
- power_loop/contracts/messages.py +90 -0
- power_loop/contracts/protocols.py +48 -0
- power_loop/contracts/tools.py +56 -0
- power_loop/core/agent_context.py +94 -0
- power_loop/core/events.py +124 -0
- power_loop/core/hooks.py +122 -0
- power_loop/core/phase.py +217 -0
- power_loop/core/pipeline.py +880 -0
- power_loop/core/runner.py +60 -0
- power_loop/core/state.py +208 -0
- power_loop/runtime/budget.py +179 -0
- power_loop/runtime/cancellation.py +127 -0
- power_loop/runtime/compact.py +300 -0
- power_loop/runtime/env.py +103 -0
- power_loop/runtime/memory.py +107 -0
- power_loop/runtime/provider.py +176 -0
- power_loop/runtime/retry.py +182 -0
- power_loop/runtime/session_store.py +636 -0
- power_loop/runtime/skills.py +201 -0
- power_loop/runtime/spec.py +233 -0
- power_loop/runtime/structured.py +225 -0
- power_loop/tools/__init__.py +51 -0
- power_loop/tools/default_manifest.py +244 -0
- power_loop/tools/default_tools.py +766 -0
- power_loop/tools/registry.py +162 -0
- power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0.dist-info/METADATA +632 -0
- power_loop-0.2.0.dist-info/RECORD +53 -0
- power_loop-0.2.0.dist-info/WHEEL +5 -0
- power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
- power_loop-0.2.0.dist-info/top_level.txt +2 -0
power_loop/agent/sink.py
ADDED
|
@@ -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"]
|