power-loop 0.8.0__tar.gz → 0.9.0__tar.gz

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 (64) hide show
  1. {power_loop-0.8.0/power_loop.egg-info → power_loop-0.9.0}/PKG-INFO +38 -1
  2. {power_loop-0.8.0 → power_loop-0.9.0}/README.md +37 -0
  3. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/__init__.py +1 -1
  4. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/agent/stateful_loop.py +33 -3
  5. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/agent/types.py +4 -0
  6. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/errors.py +4 -1
  7. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/core/pipeline.py +1 -0
  8. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/core/state.py +12 -0
  9. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/notes.py +6 -1
  10. {power_loop-0.8.0 → power_loop-0.9.0/power_loop.egg-info}/PKG-INFO +38 -1
  11. {power_loop-0.8.0 → power_loop-0.9.0}/LICENSE +0 -0
  12. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/__init__.py +0 -0
  13. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/anthropic_factory.py +0 -0
  14. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/capabilities.py +0 -0
  15. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/interface.py +0 -0
  16. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/llm_factory.py +0 -0
  17. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/llm_tooling.py +0 -0
  18. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/llm_utils.py +0 -0
  19. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/multimodal.py +0 -0
  20. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/qwen_image.py +0 -0
  21. {power_loop-0.8.0 → power_loop-0.9.0}/llm_client/web_search.py +0 -0
  22. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/agent/__init__.py +0 -0
  23. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/agent/follow_up.py +0 -0
  24. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/agent/sink.py +0 -0
  25. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/agent/system_prompt.py +0 -0
  26. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/__init__.py +0 -0
  27. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/event_payloads.py +0 -0
  28. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/events.py +0 -0
  29. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/handlers.py +0 -0
  30. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/hook_contexts.py +0 -0
  31. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/hooks.py +0 -0
  32. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/messages.py +0 -0
  33. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/protocols.py +0 -0
  34. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/contracts/tools.py +0 -0
  35. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/core/agent_context.py +0 -0
  36. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/core/events.py +0 -0
  37. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/core/hooks.py +0 -0
  38. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/core/phase.py +0 -0
  39. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/core/runner.py +0 -0
  40. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/budget.py +0 -0
  41. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/cancellation.py +0 -0
  42. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/compact.py +0 -0
  43. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/env.py +0 -0
  44. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/exec_backend.py +0 -0
  45. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/human_input.py +0 -0
  46. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/memory.py +0 -0
  47. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/provider.py +0 -0
  48. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/retry.py +0 -0
  49. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/runtime_state.py +0 -0
  50. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/session_store.py +0 -0
  51. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/skills.py +0 -0
  52. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/spec.py +0 -0
  53. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/runtime/structured.py +0 -0
  54. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/tools/__init__.py +0 -0
  55. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/tools/default_manifest.py +0 -0
  56. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/tools/default_tools.py +0 -0
  57. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/tools/registry.py +0 -0
  58. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop/tools/spawn_agent.py +0 -0
  59. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop.egg-info/SOURCES.txt +0 -0
  60. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop.egg-info/dependency_links.txt +0 -0
  61. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop.egg-info/requires.txt +0 -0
  62. {power_loop-0.8.0 → power_loop-0.9.0}/power_loop.egg-info/top_level.txt +0 -0
  63. {power_loop-0.8.0 → power_loop-0.9.0}/pyproject.toml +0 -0
  64. {power_loop-0.8.0 → power_loop-0.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -55,6 +55,11 @@ agent runs model-authored or otherwise untrusted commands, run them in your own
55
55
  (`runtime.exec_backend`); power-loop launches the persistent shell through your backend.
56
56
  Keep secrets in your orchestrator — the loop does not scrub the tool environment for you.
57
57
 
58
+ Likewise there is **no built-in scheduler/timer**: a session only runs while a `send()` /
59
+ `resume()` call is in flight. "Wake this agent again in 10 minutes" is orchestrator state —
60
+ keep it in your own durable store and call `send()` when it fires (that survives restarts;
61
+ an in-process timer would not).
62
+
58
63
  ## Install
59
64
 
60
65
  ```bash
@@ -156,6 +161,38 @@ with runtime_env_context(RuntimeEnv(workspace_dir=tenant_workspace)):
156
161
 
157
162
  See [`examples/23_per_send_overrides.py`](examples/23_per_send_overrides.py).
158
163
 
164
+ ### Token usage accounting
165
+
166
+ Every `send()` returns the run's cumulative token usage — summed over all LLM
167
+ calls of that run (tool loops make several) — so cost accounting needs no event
168
+ plumbing:
169
+
170
+ ```python
171
+ res = await loop.send("…", session_id=sid)
172
+ res.usage
173
+ # {"prompt_tokens": 1234, "completion_tokens": 56, "cache_read_tokens": 0,
174
+ # "reasoning_tokens": 0, "total_tokens": 1290, "calls": 2}
175
+ ```
176
+
177
+ For per-call, real-time metering subscribe to the `usage_updated` event (one
178
+ per LLM call, tagged with `session_id`). See
179
+ [`examples/25_token_usage.py`](examples/25_token_usage.py).
180
+
181
+ ### Crash recovery: `heal_pending`
182
+
183
+ A run killed mid tool-call leaves the session with unresolved `tool_calls`;
184
+ the next `send()` raises `SessionPendingError` (the message protocol forbids
185
+ continuing). Orchestrators whose runs can legitimately die (human interrupts,
186
+ process restarts) can opt into self-healing:
187
+
188
+ ```python
189
+ res = await loop.send("…", session_id=sid, heal_pending=True)
190
+ # stale tool_calls are aborted with synthetic <aborted> results, then the
191
+ # send proceeds. Default remains raise — healing discards in-flight work.
192
+ ```
193
+
194
+ Or recover explicitly with `resume(sid)` / `abort_pending(sid)`.
195
+
159
196
  ## Public API
160
197
 
161
198
  Stable imports are re-exported from `power_loop`:
@@ -18,6 +18,11 @@ agent runs model-authored or otherwise untrusted commands, run them in your own
18
18
  (`runtime.exec_backend`); power-loop launches the persistent shell through your backend.
19
19
  Keep secrets in your orchestrator — the loop does not scrub the tool environment for you.
20
20
 
21
+ Likewise there is **no built-in scheduler/timer**: a session only runs while a `send()` /
22
+ `resume()` call is in flight. "Wake this agent again in 10 minutes" is orchestrator state —
23
+ keep it in your own durable store and call `send()` when it fires (that survives restarts;
24
+ an in-process timer would not).
25
+
21
26
  ## Install
22
27
 
23
28
  ```bash
@@ -119,6 +124,38 @@ with runtime_env_context(RuntimeEnv(workspace_dir=tenant_workspace)):
119
124
 
120
125
  See [`examples/23_per_send_overrides.py`](examples/23_per_send_overrides.py).
121
126
 
127
+ ### Token usage accounting
128
+
129
+ Every `send()` returns the run's cumulative token usage — summed over all LLM
130
+ calls of that run (tool loops make several) — so cost accounting needs no event
131
+ plumbing:
132
+
133
+ ```python
134
+ res = await loop.send("…", session_id=sid)
135
+ res.usage
136
+ # {"prompt_tokens": 1234, "completion_tokens": 56, "cache_read_tokens": 0,
137
+ # "reasoning_tokens": 0, "total_tokens": 1290, "calls": 2}
138
+ ```
139
+
140
+ For per-call, real-time metering subscribe to the `usage_updated` event (one
141
+ per LLM call, tagged with `session_id`). See
142
+ [`examples/25_token_usage.py`](examples/25_token_usage.py).
143
+
144
+ ### Crash recovery: `heal_pending`
145
+
146
+ A run killed mid tool-call leaves the session with unresolved `tool_calls`;
147
+ the next `send()` raises `SessionPendingError` (the message protocol forbids
148
+ continuing). Orchestrators whose runs can legitimately die (human interrupts,
149
+ process restarts) can opt into self-healing:
150
+
151
+ ```python
152
+ res = await loop.send("…", session_id=sid, heal_pending=True)
153
+ # stale tool_calls are aborted with synthetic <aborted> results, then the
154
+ # send proceeds. Default remains raise — healing discards in-flight work.
155
+ ```
156
+
157
+ Or recover explicitly with `resume(sid)` / `abort_pending(sid)`.
158
+
122
159
  ## Public API
123
160
 
124
161
  Stable imports are re-exported from `power_loop`:
@@ -22,7 +22,7 @@ Stability tiers
22
22
  无版本承诺,可随时变更或删除。
23
23
  """
24
24
 
25
- __version__ = "0.8.0"
25
+ __version__ = "0.9.0"
26
26
 
27
27
  from power_loop.agent.follow_up import FollowUpQueued
28
28
  from power_loop.agent.sink import MessageSink, NullSink, SQLiteSink
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import asyncio
22
22
  import json
23
+ import logging
23
24
  from collections.abc import Sequence
24
25
  from dataclasses import dataclass, field, replace
25
26
  from typing import Any
@@ -56,6 +57,8 @@ from power_loop.runtime.session_store import (
56
57
  from power_loop.runtime.skills import SkillLoader
57
58
  from power_loop.tools.registry import ToolRegistry
58
59
 
60
+ logger = logging.getLogger("power_loop.stateful")
61
+
59
62
 
60
63
  @dataclass
61
64
  class StatefulResult:
@@ -67,6 +70,10 @@ class StatefulResult:
67
70
  rounds: int = 0
68
71
  pending_tool_calls: list[dict[str, Any]] = field(default_factory=list)
69
72
  pending_interactions: list[dict[str, Any]] = field(default_factory=list)
73
+ #: Cumulative token usage for this send (summed over every LLM call):
74
+ #: {prompt_tokens, completion_tokens, cache_read_tokens, reasoning_tokens,
75
+ #: total_tokens, calls}. Empty when the run never reached the LLM.
76
+ usage: dict[str, int] = field(default_factory=dict)
70
77
 
71
78
 
72
79
  class StatefulAgentLoop:
@@ -134,6 +141,7 @@ class StatefulAgentLoop:
134
141
  stop_event: CancellationLike = None,
135
142
  tools: Sequence[str] | ToolRegistry | None = None,
136
143
  system_prompt: str | None = None,
144
+ heal_pending: bool = False,
137
145
  ) -> StatefulResult:
138
146
  """Append one user input to the session and run the loop.
139
147
 
@@ -149,13 +157,24 @@ class StatefulAgentLoop:
149
157
  (precedence: this arg > session system_prompt > config).
150
158
 
151
159
  Raises :class:`SessionPendingError` if the session has unresolved
152
- tool_calls; the caller must call :meth:`resume` or
153
- :meth:`abort_pending` first.
160
+ tool_calls (a previous run died mid tool-call); the caller must call
161
+ :meth:`resume` or :meth:`abort_pending` first — or pass
162
+ ``heal_pending=True`` to have ``send`` abort the stale tool_calls
163
+ itself and proceed (the right default for orchestrators whose runs
164
+ can be killed, e.g. by a human interrupt).
154
165
  """
155
166
  sid = session_id
156
167
  async with self._lock_for(sid):
157
168
  self._ensure_session_or_raise(sid)
158
- self._raise_if_pending(sid)
169
+ if heal_pending:
170
+ healed = self._heal_pending(sid)
171
+ if healed:
172
+ logger.warning(
173
+ "send(heal_pending=True): aborted %d stale tool_call(s) "
174
+ "in session %s before proceeding", healed, sid,
175
+ )
176
+ else:
177
+ self._raise_if_pending(sid)
159
178
  self._persist_user_input(sid, user_input)
160
179
  return await self._run_loop(
161
180
  sid, stop_event=stop_event, tools=tools, system_prompt=system_prompt
@@ -217,6 +236,7 @@ class StatefulAgentLoop:
217
236
  stop_event: CancellationLike = None,
218
237
  tools: Sequence[str] | ToolRegistry | None = None,
219
238
  system_prompt: str | None = None,
239
+ heal_pending: bool = False,
220
240
  ) -> StatefulResult:
221
241
  return asyncio.run(
222
242
  self.send(
@@ -225,6 +245,7 @@ class StatefulAgentLoop:
225
245
  stop_event=stop_event,
226
246
  tools=tools,
227
247
  system_prompt=system_prompt,
248
+ heal_pending=heal_pending,
228
249
  )
229
250
  )
230
251
 
@@ -301,6 +322,14 @@ class StatefulAgentLoop:
301
322
 
302
323
  return await self._run_loop(session_id, stop_event=stop_event, sink=sink)
303
324
 
325
+ def _heal_pending(self, sid: str) -> int:
326
+ """abort_pending without the session-existence re-check (callers in
327
+ send already hold the lock and have validated the session)."""
328
+ state = self.store.get_state(sid)
329
+ if state is None or not state.pending:
330
+ return 0
331
+ return self.abort_pending(sid, reason="auto-healed by send(heal_pending=True)")
332
+
304
333
  def abort_pending(self, session_id: str, *, reason: str = "aborted") -> int:
305
334
  """Synthesize ``<aborted: reason>`` tool messages for every unresolved
306
335
  tool_call, restoring message-protocol validity. Returns the number of
@@ -593,6 +622,7 @@ class StatefulAgentLoop:
593
622
  rounds=result.rounds,
594
623
  pending_tool_calls=result.pending_tool_calls,
595
624
  pending_interactions=result.pending_interactions,
625
+ usage=result.usage,
596
626
  )
