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
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""AgentPipeline — the core agent loop refactored into discrete, hookable phases.
|
|
2
|
+
|
|
3
|
+
Phase methods (``prepare_round``, ``call_llm``, ``execute_tool``) are pure
|
|
4
|
+
business logic with explicit parameters and return types. All hook
|
|
5
|
+
orchestration, directive checks, and event publishing live in ``run()``.
|
|
6
|
+
|
|
7
|
+
The old ``agent_loop_async`` function is preserved in ``agent.py`` as a thin
|
|
8
|
+
wrapper that delegates to ``AgentPipeline.run()``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from llm_client.interface import LLMRequest, LLMResponse, LLMService
|
|
19
|
+
from power_loop.agent.sink import MessageSink, NullSink
|
|
20
|
+
from power_loop.agent.system_prompt import DEFAULT_AGENT_SYSTEM_PROMPT
|
|
21
|
+
from power_loop.agent.types import AgentLoopConfig, AgentLoopResult, LoopMessage
|
|
22
|
+
from power_loop.contracts.errors import (
|
|
23
|
+
CancellationRequested,
|
|
24
|
+
LLMRetryExhausted,
|
|
25
|
+
LLMTimeout,
|
|
26
|
+
ToolNotFound,
|
|
27
|
+
ToolValidationError,
|
|
28
|
+
)
|
|
29
|
+
from power_loop.contracts.event_payloads import (
|
|
30
|
+
AutoCompactStatusPayload,
|
|
31
|
+
BaseEventPayload,
|
|
32
|
+
HitRoundLimitStatusPayload,
|
|
33
|
+
LlmDegradedPayload,
|
|
34
|
+
LlmRetryAttemptedPayload,
|
|
35
|
+
LoopCancelledPayload,
|
|
36
|
+
MemoryFailedPayload,
|
|
37
|
+
MemoryRecalledPayload,
|
|
38
|
+
RoundCompletedPayload,
|
|
39
|
+
RoundStartedPayload,
|
|
40
|
+
RoundToolsPresentPayload,
|
|
41
|
+
RoundUsageStatusPayload,
|
|
42
|
+
SessionEndedPayload,
|
|
43
|
+
SessionStartedPayload,
|
|
44
|
+
StreamCompletedPayload,
|
|
45
|
+
StreamDeltaPayload,
|
|
46
|
+
StreamStartedPayload,
|
|
47
|
+
ToolCallCompletedPayload,
|
|
48
|
+
ToolCallFailedPayload,
|
|
49
|
+
ToolCallStartedPayload,
|
|
50
|
+
UsageUpdatedPayload,
|
|
51
|
+
UserNotificationPayload,
|
|
52
|
+
)
|
|
53
|
+
from power_loop.contracts.events import AgentEvent, AgentEventType
|
|
54
|
+
from power_loop.contracts.hook_contexts import (
|
|
55
|
+
CompactAfterCtx,
|
|
56
|
+
CompactBeforeCtx,
|
|
57
|
+
LlmAfterCtx,
|
|
58
|
+
LlmBeforeCtx,
|
|
59
|
+
MemoryRecalledCtx,
|
|
60
|
+
MessageAppendCtx,
|
|
61
|
+
RoundDecideCtx,
|
|
62
|
+
RoundEndCtx,
|
|
63
|
+
RoundStartCtx,
|
|
64
|
+
SessionEndCtx,
|
|
65
|
+
SessionStartCtx,
|
|
66
|
+
ToolAfterCtx,
|
|
67
|
+
ToolBeforeCtx,
|
|
68
|
+
ToolErrorCtx,
|
|
69
|
+
ToolsBatchAfterCtx,
|
|
70
|
+
ToolsBatchBeforeCtx,
|
|
71
|
+
)
|
|
72
|
+
from power_loop.contracts.hooks import HookDirective, HookPoint
|
|
73
|
+
from power_loop.core.events import AgentEventBus
|
|
74
|
+
from power_loop.core.hooks import AgentHooks
|
|
75
|
+
from power_loop.core.state import ContextManager
|
|
76
|
+
from power_loop.runtime.cancellation import CancellationLike, CancellationToken
|
|
77
|
+
from power_loop.runtime.memory import MemorySnapshot, tag_as_memory
|
|
78
|
+
from power_loop.runtime.retry import with_retry
|
|
79
|
+
from power_loop.tools.registry import ToolRegistry
|
|
80
|
+
|
|
81
|
+
RESULT_MAX_CHARS = 50000
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Utility functions (unchanged from old agent.py) ──
|
|
85
|
+
|
|
86
|
+
def _truncate_result(output: Any) -> str:
|
|
87
|
+
s = str(output)
|
|
88
|
+
if len(s) <= RESULT_MAX_CHARS:
|
|
89
|
+
return s
|
|
90
|
+
return s[: RESULT_MAX_CHARS - 50] + f"\n... (truncated, {len(s)} total chars)"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _tool_call_name(tool_call: Mapping[str, Any]) -> str:
|
|
94
|
+
fn = tool_call.get("function")
|
|
95
|
+
if isinstance(fn, Mapping):
|
|
96
|
+
return str(fn.get("name") or "unknown")
|
|
97
|
+
return str(tool_call.get("name") or "unknown")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _tool_call_args(tool_call: Mapping[str, Any]) -> dict[str, Any]:
|
|
101
|
+
fn = tool_call.get("function")
|
|
102
|
+
if not isinstance(fn, Mapping):
|
|
103
|
+
return {}
|
|
104
|
+
args = fn.get("arguments")
|
|
105
|
+
if isinstance(args, Mapping):
|
|
106
|
+
return dict(args)
|
|
107
|
+
if not isinstance(args, str):
|
|
108
|
+
return {}
|
|
109
|
+
text = args.strip()
|
|
110
|
+
if not text:
|
|
111
|
+
return {}
|
|
112
|
+
try:
|
|
113
|
+
loaded = json.loads(text)
|
|
114
|
+
return dict(loaded) if isinstance(loaded, Mapping) else {}
|
|
115
|
+
except Exception:
|
|
116
|
+
try:
|
|
117
|
+
repaired = text.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
118
|
+
loaded = json.loads(repaired)
|
|
119
|
+
return dict(loaded) if isinstance(loaded, Mapping) else {}
|
|
120
|
+
except Exception:
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _sanitize_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
125
|
+
out: list[dict[str, Any]] = []
|
|
126
|
+
for tc in tool_calls:
|
|
127
|
+
tc2: dict[str, Any] = dict(tc)
|
|
128
|
+
fn = tc2.get("function")
|
|
129
|
+
if isinstance(fn, Mapping):
|
|
130
|
+
fn2 = dict(fn)
|
|
131
|
+
args = fn2.get("arguments")
|
|
132
|
+
if isinstance(args, Mapping):
|
|
133
|
+
fn2["arguments"] = json.dumps(dict(args), ensure_ascii=False)
|
|
134
|
+
elif isinstance(args, str):
|
|
135
|
+
try:
|
|
136
|
+
json.loads(args)
|
|
137
|
+
except Exception:
|
|
138
|
+
try:
|
|
139
|
+
repaired = args.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
140
|
+
json.loads(repaired)
|
|
141
|
+
fn2["arguments"] = repaired
|
|
142
|
+
except Exception:
|
|
143
|
+
fn2["arguments"] = "{}"
|
|
144
|
+
elif args is None:
|
|
145
|
+
fn2["arguments"] = "{}"
|
|
146
|
+
tc2["function"] = fn2
|
|
147
|
+
out.append(tc2)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_cancelled(token: CancellationToken | None) -> bool:
|
|
152
|
+
return bool(token is not None and token.is_cancelled())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _round_usage_payload(*, round_index: int, max_rounds: int, usage: dict[str, Any]) -> RoundUsageStatusPayload:
|
|
156
|
+
def _g(*keys: str) -> int | None:
|
|
157
|
+
for k in keys:
|
|
158
|
+
if k in usage and usage[k] is not None:
|
|
159
|
+
return int(usage[k])
|
|
160
|
+
return None
|
|
161
|
+
return RoundUsageStatusPayload(
|
|
162
|
+
time_iso=datetime.now().isoformat(timespec="seconds"),
|
|
163
|
+
round_index=round_index,
|
|
164
|
+
round_number=round_index + 1,
|
|
165
|
+
max_rounds=max_rounds,
|
|
166
|
+
prompt_tokens=_g("prompt_tokens", "input"),
|
|
167
|
+
completion_tokens=_g("completion_tokens", "output"),
|
|
168
|
+
cache_read_tokens=_g("cache_read_tokens", "cache_read"),
|
|
169
|
+
reasoning_tokens=_g("reasoning_tokens", "reasoning"),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── AgentPipeline ──
|
|
174
|
+
|
|
175
|
+
class AgentPipeline:
|
|
176
|
+
"""Agent loop as a pipeline of hookable phases.
|
|
177
|
+
|
|
178
|
+
Attributes set by the caller (or by ``from_context``):
|
|
179
|
+
llm, config, tool_registry, hooks, bus, ctx, session_id, stop_event
|
|
180
|
+
|
|
181
|
+
Mutable session state:
|
|
182
|
+
history, rounds_since_todo, system_prompt, runtime_tools
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
llm: LLMService,
|
|
189
|
+
config: AgentLoopConfig,
|
|
190
|
+
tool_registry: ToolRegistry | None = None,
|
|
191
|
+
hooks: AgentHooks,
|
|
192
|
+
bus: AgentEventBus,
|
|
193
|
+
ctx: ContextManager,
|
|
194
|
+
session_id: str | None = None,
|
|
195
|
+
stop_event: CancellationLike = None,
|
|
196
|
+
sink: MessageSink | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
self.llm = llm
|
|
199
|
+
self.config = config
|
|
200
|
+
self.tool_registry = tool_registry
|
|
201
|
+
self.hooks = hooks
|
|
202
|
+
self.bus = bus
|
|
203
|
+
self.ctx = ctx
|
|
204
|
+
self.session_id = session_id
|
|
205
|
+
# Normalise to CancellationToken once; pipeline only ever sees this shape.
|
|
206
|
+
self.cancel_token: CancellationToken = CancellationToken.from_any(stop_event)
|
|
207
|
+
# Legacy attribute kept for hook ctx fields (RoundStartCtx.stop_event etc.).
|
|
208
|
+
self.stop_event = stop_event if isinstance(stop_event, threading.Event) else None
|
|
209
|
+
self.sink: MessageSink = sink if sink is not None else NullSink()
|
|
210
|
+
|
|
211
|
+
self.system_prompt = (config.system_prompt or DEFAULT_AGENT_SYSTEM_PROMPT).strip()
|
|
212
|
+
self.runtime_tools = tool_registry.to_openai_tools() if tool_registry is not None else None
|
|
213
|
+
self.history: list[LoopMessage] = []
|
|
214
|
+
self.rounds_since_todo = 0
|
|
215
|
+
self._completed_rounds = 0
|
|
216
|
+
|
|
217
|
+
# ── Helper: emit event ──
|
|
218
|
+
|
|
219
|
+
def _emit(self, event_type: AgentEventType, data: BaseEventPayload,
|
|
220
|
+
*, round_index: int | None = None, stream_id: str | None = None) -> None:
|
|
221
|
+
self.bus.publish(AgentEvent(
|
|
222
|
+
type=event_type,
|
|
223
|
+
data=data,
|
|
224
|
+
session_id=self.session_id,
|
|
225
|
+
round_index=round_index,
|
|
226
|
+
stream_id=stream_id,
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
# ── Helper: append message (with MESSAGE_APPEND hook) ──
|
|
230
|
+
|
|
231
|
+
async def _append_message(self, msg: LoopMessage, *, round_index: int | None = None) -> None:
|
|
232
|
+
ctx = MessageAppendCtx(
|
|
233
|
+
round_index=round_index or 0,
|
|
234
|
+
message=dict(msg),
|
|
235
|
+
session_id=self.session_id,
|
|
236
|
+
)
|
|
237
|
+
await self.hooks.run_typed_async(HookPoint.MESSAGE_APPEND, ctx)
|
|
238
|
+
self.history.append(ctx.message)
|
|
239
|
+
self.sink.on_message_appended(ctx.message, round_index=round_index)
|
|
240
|
+
|
|
241
|
+
# ── Helper: finalize session ──
|
|
242
|
+
|
|
243
|
+
async def _finalize(self, reason: str, *, final_text: str | None = None,
|
|
244
|
+
rounds: int | None = None) -> None:
|
|
245
|
+
if rounds is not None:
|
|
246
|
+
self._completed_rounds = rounds
|
|
247
|
+
ctx = SessionEndCtx(
|
|
248
|
+
scope="main", reason=reason,
|
|
249
|
+
messages=self.history, final_text=final_text,
|
|
250
|
+
)
|
|
251
|
+
await self.hooks.run_typed_async(HookPoint.SESSION_END, ctx)
|
|
252
|
+
self._emit(AgentEventType.SESSION_ENDED, SessionEndedPayload(reason=reason))
|
|
253
|
+
await self._maybe_remember(reason=reason, final_text=final_text or "")
|
|
254
|
+
|
|
255
|
+
# ── Memory: recall at start, remember at end (M1.9) ──
|
|
256
|
+
|
|
257
|
+
async def _maybe_recall(self) -> None:
|
|
258
|
+
"""Call ``config.memory.recall`` and inject results into ``self.history``.
|
|
259
|
+
|
|
260
|
+
Injection position: after any leading ``role=system`` messages
|
|
261
|
+
(which includes a ``compact_note`` if one is present). Recalled
|
|
262
|
+
messages are tagged ``role=system, name=memory_*`` so they share
|
|
263
|
+
the system region's compactor protection.
|
|
264
|
+
|
|
265
|
+
Soft-fails on any exception by emitting ``MEMORY_FAILED`` and
|
|
266
|
+
continuing with no injection.
|
|
267
|
+
"""
|
|
268
|
+
provider = self.config.memory
|
|
269
|
+
if provider is None:
|
|
270
|
+
return
|
|
271
|
+
budget = int(self.config.memory_budget_tokens or 0)
|
|
272
|
+
try:
|
|
273
|
+
recalled = await provider.recall(
|
|
274
|
+
messages=self.history, session_id=self.session_id, budget_tokens=budget,
|
|
275
|
+
)
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
self._emit(
|
|
278
|
+
AgentEventType.MEMORY_FAILED,
|
|
279
|
+
MemoryFailedPayload(
|
|
280
|
+
phase="recall", error_type=type(exc).__name__,
|
|
281
|
+
error_message=str(exc)[:500],
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
recalled = list(recalled or [])
|
|
287
|
+
returned = len(recalled)
|
|
288
|
+
hook_ctx = MemoryRecalledCtx(
|
|
289
|
+
recalled=recalled, session_id=self.session_id, budget_tokens=budget,
|
|
290
|
+
)
|
|
291
|
+
await self.hooks.run_typed_async(HookPoint.MEMORY_RECALLED, hook_ctx)
|
|
292
|
+
if hook_ctx.directive == HookDirective.SKIP:
|
|
293
|
+
self._emit(
|
|
294
|
+
AgentEventType.MEMORY_RECALLED,
|
|
295
|
+
MemoryRecalledPayload(returned=returned, injected=0, budget_tokens=budget),
|
|
296
|
+
)
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
tagged = tag_as_memory(list(hook_ctx.recalled or []))
|
|
300
|
+
if not tagged:
|
|
301
|
+
self._emit(
|
|
302
|
+
AgentEventType.MEMORY_RECALLED,
|
|
303
|
+
MemoryRecalledPayload(returned=returned, injected=0, budget_tokens=budget),
|
|
304
|
+
)
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
insert_at = 0
|
|
308
|
+
n = len(self.history)
|
|
309
|
+
while insert_at < n and self.history[insert_at].get("role") == "system":
|
|
310
|
+
insert_at += 1
|
|
311
|
+
self.history[insert_at:insert_at] = tagged
|
|
312
|
+
|
|
313
|
+
self._emit(
|
|
314
|
+
AgentEventType.MEMORY_RECALLED,
|
|
315
|
+
MemoryRecalledPayload(returned=returned, injected=len(tagged), budget_tokens=budget),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
async def _maybe_remember(self, *, reason: str, final_text: str) -> None:
|
|
319
|
+
provider = self.config.memory
|
|
320
|
+
if provider is None:
|
|
321
|
+
return
|
|
322
|
+
snapshot = MemorySnapshot(
|
|
323
|
+
session_id=self.session_id or "",
|
|
324
|
+
messages=list(self.history),
|
|
325
|
+
final_text=final_text,
|
|
326
|
+
rounds=self._completed_rounds,
|
|
327
|
+
status=reason,
|
|
328
|
+
)
|
|
329
|
+
try:
|
|
330
|
+
await provider.remember(snapshot=snapshot, session_id=self.session_id)
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
self._emit(
|
|
333
|
+
AgentEventType.MEMORY_FAILED,
|
|
334
|
+
MemoryFailedPayload(
|
|
335
|
+
phase="remember", error_type=type(exc).__name__,
|
|
336
|
+
error_message=str(exc)[:500],
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _make_result(self, status: str, *, final_text: str = "", rounds: int = 0,
|
|
341
|
+
pending_tool_calls: list | None = None) -> AgentLoopResult:
|
|
342
|
+
self._completed_rounds = rounds # for MemorySnapshot
|
|
343
|
+
return AgentLoopResult(
|
|
344
|
+
status=status, # type: ignore[arg-type]
|
|
345
|
+
final_text=final_text,
|
|
346
|
+
rounds=rounds,
|
|
347
|
+
pending_tool_calls=pending_tool_calls or [],
|
|
348
|
+
messages=self.history,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# ══════════════════════════════════════════════════════════════
|
|
352
|
+
# Phase methods — pure business logic with explicit parameters.
|
|
353
|
+
# Hook orchestration is handled entirely by run().
|
|
354
|
+
# ══════════════════════════════════════════════════════════════
|
|
355
|
+
|
|
356
|
+
async def prepare_round(self, round_index: int) -> None:
|
|
357
|
+
"""Prepare a new round: todo reminders, then run the pluggable
|
|
358
|
+
compactor if one is configured on the loop config."""
|
|
359
|
+
# Todo reminder
|
|
360
|
+
if self.rounds_since_todo >= 5 and self.ctx.todo.has_in_progress:
|
|
361
|
+
await self._append_message(
|
|
362
|
+
{"role": "user", "content": "<reminder>You have an in_progress todo. Update your todos.</reminder>"},
|
|
363
|
+
round_index=round_index,
|
|
364
|
+
)
|
|
365
|
+
self._emit(AgentEventType.USER_NOTIFICATION, UserNotificationPayload(message="update your todos"), round_index=round_index)
|
|
366
|
+
self.rounds_since_todo = 0
|
|
367
|
+
|
|
368
|
+
# Microcompact (legacy: dump large tool outputs to disk and replace
|
|
369
|
+
# with a short pointer — orthogonal to LLM-based compaction).
|
|
370
|
+
self.ctx.microcompact(self.history)
|
|
371
|
+
|
|
372
|
+
compactor = self.config.compactor
|
|
373
|
+
if compactor is None:
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
compact_before = CompactBeforeCtx(
|
|
377
|
+
round_index=round_index,
|
|
378
|
+
messages=self.history,
|
|
379
|
+
)
|
|
380
|
+
await self.hooks.run_typed_async(HookPoint.COMPACT_BEFORE, compact_before)
|
|
381
|
+
if compact_before.directive == HookDirective.SKIP:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
plan = await compactor.maybe_compact(
|
|
385
|
+
self.history,
|
|
386
|
+
llm=self.llm,
|
|
387
|
+
max_tokens=int(self.config.max_tokens or 0),
|
|
388
|
+
round_index=round_index,
|
|
389
|
+
)
|
|
390
|
+
if plan is None:
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
self._emit(
|
|
394
|
+
AgentEventType.STATUS_CHANGED,
|
|
395
|
+
AutoCompactStatusPayload(
|
|
396
|
+
phase="started",
|
|
397
|
+
round_index=round_index,
|
|
398
|
+
trigger="compactor_plan_emitted",
|
|
399
|
+
before_tokens=plan.before_tokens,
|
|
400
|
+
after_tokens=plan.after_tokens,
|
|
401
|
+
),
|
|
402
|
+
round_index=round_index,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Apply plan IN-MEMORY first, then persist via the sink.
|
|
406
|
+
note_msg = {"role": "system", "name": "compact_note", "content": plan.summary_text}
|
|
407
|
+
before_len = len(self.history)
|
|
408
|
+
self.history = (
|
|
409
|
+
self.history[: plan.fold_start_idx]
|
|
410
|
+
+ [note_msg]
|
|
411
|
+
+ self.history[plan.fold_end_idx + 1 :]
|
|
412
|
+
)
|
|
413
|
+
# Persist (no-op for NullSink).
|
|
414
|
+
self.sink.on_compaction(
|
|
415
|
+
fold_start_idx=plan.fold_start_idx,
|
|
416
|
+
fold_end_idx=plan.fold_end_idx,
|
|
417
|
+
summary_text=plan.summary_text,
|
|
418
|
+
before_tokens=plan.before_tokens,
|
|
419
|
+
after_tokens=plan.after_tokens,
|
|
420
|
+
round_index=round_index,
|
|
421
|
+
)
|
|
422
|
+
compact_after = CompactAfterCtx(
|
|
423
|
+
round_index=round_index,
|
|
424
|
+
messages=self.history,
|
|
425
|
+
messages_before_count=before_len,
|
|
426
|
+
messages_after_count=len(self.history),
|
|
427
|
+
)
|
|
428
|
+
await self.hooks.run_typed_async(HookPoint.COMPACT_AFTER, compact_after)
|
|
429
|
+
|
|
430
|
+
async def call_llm(
|
|
431
|
+
self,
|
|
432
|
+
round_index: int,
|
|
433
|
+
*,
|
|
434
|
+
messages: list[LoopMessage],
|
|
435
|
+
system_prompt: str,
|
|
436
|
+
tools: list[dict[str, Any]] | None,
|
|
437
|
+
max_tokens: int,
|
|
438
|
+
temperature: float,
|
|
439
|
+
) -> LLMResponse:
|
|
440
|
+
"""Call the LLM and return its response.
|
|
441
|
+
|
|
442
|
+
If ``config.retry_policy`` is set, transient failures retry under
|
|
443
|
+
:func:`with_retry`; exhaustion raises :class:`LLMRetryExhausted` /
|
|
444
|
+
:class:`LLMTimeout`, which :meth:`run` catches and degrades from.
|
|
445
|
+
Cancellation during retry sleep raises :class:`CancellationRequested`.
|
|
446
|
+
"""
|
|
447
|
+
def _on_delta(text: str) -> None:
|
|
448
|
+
if text:
|
|
449
|
+
self._emit(AgentEventType.STREAM_DELTA,
|
|
450
|
+
StreamDeltaPayload(text=text, is_think=False),
|
|
451
|
+
round_index=round_index, stream_id="main")
|
|
452
|
+
|
|
453
|
+
def _on_think(text: str) -> None:
|
|
454
|
+
if text:
|
|
455
|
+
self._emit(AgentEventType.STREAM_THINK_DELTA,
|
|
456
|
+
StreamDeltaPayload(text=text, is_think=True),
|
|
457
|
+
round_index=round_index, stream_id="main")
|
|
458
|
+
|
|
459
|
+
request = LLMRequest(
|
|
460
|
+
messages=messages,
|
|
461
|
+
system_prompt=system_prompt,
|
|
462
|
+
tools=tools,
|
|
463
|
+
tool_choice="auto" if tools else None, # DashScope rejects "auto" with no tools
|
|
464
|
+
max_tokens=max_tokens,
|
|
465
|
+
temperature=temperature,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
async def _do_call() -> LLMResponse:
|
|
469
|
+
# STREAM_STARTED is emitted **per attempt** so subscribers know
|
|
470
|
+
# a fresh stream is beginning; STREAM_COMPLETED only on success
|
|
471
|
+
# of the attempt that returns.
|
|
472
|
+
self._emit(AgentEventType.STREAM_STARTED, StreamStartedPayload(),
|
|
473
|
+
round_index=round_index, stream_id="main")
|
|
474
|
+
return await self.llm.complete(
|
|
475
|
+
request,
|
|
476
|
+
on_chunk_delta_text=_on_delta,
|
|
477
|
+
on_chunk_think=_on_think,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
policy = self.config.retry_policy
|
|
481
|
+
if policy is None:
|
|
482
|
+
response = await _do_call()
|
|
483
|
+
else:
|
|
484
|
+
def _on_retry(attempt: int, exc: BaseException, sleep_s: float) -> None:
|
|
485
|
+
self._emit(
|
|
486
|
+
AgentEventType.LLM_RETRY_ATTEMPTED,
|
|
487
|
+
LlmRetryAttemptedPayload(
|
|
488
|
+
attempt=attempt,
|
|
489
|
+
max_attempts=policy.max_attempts,
|
|
490
|
+
error_type=type(exc).__name__,
|
|
491
|
+
error_message=str(exc)[:500],
|
|
492
|
+
next_sleep_seconds=sleep_s,
|
|
493
|
+
),
|
|
494
|
+
round_index=round_index,
|
|
495
|
+
stream_id="main",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
response = await with_retry(
|
|
499
|
+
_do_call, policy=policy, token=self.cancel_token, on_retry=_on_retry,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
self._emit(AgentEventType.STREAM_COMPLETED, StreamCompletedPayload(),
|
|
503
|
+
round_index=round_index, stream_id="main")
|
|
504
|
+
|
|
505
|
+
return response
|
|
506
|
+
|
|
507
|
+
async def execute_tool(self, tool_name: str, tool_args: dict[str, Any]) -> tuple[str, bool]:
|
|
508
|
+
"""Execute a single tool and return ``(output_string, failed)``.
|
|
509
|
+
|
|
510
|
+
Catches :class:`ToolNotFound` / :class:`ToolValidationError` from
|
|
511
|
+
the registry and returns them as error strings (failed=True), making
|
|
512
|
+
them visible to the LLM so it can self-correct.
|
|
513
|
+
"""
|
|
514
|
+
if self.tool_registry is None:
|
|
515
|
+
return (f"Error: tool '{tool_name}' requested but no tool registry configured", True)
|
|
516
|
+
try:
|
|
517
|
+
validation_err = self.tool_registry.validate(tool_name, tool_args)
|
|
518
|
+
if validation_err is not None:
|
|
519
|
+
return (validation_err, True)
|
|
520
|
+
|
|
521
|
+
result = await self.tool_registry.invoke_async(tool_name, tool_args)
|
|
522
|
+
except (ToolNotFound, ToolValidationError) as exc:
|
|
523
|
+
return (str(exc), True)
|
|
524
|
+
if not isinstance(result, str):
|
|
525
|
+
result = json.dumps(result, ensure_ascii=False)
|
|
526
|
+
return (str(result), False)
|
|
527
|
+
|
|
528
|
+
# ══════════════════════════════════════════════════════════════
|
|
529
|
+
# Main orchestrator — loop, hooks, directive checks, events
|
|
530
|
+
# ══════════════════════════════════════════════════════════════
|
|
531
|
+
|
|
532
|
+
async def run(self, messages: list[LoopMessage]) -> AgentLoopResult:
|
|
533
|
+
"""Run the full agent loop. Returns when done, cancelled, or hit round limit."""
|
|
534
|
+
self.history = [dict(m) for m in messages]
|
|
535
|
+
|
|
536
|
+
# ── Session start ──
|
|
537
|
+
session_ctx = SessionStartCtx(
|
|
538
|
+
scope="main", messages=self.history, stop_event=self.stop_event,
|
|
539
|
+
)
|
|
540
|
+
await self.hooks.run_typed_async(HookPoint.SESSION_START, session_ctx)
|
|
541
|
+
if isinstance(session_ctx.messages, list):
|
|
542
|
+
self.history = session_ctx.messages
|
|
543
|
+
self._emit(AgentEventType.SESSION_STARTED, SessionStartedPayload(scope="main"))
|
|
544
|
+
|
|
545
|
+
# ── Memory recall (M1.9) ──
|
|
546
|
+
await self._maybe_recall()
|
|
547
|
+
|
|
548
|
+
# ── Round loop ──
|
|
549
|
+
for round_idx in range(int(self.config.max_rounds)):
|
|
550
|
+
# Track for MemorySnapshot: how many round attempts we made.
|
|
551
|
+
self._completed_rounds = round_idx
|
|
552
|
+
if _is_cancelled(self.cancel_token):
|
|
553
|
+
await self._finalize("cancelled")
|
|
554
|
+
return self._make_result("cancelled", final_text="[cancelled by user]", rounds=round_idx)
|
|
555
|
+
|
|
556
|
+
# ── Hook: ROUND_START ──
|
|
557
|
+
round_ctx = RoundStartCtx(
|
|
558
|
+
round_index=round_idx, messages=self.history,
|
|
559
|
+
stop_event=self.stop_event,
|
|
560
|
+
)
|
|
561
|
+
await self.hooks.run_typed_async(HookPoint.ROUND_START, round_ctx)
|
|
562
|
+
if round_ctx.directive == HookDirective.BREAK:
|
|
563
|
+
await self._finalize(round_ctx.reason or "hook_break")
|
|
564
|
+
return self._make_result("completed", rounds=round_idx)
|
|
565
|
+
if round_ctx.directive == HookDirective.SKIP:
|
|
566
|
+
continue
|
|
567
|
+
|
|
568
|
+
# Apply hook-modified messages
|
|
569
|
+
if isinstance(round_ctx.messages, list):
|
|
570
|
+
self.history = round_ctx.messages
|
|
571
|
+
|
|
572
|
+
# ── Business logic: prepare round ──
|
|
573
|
+
await self.prepare_round(round_idx)
|
|
574
|
+
|
|
575
|
+
self.sink.on_round_started(round_idx)
|
|
576
|
+
self._emit(AgentEventType.ROUND_STARTED, RoundStartedPayload(round_index=round_idx), round_index=round_idx)
|
|
577
|
+
|
|
578
|
+
# Todo snapshot injection
|
|
579
|
+
todo_snap = self.ctx.todo.snapshot_for_prompt()
|
|
580
|
+
if todo_snap:
|
|
581
|
+
await self._append_message({"role": "user", "content": todo_snap}, round_index=round_idx)
|
|
582
|
+
|
|
583
|
+
# ── Hook: LLM_BEFORE ──
|
|
584
|
+
llm_before = LlmBeforeCtx(
|
|
585
|
+
round_index=round_idx,
|
|
586
|
+
messages=self.history,
|
|
587
|
+
system_prompt=self.system_prompt,
|
|
588
|
+
tools=self.runtime_tools,
|
|
589
|
+
max_tokens=int(self.config.max_tokens or 8000),
|
|
590
|
+
temperature=float(self.config.temperature or 0),
|
|
591
|
+
)
|
|
592
|
+
await self.hooks.run_typed_async(HookPoint.LLM_BEFORE, llm_before)
|
|
593
|
+
|
|
594
|
+
if llm_before.directive == HookDirective.SHORT_CIRCUIT:
|
|
595
|
+
response = llm_before.output
|
|
596
|
+
if not isinstance(response, LLMResponse):
|
|
597
|
+
raise ValueError("LLM_BEFORE SHORT_CIRCUIT but no valid LLMResponse")
|
|
598
|
+
elif llm_before.directive == HookDirective.BREAK:
|
|
599
|
+
await self._finalize("hook_break")
|
|
600
|
+
return self._make_result("completed", rounds=round_idx)
|
|
601
|
+
else:
|
|
602
|
+
# ── Business logic: call LLM (with retry/timeout/cancel) ──
|
|
603
|
+
try:
|
|
604
|
+
response = await self.call_llm(
|
|
605
|
+
round_idx,
|
|
606
|
+
messages=llm_before.messages,
|
|
607
|
+
system_prompt=llm_before.system_prompt,
|
|
608
|
+
tools=llm_before.tools,
|
|
609
|
+
max_tokens=llm_before.max_tokens,
|
|
610
|
+
temperature=llm_before.temperature,
|
|
611
|
+
)
|
|
612
|
+
except CancellationRequested as exc:
|
|
613
|
+
self._emit(
|
|
614
|
+
AgentEventType.LOOP_CANCELLED,
|
|
615
|
+
LoopCancelledPayload(reason=exc.reason, round_index=round_idx),
|
|
616
|
+
round_index=round_idx,
|
|
617
|
+
)
|
|
618
|
+
await self._finalize("cancelled")
|
|
619
|
+
return self._make_result(
|
|
620
|
+
"cancelled", final_text=f"[cancelled: {exc.reason}]", rounds=round_idx,
|
|
621
|
+
)
|
|
622
|
+
except (LLMRetryExhausted, LLMTimeout) as exc:
|
|
623
|
+
reason = "timeout" if isinstance(exc, LLMTimeout) else "retry_exhausted"
|
|
624
|
+
inner = getattr(exc, "last_error", exc)
|
|
625
|
+
self._emit(
|
|
626
|
+
AgentEventType.LLM_DEGRADED,
|
|
627
|
+
LlmDegradedPayload(
|
|
628
|
+
reason=reason,
|
|
629
|
+
attempts=getattr(exc, "attempts", 0),
|
|
630
|
+
error_type=type(inner).__name__,
|
|
631
|
+
error_message=str(inner)[:500],
|
|
632
|
+
),
|
|
633
|
+
round_index=round_idx,
|
|
634
|
+
)
|
|
635
|
+
msg = f"[degraded: LLM {reason} — {type(inner).__name__}: {str(inner)[:200]}]"
|
|
636
|
+
await self._append_message({"role": "assistant", "content": msg}, round_index=round_idx)
|
|
637
|
+
await self._finalize("degraded", final_text=msg, rounds=round_idx + 1)
|
|
638
|
+
return self._make_result("degraded", final_text=msg, rounds=round_idx + 1)
|
|
639
|
+
|
|
640
|
+
# ── Hook: LLM_AFTER ──
|
|
641
|
+
llm_after = LlmAfterCtx(
|
|
642
|
+
round_index=round_idx,
|
|
643
|
+
output=response,
|
|
644
|
+
messages=self.history,
|
|
645
|
+
)
|
|
646
|
+
await self.hooks.run_typed_async(HookPoint.LLM_AFTER, llm_after)
|
|
647
|
+
if llm_after.directive == HookDirective.BREAK:
|
|
648
|
+
text = (getattr(response, "raw_text", "") or "").strip()
|
|
649
|
+
await self._append_message({"role": "assistant", "content": text}, round_index=round_idx)
|
|
650
|
+
await self._finalize("hook_break", final_text=text, rounds=round_idx + 1)
|
|
651
|
+
return self._make_result("completed", final_text=text, rounds=round_idx + 1)
|
|
652
|
+
# After hook may replace the response
|
|
653
|
+
if isinstance(llm_after.output, LLMResponse):
|
|
654
|
+
response = llm_after.output
|
|
655
|
+
|
|
656
|
+
# ── Post-LLM processing ──
|
|
657
|
+
usage = self.ctx.update_usage(response)
|
|
658
|
+
self._emit(AgentEventType.STATUS_CHANGED, _round_usage_payload(
|
|
659
|
+
round_index=round_idx, max_rounds=int(self.config.max_rounds), usage=usage,
|
|
660
|
+
), round_index=round_idx)
|
|
661
|
+
self._emit(AgentEventType.USAGE_UPDATED, UsageUpdatedPayload(usage=usage), round_index=round_idx)
|
|
662
|
+
|
|
663
|
+
assistant_text = (getattr(response, "raw_text", "") or getattr(response, "content_text", "") or "").strip()
|
|
664
|
+
tool_calls = response.get_tool_calls()
|
|
665
|
+
self._emit(AgentEventType.ROUND_TOOLS_PRESENT, RoundToolsPresentPayload(has_tools=bool(tool_calls)), round_index=round_idx)
|
|
666
|
+
|
|
667
|
+
# Append assistant message
|
|
668
|
+
assistant_msg: dict[str, Any] = {"role": "assistant", "content": assistant_text}
|
|
669
|
+
sanitized_tool_calls: list[dict[str, Any]] | None = None
|
|
670
|
+
if tool_calls:
|
|
671
|
+
sanitized_tool_calls = _sanitize_tool_calls(tool_calls)
|
|
672
|
+
assistant_msg["tool_calls"] = sanitized_tool_calls
|
|
673
|
+
await self._append_message(assistant_msg, round_index=round_idx)
|
|
674
|
+
# Mark pending IMMEDIATELY so a crash here leaves a recoverable state.
|
|
675
|
+
if sanitized_tool_calls:
|
|
676
|
+
assistant_seq = len(self.history) # 1-based position in history
|
|
677
|
+
self.sink.on_assistant_tool_calls(
|
|
678
|
+
assistant_seq=assistant_seq,
|
|
679
|
+
tool_calls=sanitized_tool_calls,
|
|
680
|
+
round_index=round_idx,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# Remove todo snapshot
|
|
684
|
+
if todo_snap:
|
|
685
|
+
idx = len(self.history) - 2
|
|
686
|
+
if idx >= 0 and self.history[idx].get("content") == todo_snap:
|
|
687
|
+
self.history.pop(idx)
|
|
688
|
+
|
|
689
|
+
# ── No tools → completed ──
|
|
690
|
+
if not tool_calls:
|
|
691
|
+
self._emit(AgentEventType.ROUND_COMPLETED,
|
|
692
|
+
RoundCompletedPayload(round_index=round_idx, has_tools=False), round_index=round_idx)
|
|
693
|
+
round_end = RoundEndCtx(
|
|
694
|
+
round_index=round_idx, messages=self.history,
|
|
695
|
+
has_tools=False, response_text=assistant_text,
|
|
696
|
+
)
|
|
697
|
+
await self.hooks.run_typed_async(HookPoint.ROUND_END, round_end)
|
|
698
|
+
self.sink.on_round_ended(round_idx, usage=usage)
|
|
699
|
+
await self._finalize("completed", final_text=assistant_text, rounds=round_idx + 1)
|
|
700
|
+
return self._make_result("completed", final_text=assistant_text, rounds=round_idx + 1)
|
|
701
|
+
|
|
702
|
+
# ── Hook: ROUND_DECIDE ──
|
|
703
|
+
decide_ctx = RoundDecideCtx(
|
|
704
|
+
round_index=round_idx, messages=self.history,
|
|
705
|
+
tool_calls=tool_calls, assistant_text=assistant_text,
|
|
706
|
+
)
|
|
707
|
+
await self.hooks.run_typed_async(HookPoint.ROUND_DECIDE, decide_ctx)
|
|
708
|
+
if decide_ctx.directive == HookDirective.BREAK:
|
|
709
|
+
await self._finalize("hook_break", final_text=assistant_text, rounds=round_idx + 1)
|
|
710
|
+
return self._make_result("completed", final_text=assistant_text, rounds=round_idx + 1)
|
|
711
|
+
if decide_ctx.directive == HookDirective.SKIP:
|
|
712
|
+
for tc in tool_calls:
|
|
713
|
+
cid = str(tc.get("id") or "")
|
|
714
|
+
tname = _tool_call_name(tc)
|
|
715
|
+
await self._append_message(
|
|
716
|
+
{"role": "tool", "tool_call_id": cid, "name": tname, "content": decide_ctx.output},
|
|
717
|
+
round_index=round_idx,
|
|
718
|
+
)
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
if self.tool_registry is None:
|
|
722
|
+
return self._make_result("pending_tools", final_text=assistant_text,
|
|
723
|
+
rounds=round_idx + 1, pending_tool_calls=tool_calls)
|
|
724
|
+
|
|
725
|
+
# ── Hook: TOOLS_BATCH_BEFORE ──
|
|
726
|
+
batch_ctx = ToolsBatchBeforeCtx(
|
|
727
|
+
round_index=round_idx,
|
|
728
|
+
messages=self.history,
|
|
729
|
+
tool_calls=tool_calls,
|
|
730
|
+
)
|
|
731
|
+
await self.hooks.run_typed_async(HookPoint.TOOLS_BATCH_BEFORE, batch_ctx)
|
|
732
|
+
skip_batch = batch_ctx.directive == HookDirective.SKIP
|
|
733
|
+
|
|
734
|
+
# ── Execute tools ──
|
|
735
|
+
used_todo = False
|
|
736
|
+
for tool_call in tool_calls:
|
|
737
|
+
if _is_cancelled(self.cancel_token):
|
|
738
|
+
await self._finalize("cancelled")
|
|
739
|
+
return self._make_result("cancelled", final_text="[cancelled by user]", rounds=round_idx + 1)
|
|
740
|
+
|
|
741
|
+
call_id = str(tool_call.get("id") or "")
|
|
742
|
+
tool_name = _tool_call_name(tool_call)
|
|
743
|
+
tool_args = _tool_call_args(tool_call)
|
|
744
|
+
|
|
745
|
+
# Batch skip
|
|
746
|
+
if skip_batch:
|
|
747
|
+
await self._append_message(
|
|
748
|
+
{"role": "tool", "tool_call_id": call_id, "name": tool_name, "content": batch_ctx.output},
|
|
749
|
+
round_index=round_idx,
|
|
750
|
+
)
|
|
751
|
+
continue
|
|
752
|
+
|
|
753
|
+
# ── Hook: TOOL_BEFORE ──
|
|
754
|
+
tb_ctx = ToolBeforeCtx(
|
|
755
|
+
round_index=round_idx,
|
|
756
|
+
tool_call=tool_call,
|
|
757
|
+
tool_name=tool_name,
|
|
758
|
+
tool_args=tool_args,
|
|
759
|
+
)
|
|
760
|
+
await self.hooks.run_typed_async(HookPoint.TOOL_BEFORE, tb_ctx)
|
|
761
|
+
tool_name = tb_ctx.tool_name
|
|
762
|
+
tool_args = tb_ctx.tool_args
|
|
763
|
+
|
|
764
|
+
if tb_ctx.directive == HookDirective.SKIP:
|
|
765
|
+
await self._append_message(
|
|
766
|
+
{"role": "tool", "tool_call_id": call_id, "name": tool_name, "content": tb_ctx.output},
|
|
767
|
+
round_index=round_idx,
|
|
768
|
+
)
|
|
769
|
+
continue
|
|
770
|
+
|
|
771
|
+
self._emit(AgentEventType.TOOL_CALL_STARTED,
|
|
772
|
+
ToolCallStartedPayload(name=tool_name, tool_input=tool_args, tool_call_id=call_id),
|
|
773
|
+
round_index=round_idx)
|
|
774
|
+
|
|
775
|
+
# ── Business logic: execute tool ──
|
|
776
|
+
failed = False
|
|
777
|
+
try:
|
|
778
|
+
output, failed = await self.execute_tool(tool_name, tool_args)
|
|
779
|
+
except Exception as exc:
|
|
780
|
+
# ── Hook: TOOL_ERROR ──
|
|
781
|
+
err_ctx = ToolErrorCtx(
|
|
782
|
+
round_index=round_idx,
|
|
783
|
+
tool_call=tool_call,
|
|
784
|
+
tool_name=tool_name,
|
|
785
|
+
tool_args=tool_args,
|
|
786
|
+
error=exc,
|
|
787
|
+
error_message=str(exc),
|
|
788
|
+
)
|
|
789
|
+
await self.hooks.run_typed_async(HookPoint.TOOL_ERROR, err_ctx)
|
|
790
|
+
if err_ctx.directive == HookDirective.SKIP:
|
|
791
|
+
output = err_ctx.output or f"Error: {exc}"
|
|
792
|
+
elif err_ctx.directive == HookDirective.SHORT_CIRCUIT:
|
|
793
|
+
try:
|
|
794
|
+
output, failed = await self.execute_tool(tool_name, tool_args)
|
|
795
|
+
except Exception as retry_exc:
|
|
796
|
+
output = f"Error (retry failed): {retry_exc}"
|
|
797
|
+
failed = True
|
|
798
|
+
else:
|
|
799
|
+
output = f"Error: {exc}"
|
|
800
|
+
failed = True
|
|
801
|
+
|
|
802
|
+
# ── Hook: TOOL_AFTER ──
|
|
803
|
+
ta_ctx = ToolAfterCtx(
|
|
804
|
+
round_index=round_idx,
|
|
805
|
+
tool_call=tool_call,
|
|
806
|
+
tool_name=tool_name,
|
|
807
|
+
tool_args=tool_args,
|
|
808
|
+
output=output,
|
|
809
|
+
failed=failed,
|
|
810
|
+
)
|
|
811
|
+
await self.hooks.run_typed_async(HookPoint.TOOL_AFTER, ta_ctx)
|
|
812
|
+
output = ta_ctx.output
|
|
813
|
+
failed = ta_ctx.failed
|
|
814
|
+
|
|
815
|
+
if tool_name == "todo":
|
|
816
|
+
used_todo = True
|
|
817
|
+
self.rounds_since_todo = 0
|
|
818
|
+
|
|
819
|
+
if failed:
|
|
820
|
+
self._emit(AgentEventType.TOOL_CALL_FAILED,
|
|
821
|
+
ToolCallFailedPayload(name=tool_name, output=output, tool_input=tool_args, tool_call_id=call_id),
|
|
822
|
+
round_index=round_idx)
|
|
823
|
+
|
|
824
|
+
self._emit(AgentEventType.TOOL_CALL_COMPLETED,
|
|
825
|
+
ToolCallCompletedPayload(name=tool_name, output=output, tool_input=tool_args, tool_call_id=call_id),
|
|
826
|
+
round_index=round_idx)
|
|
827
|
+
|
|
828
|
+
await self._append_message(
|
|
829
|
+
{"role": "tool", "tool_call_id": call_id, "name": tool_name, "content": _truncate_result(output)},
|
|
830
|
+
round_index=round_idx,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# TOOL_AFTER BREAK → stop remaining tools
|
|
834
|
+
if ta_ctx.directive == HookDirective.BREAK:
|
|
835
|
+
break
|
|
836
|
+
|
|
837
|
+
# ── Hook: TOOLS_BATCH_AFTER ──
|
|
838
|
+
batch_after_ctx = ToolsBatchAfterCtx(
|
|
839
|
+
round_index=round_idx,
|
|
840
|
+
messages=self.history,
|
|
841
|
+
used_todo=used_todo,
|
|
842
|
+
)
|
|
843
|
+
await self.hooks.run_typed_async(HookPoint.TOOLS_BATCH_AFTER, batch_after_ctx)
|
|
844
|
+
|
|
845
|
+
self._emit(AgentEventType.ROUND_COMPLETED,
|
|
846
|
+
RoundCompletedPayload(round_index=round_idx, has_tools=True, used_todo=used_todo),
|
|
847
|
+
round_index=round_idx)
|
|
848
|
+
round_end = RoundEndCtx(
|
|
849
|
+
round_index=round_idx,
|
|
850
|
+
messages=self.history,
|
|
851
|
+
has_tools=True,
|
|
852
|
+
used_todo=used_todo,
|
|
853
|
+
)
|
|
854
|
+
await self.hooks.run_typed_async(HookPoint.ROUND_END, round_end)
|
|
855
|
+
|
|
856
|
+
if not used_todo:
|
|
857
|
+
self.rounds_since_todo += 1
|
|
858
|
+
|
|
859
|
+
# ── Hit max rounds ──
|
|
860
|
+
await self._append_message({
|
|
861
|
+
"role": "user",
|
|
862
|
+
"content": f"You have reached the maximum of {self.config.max_rounds} rounds. "
|
|
863
|
+
f"Summarize what you accomplished and what remains.",
|
|
864
|
+
})
|
|
865
|
+
self._emit(AgentEventType.STATUS_CHANGED, HitRoundLimitStatusPayload(max_rounds=int(self.config.max_rounds)))
|
|
866
|
+
|
|
867
|
+
final_resp = await self.llm.complete(LLMRequest(
|
|
868
|
+
messages=self.history,
|
|
869
|
+
system_prompt=self.system_prompt,
|
|
870
|
+
tools=self.runtime_tools,
|
|
871
|
+
tool_choice="auto" if self.runtime_tools else None,
|
|
872
|
+
max_tokens=int(self.config.max_tokens or 8000),
|
|
873
|
+
temperature=float(self.config.temperature or 0),
|
|
874
|
+
))
|
|
875
|
+
final_text = (getattr(final_resp, "raw_text", "") or getattr(final_resp, "content_text", "") or "").strip()
|
|
876
|
+
self._emit(AgentEventType.USAGE_UPDATED, UsageUpdatedPayload(usage=self.ctx.update_usage(final_resp)))
|
|
877
|
+
await self._finalize("hit_round_limit", final_text=f"[hit_round_limit]\n{final_text}",
|
|
878
|
+
rounds=int(self.config.max_rounds))
|
|
879
|
+
return self._make_result("hit_round_limit", final_text=f"[hit_round_limit]\n{final_text}",
|
|
880
|
+
rounds=int(self.config.max_rounds))
|