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,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))