597
627
 
598
628
  def _prime_sink_from_pending(self, sid: str, sink: SQLiteSink) -> None:
@@ -66,3 +66,7 @@ class AgentLoopResult:
66
66
  pending_tool_calls: list[dict[str, Any]] = field(default_factory=list)
67
67
  pending_interactions: list[dict[str, Any]] = field(default_factory=list)
68
68
  messages: list[LoopMessage] = field(default_factory=list)
69
+ #: Cumulative token usage across every LLM call of this run:
70
+ #: {prompt_tokens, completion_tokens, cache_read_tokens, reasoning_tokens,
71
+ #: total_tokens, calls}. Empty dict when the run never reached the LLM.
72
+ usage: dict[str, int] = field(default_factory=dict)
@@ -52,7 +52,10 @@ class SessionPendingError(PowerLoopError):
52
52
  )
53
53
  super().__init__(
54
54
  f"session {session_id} has {len(pending_tool_calls)} unresolved tool_calls"
55
- f" from round (assistant_seq={assistant_seq}): {names}"
55
+ f" from round (assistant_seq={assistant_seq}): {names}."
56
+ " Recover with resume(sid) to finish them, abort_pending(sid) to"
57
+ " discard them, or call send(..., heal_pending=True) to auto-abort"
58
+ " and proceed"
56
59
  )
57
60
 
58
61
 
@@ -389,6 +389,7 @@ class AgentPipeline:
389
389
  pending_tool_calls=pending_tool_calls or [],
390
390
  pending_interactions=pending_interactions or [],
391
391
  messages=self.history,
392
+ usage=dict(self.ctx.usage_totals),
392
393
  )
393
394
 
394
395
  def _persist_pending_interaction(
@@ -94,7 +94,14 @@ class ContextManager:
94
94
  recent_files: list[str] = field(default_factory=list)
95
95
  _file_counter: int = 0
96
96
 
97
+ #: Usage of the **last** LLM call only (overwritten by every
98
+ #: :meth:`update_usage`). For whole-run totals use :attr:`usage_totals`.
97
99
  token_usage: dict[str, Any] = field(default_factory=dict)
100
+ #: Cumulative usage across every LLM call of this context's lifetime
101
+ #: (one context = one ``send``/run): prompt_tokens / completion_tokens /
102
+ #: cache_read_tokens / reasoning_tokens / total_tokens / calls.
103
+ #: Surfaced on ``AgentLoopResult.usage`` and ``StatefulResult.usage``.
104
+ usage_totals: dict[str, int] = field(default_factory=dict)
98
105
  #: 可选计数器,供测试/扩展使用;**不会**被 ``update_usage`` 自动递增
99
106
  api_calls: int = 0
100
107
 
@@ -165,6 +172,11 @@ class ContextManager:
165
172
  "reasoning": reasoning,
166
173
  }
167
174
  self.token_usage = usage_out
175
+ totals = self.usage_totals
176
+ totals["calls"] = totals.get("calls", 0) + 1
177
+ for key in ("prompt_tokens", "completion_tokens", "cache_read_tokens",
178
+ "reasoning_tokens", "total_tokens"):
179
+ totals[key] = totals.get(key, 0) + usage_out[key]
168
180
  return usage_out
169
181
 
170
182
  def microcompact(self, messages: list[dict[str, Any]]) -> None:
@@ -190,5 +190,10 @@ class SQLiteNoteMemory:
190
190
  return []
191
191
  return [{"role": "system", "name": "memory_notes", "content": text}]
192
192
 
193
- async def remember(self, snapshot: Any) -> None: # noqa: ARG002
193
+ async def remember(
194
+ self,
195
+ *,
196
+ snapshot: Any,
197
+ session_id: str | None = None,
198
+ ) -> None:
194
199
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -55,6 +55,11 @@ agent runs model-authored or otherwise untrusted commands, run them in your own
55
55
  (`runtime.exec_backend`); power-loop launches the persistent shell through your backend.
56
56
  Keep secrets in your orchestrator — the loop does not scrub the tool environment for you.
57
57
 
58
+ Likewise there is **no built-in scheduler/timer**: a session only runs while a `send()` /
59
+ `resume()` call is in flight. "Wake this agent again in 10 minutes" is orchestrator state —
60
+ keep it in your own durable store and call `send()` when it fires (that survives restarts;
61
+ an in-process timer would not).
62
+
58
63
  ## Install
59
64
 
60
65
  ```bash
@@ -156,6 +161,38 @@ with runtime_env_context(RuntimeEnv(workspace_dir=tenant_workspace)):
156
161
 
157
162
  See [`examples/23_per_send_overrides.py`](examples/23_per_send_overrides.py).
158
163
 
164
+ ### Token usage accounting
165
+
166
+ Every `send()` returns the run's cumulative token usage — summed over all LLM
167
+ calls of that run (tool loops make several) — so cost accounting needs no event
168
+ plumbing:
169
+
170
+ ```python
171
+ res = await loop.send("…", session_id=sid)
172
+ res.usage
173
+ # {"prompt_tokens": 1234, "completion_tokens": 56, "cache_read_tokens": 0,
174
+ # "reasoning_tokens": 0, "total_tokens": 1290, "calls": 2}
175
+ ```
176
+
177
+ For per-call, real-time metering subscribe to the `usage_updated` event (one
178
+ per LLM call, tagged with `session_id`). See
179
+ [`examples/25_token_usage.py`](examples/25_token_usage.py).
180
+
181
+ ### Crash recovery: `heal_pending`
182
+
183
+ A run killed mid tool-call leaves the session with unresolved `tool_calls`;
184
+ the next `send()` raises `SessionPendingError` (the message protocol forbids
185
+ continuing). Orchestrators whose runs can legitimately die (human interrupts,
186
+ process restarts) can opt into self-healing:
187
+
188
+ ```python
189
+ res = await loop.send("…", session_id=sid, heal_pending=True)
190
+ # stale tool_calls are aborted with synthetic <aborted> results, then the
191
+ # send proceeds. Default remains raise — healing discards in-flight work.
192
+ ```
193
+
194
+ Or recover explicitly with `resume(sid)` / `abort_pending(sid)`.
195
+
159
196
  ## Public API
160
197
 
161
198
  Stable imports are re-exported from `power_loop`:
File without changes
File without changes
File without changes