power-loop 3.4.0__tar.gz → 3.6.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 (105) hide show
  1. {power_loop-3.4.0 → power_loop-3.6.0}/PKG-INFO +1 -1
  2. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/__init__.py +1 -1
  3. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/llm_factory.py +13 -3
  4. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/sink.py +61 -10
  5. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/stateful_loop.py +71 -25
  6. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/messages.py +5 -2
  7. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/protocols.py +8 -0
  8. power_loop-3.6.0/power_loop/contrib/_redact.py +84 -0
  9. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/jsonl_sink.py +19 -4
  10. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/logging_sink.py +7 -1
  11. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/metrics_sink.py +14 -0
  12. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/otel_sink.py +13 -0
  13. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/phase.py +18 -6
  14. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/pipeline.py +71 -58
  15. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/budget.py +16 -0
  16. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/compact.py +10 -2
  17. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/memory.py +18 -9
  18. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/provider.py +7 -0
  19. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/representation.py +39 -4
  20. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/session_store.py +17 -2
  21. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/skills.py +10 -1
  22. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/spec.py +13 -2
  23. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/dialect.py +43 -15
  24. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/schema.py +22 -2
  25. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/store.py +17 -6
  26. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/structured.py +30 -13
  27. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/timers.py +14 -2
  28. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/default_manifest.py +23 -8
  29. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/default_tools.py +89 -9
  30. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/engine.py +67 -12
  31. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/journal.py +5 -2
  32. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/resume.py +23 -1
  33. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/runner.py +15 -0
  34. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/spec.py +119 -6
  35. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/subprocess_executor.py +77 -18
  36. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/PKG-INFO +1 -1
  37. power_loop-3.4.0/power_loop/contrib/_redact.py +0 -51
  38. {power_loop-3.4.0 → power_loop-3.6.0}/LICENSE +0 -0
  39. {power_loop-3.4.0 → power_loop-3.6.0}/README.md +0 -0
  40. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/__init__.py +0 -0
  41. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  42. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  43. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  44. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  45. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  46. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  47. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  48. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/__init__.py +0 -0
  49. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/follow_up.py +0 -0
  50. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/system_prompt.py +0 -0
  51. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/types.py +0 -0
  52. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/__init__.py +0 -0
  53. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/errors.py +0 -0
  54. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/event_payloads.py +0 -0
  55. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/events.py +0 -0
  56. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/handlers.py +0 -0
  57. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/hook_contexts.py +0 -0
  58. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/hooks.py +0 -0
  59. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/tools.py +0 -0
  60. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/__init__.py +0 -0
  61. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/mcp.py +0 -0
  62. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/agent_context.py +0 -0
  63. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/events.py +0 -0
  64. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/hooks.py +0 -0
  65. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/runner.py +0 -0
  66. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/state.py +0 -0
  67. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/py.typed +0 -0
  68. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/blackboard.py +0 -0
  69. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/cancellation.py +0 -0
  70. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/env.py +0 -0
  71. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/exec_backend.py +0 -0
  72. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/fold.py +0 -0
  73. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/fold_adapter.py +0 -0
  74. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/history_projector.py +0 -0
  75. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/history_sanitize.py +0 -0
  76. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/human_input.py +0 -0
  77. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/notes.py +0 -0
  78. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/retry.py +0 -0
  79. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/runtime_state.py +0 -0
  80. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/__init__.py +0 -0
  81. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  82. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  83. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  84. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  85. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/capabilities.py +0 -0
  86. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/db.py +0 -0
  87. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/factory.py +0 -0
  88. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/types.py +0 -0
  89. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/stub_provider.py +0 -0
  90. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/__init__.py +0 -0
  91. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/blackboard.py +0 -0
  92. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/registry.py +0 -0
  93. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/spawn_agent.py +0 -0
  94. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/__init__.py +0 -0
  95. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/api.py +0 -0
  96. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/introspect.py +0 -0
  97. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/result.py +0 -0
  98. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/tool.py +0 -0
  99. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/worker.py +0 -0
  100. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/SOURCES.txt +0 -0
  101. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/dependency_links.txt +0 -0
  102. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/requires.txt +0 -0
  103. {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/top_level.txt +0 -0
  104. {power_loop-3.4.0 → power_loop-3.6.0}/pyproject.toml +0 -0
  105. {power_loop-3.4.0 → power_loop-3.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 3.4.0
3
+ Version: 3.6.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
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "3.4.0"
18
+ __version__ = "3.6.0"
19
19
 
20
20
  # Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
21
21
  # a custom LLMService) don't reach into the internal vendored transport package (H3.4).
@@ -624,7 +624,7 @@ class OpenAICompatibleChatLLMService(LLMService):
624
624
  if not isinstance(items, list):
625
625
  items = [items]
626
626
 
627
- for pos, it in enumerate(items):
627
+ for it in items:
628
628
  d = _as_dict(it)
629
629
  if not isinstance(d, dict):
630
630
  continue
@@ -641,8 +641,18 @@ class OpenAICompatibleChatLLMService(LLMService):
641
641
  elif delta_index is not None:
642
642
  call_key = f"index_{delta_index}"
643
643
  else:
644
- # fall back to position inside this delta event
645
- call_key = f"event_pos_{pos}"
644
+ # Ambiguous: neither id nor index (non-standard provider). Keying purely by this
645
+ # event's position collided sequential DISTINCT calls (both arriving at pos 0 in
646
+ # separate events) into one entry, merging their arguments (llm-transport-5).
647
+ # OpenAI-style streaming starts a call with a function NAME and continues it with
648
+ # arguments-only deltas: a name-bearing (or first-ever) ambiguous delta opens a
649
+ # NEW slot; an arguments-only one continues the most-recent call.
650
+ fn_obj = d.get("function")
651
+ fn_obj = fn_obj if isinstance(fn_obj, dict) else (_as_dict(fn_obj) or {})
652
+ if fn_obj.get("name") or not tool_call_order:
653
+ call_key = f"ambiguous_{len(tool_call_order)}"
654
+ else:
655
+ call_key = tool_call_order[-1]
646
656
 
647
657
  if call_key not in tool_call_store:
648
658
  tool_call_store[call_key] = {
@@ -26,8 +26,13 @@ logger = logging.getLogger(__name__)
26
26
  class MessageSink(Protocol):
27
27
  """Persistence callbacks invoked by :class:`AgentPipeline`.
28
28
 
29
- Every method MUST be safe to call multiple times and MUST NOT raise on
30
- normal paths — sinks degrade gracefully and log internally if needed.
29
+ Every method MUST be safe to call multiple times.
30
+
31
+ Raising contract (prompt-sink-provider-2): an OBSERVABILITY sink (logging / metrics) MUST NOT
32
+ raise on normal paths — it degrades gracefully and logs internally. A PERSISTENCE sink (e.g.
33
+ :class:`SQLiteSink`, the durable source of truth) MAY raise on a genuine store I/O failure, and
34
+ the pipeline treats that as a FATAL send error (it does not swallow it): the send aborts and the
35
+ durable store stays authoritative. Callers that want best-effort persistence must wrap the sink.
31
36
  """
32
37
 
33
38
  async def on_round_started(self, round_index: int) -> None: ...
@@ -145,14 +150,15 @@ class SQLiteSink:
145
150
  role = message.get("role")
146
151
  if role == "tool":
147
152
  tool_call_id = str(message.get("tool_call_id") or "")
153
+ text, structured = _encode_content(message.get("content"))
148
154
  seq = await self.store.append_message(
149
155
  self.session_id,
150
156
  role="tool",
151
- content=_as_text(message.get("content")),
157
+ content=text,
152
158
  tool_call_id=tool_call_id,
153
159
  name=message.get("name"),
154
160
  round_index=round_index,
155
- meta=message.get("meta"),
161
+ meta=_meta_with_content_encoding(message.get("meta"), structured=structured),
156
162
  send_index=message.get("send_index"),
157
163
  )
158
164
  self._history_seqs.append(seq)
@@ -189,13 +195,14 @@ class SQLiteSink:
189
195
  return
190
196
  if role == "assistant":
191
197
  tool_calls = message.get("tool_calls")
198
+ text, structured = _encode_content(message.get("content"))
192
199
  seq = await self.store.append_message(
193
200
  self.session_id,
194
201
  role="assistant",
195
- content=_as_text(message.get("content")),
202
+ content=text,
196
203
  tool_calls=list(tool_calls) if tool_calls else None,
197
204
  round_index=round_index,
198
- meta=message.get("meta"),
205
+ meta=_meta_with_content_encoding(message.get("meta"), structured=structured),
199
206
  send_index=message.get("send_index"),
200
207
  hook_injected=message.get("hook_injected"),
201
208
  )
@@ -205,13 +212,14 @@ class SQLiteSink:
205
212
  self._assistant_seq = seq
206
213
  return
207
214
  # user / system / anything else
215
+ text, structured = _encode_content(message.get("content"))
208
216
  seq = await self.store.append_message(
209
217
  self.session_id,
210
218
  role=str(role or "user"),
211
- content=_as_text(message.get("content")),
219
+ content=text,
212
220
  name=message.get("name"),
213
221
  round_index=round_index,
214
- meta=message.get("meta"),
222
+ meta=_meta_with_content_encoding(message.get("meta"), structured=structured),
215
223
  send_index=message.get("send_index"),
216
224
  )
217
225
  self._history_seqs.append(seq)
@@ -224,12 +232,18 @@ class SQLiteSink:
224
232
  ) -> None:
225
233
  ids = [str(tc.get("id") or "") for tc in tool_calls if tc.get("id")]
226
234
  self._unresolved = set(ids)
227
- self._assistant_seq = assistant_seq
235
+ # Prefer the DB seq captured when the assistant row was appended (on_message_appended set
236
+ # self._assistant_seq to the store seq) over the caller's in-memory history INDEX, which
237
+ # diverges from the store seq once history is compacted/projected/rebuilt — persisting the
238
+ # index as assistant_seq would point resume at the wrong row (pipeline-runner-4). Fall back
239
+ # to the passed value only if no row seq was captured.
240
+ seq = self._assistant_seq if self._assistant_seq is not None else assistant_seq
241
+ self._assistant_seq = seq
228
242
  self._tool_calls = list(tool_calls)
229
243
  await self.store.set_pending(
230
244
  self.session_id,
231
245
  {
232
- "assistant_seq": assistant_seq,
246
+ "assistant_seq": seq,
233
247
  "round_index": round_index,
234
248
  "tool_call_ids": ids,
235
249
  "tool_calls": list(tool_calls),
@@ -262,6 +276,13 @@ class SQLiteSink:
262
276
  rows ``compacted_out``. The in-memory fold still stands; the un-persisted
263
277
  compaction simply re-triggers next round (active rows are untouched, so a
264
278
  resume is correct), trading a missed optimization for zero corruption.
279
+
280
+ RESIDUAL (compaction-2): this guard is LENGTH-only. A hook that replaces history with a
281
+ list of the SAME length but DIFFERENT identities (a length-preserving swap) passes the
282
+ check, so the fold indices would map through ``_history_seqs`` onto the wrong rows. INVARIANT
283
+ for hosts: a SESSION_START/ROUND_START hook that rewrites history must NOT preserve length
284
+ while changing message identities — change the length (so this guard skips persistence), or
285
+ keep identities stable. (A future fix can pass per-message identity, not just the length.)
265
286
  """
266
287
  if expected_history_len is not None and len(self._history_seqs) != expected_history_len:
267
288
  logger.warning(
@@ -359,6 +380,36 @@ class SQLiteSink:
359
380
  )
360
381
 
361
382
 
383
+ # Meta marker recording that a row's text column holds JSON-encoded *structured* content
384
+ # (a multimodal list / dict), so the reload path can losslessly reconstruct it instead of
385
+ # handing the model a literal JSON string. (H6 — BUG_REVIEW_3.4.) The marker lives in meta
386
+ # (jsonb, already round-trips) because the content column alone can't distinguish a
387
+ # stringified list from a user string that merely looks like JSON.
388
+ CONTENT_ENCODING_META_KEY = "content_encoding"
389
+ CONTENT_ENCODING_JSON = "json"
390
+
391
+
392
+ def _encode_content(content: Any) -> tuple[str | None, bool]:
393
+ """Return ``(text, structured)``. ``str``/``None`` pass through unflagged; a non-string
394
+ (multimodal list/dict) is JSON-encoded and flagged so reload reconstructs the original."""
395
+ if content is None or isinstance(content, str):
396
+ return content, False
397
+ import json
398
+
399
+ return json.dumps(content, ensure_ascii=False), True
400
+
401
+
402
+ def _meta_with_content_encoding(
403
+ meta: dict[str, Any] | None, *, structured: bool
404
+ ) -> dict[str, Any] | None:
405
+ """Stamp the structured-content marker into ``meta`` (copy-on-write) when needed."""
406
+ if not structured:
407
+ return meta
408
+ out = dict(meta or {})
409
+ out[CONTENT_ENCODING_META_KEY] = CONTENT_ENCODING_JSON
410
+ return out
411
+
412
+
362
413
  def _as_text(content: Any) -> str | None:
363
414
  if content is None:
364
415
  return None
@@ -29,7 +29,13 @@ from typing import TYPE_CHECKING, Any
29
29
 
30
30
  from power_loop._vendor.llm_client.interface import LLMService
31
31
  from power_loop.agent.follow_up import FollowUpQueued, merge_follow_up_inputs
32
- from power_loop.agent.sink import SQLiteSink
32
+ from power_loop.agent.sink import (
33
+ CONTENT_ENCODING_JSON,
34
+ CONTENT_ENCODING_META_KEY,
35
+ SQLiteSink,
36
+ _encode_content,
37
+ _meta_with_content_encoding,
38
+ )
33
39
  from power_loop.agent.system_prompt import (
34
40
  resolve_runtime_system_prompt,
35
41
  )
@@ -759,12 +765,17 @@ class StatefulAgentLoop:
759
765
  sink = SQLiteSink(store, session_id)
760
766
  await self._prime_sink_from_pending(session_id, sink)
761
767
  round_index = int((pending or {}).get("round_index") or 0)
768
+ # Stamp the in-flight send's index so projection mode keeps this answer in the
769
+ # active send's current_rows (else it lands in the NULL-send_index legacy prefix,
770
+ # renders before its own tool_call, and is dropped as an orphan). See H1.
771
+ send_index = await self._current_send_index(store, session_id)
762
772
  await sink.on_message_appended(
763
773
  {
764
774
  "role": "tool",
765
775
  "tool_call_id": str(interaction["tool_call_id"]),
766
776
  "name": str(interaction.get("tool_name") or "request_user_input"),
767
777
  "content": _as_tool_result_text(value),
778
+ "send_index": send_index,
768
779
  },
769
780
  round_index=round_index,
770
781
  )
@@ -808,6 +819,10 @@ class StatefulAgentLoop:
808
819
  # tool_calls from self._tool_calls (sink.py:171-174); left empty it would write
809
820
  # {tool_call_ids:[…], tool_calls:[]}, a self-inconsistent pending.
810
821
  sink._tool_calls = list(tool_calls)
822
+ # Stamp the pending send's index (runtime_state still holds it — abort runs before the
823
+ # next _persist_user_input bumps it) so projection keeps these <aborted> rows paired with
824
+ # their assistant tool_call instead of orphaning them in the legacy prefix. See H1.
825
+ send_index = await self._current_send_index(store, session_id)
811
826
  for tc in tool_calls:
812
827
  cid = str(tc.get("id") or "")
813
828
  name = _tool_call_name(tc) if "function" in tc or "name" in tc else None
@@ -817,6 +832,7 @@ class StatefulAgentLoop:
817
832
  "tool_call_id": cid,
818
833
  "name": name,
819
834
  "content": f"<aborted: {reason}>",
835
+ "send_index": send_index,
820
836
  },
821
837
  round_index=round_index,
822
838
  )
@@ -1031,17 +1047,46 @@ class StatefulAgentLoop:
1031
1047
  pending_tool_calls=pending.get("tool_calls", []),
1032
1048
  )
1033
1049
 
1050
+ @staticmethod
1051
+ def _coerce_send_index(raw: Any) -> int | None:
1052
+ """The current send's authoritative index, or None when unallocated/legacy.
1053
+
1054
+ send_index is allocated >= 1 by _persist_user_input and persists across
1055
+ resume()/submit_input()/follow-up (they inherit, never re-bump). 0 is the
1056
+ unallocated/legacy default. A corrupted runtime_state value (non-numeric /
1057
+ inf / nan) must degrade to "unallocated", never crash int()."""
1058
+ try:
1059
+ v = int(raw)
1060
+ except (TypeError, ValueError, OverflowError):
1061
+ return None
1062
+ return v if v >= 1 else None
1063
+
1064
+ async def _current_send_index(self, store: Any, sid: str) -> int | None:
1065
+ """Read the in-flight send index from runtime state (None if unallocated).
1066
+
1067
+ Out-of-band tool appends (submit_input/resume/abort_pending) MUST stamp this
1068
+ onto every row so projection mode partitions them into the active send's
1069
+ ``current_rows`` rather than the legacy (NULL send_index) prefix — otherwise
1070
+ the tool result renders BEFORE its own assistant tool_call and align_tool_calls
1071
+ drops it as an orphan, silently losing the answer."""
1072
+ raw = await store.get_runtime_state(sid, "send_index", default=0)
1073
+ return self._coerce_send_index(raw)
1074
+
1034
1075
  async def _persist_user_input(self, sid: str, user_input: str | LoopMessage) -> None:
1035
1076
  store = await self._ensure_store()
1036
1077
  role: str
1037
1078
  content: str | None
1038
1079
  name: str | None
1080
+ # Encode multimodal (list/dict) content losslessly: JSON in the text column + a meta
1081
+ # marker so the reload path reconstructs the original structure rather than handing the
1082
+ # model a literal JSON string (vision would otherwise silently break). See H6.
1039
1083
  if isinstance(user_input, str):
1040
- role, content, name = "user", user_input, None
1084
+ role, content, name, structured = "user", user_input, None, False
1041
1085
  else:
1042
1086
  role = str(user_input.get("role", "user"))
1043
- content = _as_text(user_input.get("content"))
1087
+ content, structured = _encode_content(user_input.get("content"))
1044
1088
  name = user_input.get("name")
1089
+ meta = _meta_with_content_encoding(None, structured=structured)
1045
1090
  # Allocate the next monotonic SEND index for this session (atomic RMW under the
1046
1091
  # session_state row lock — never resets, unlike round_index). This is the single
1047
1092
  # send-begin point (exactly one user row per send; resume()/follow-up drains do
@@ -1051,18 +1096,18 @@ class StatefulAgentLoop:
1051
1096
  sid, "send_index", lambda v: int(v or 0) + 1, default=0
1052
1097
  )
1053
1098
  seq = await store.append_message(
1054
- sid, role=role, content=content, name=name, send_index=send_index
1099
+ sid, role=role, content=content, name=name, send_index=send_index, meta=meta
1055
1100
  )
1056
1101
  # Keep a live cache entry current with the loop's OWN append (no reload): the next
1057
1102
  # send's next_seq token will then match and reuse the cached window. No-op if this
1058
1103
  # session isn't cached. The row mirrors what append_message persisted (only
1059
- # seq/role/content/name/send_index are consumed when rebuilding the working history).
1104
+ # seq/role/content/name/send_index/meta are consumed when rebuilding the working history).
1060
1105
  self._cache_append(
1061
1106
  sid,
1062
1107
  MessageRow(
1063
1108
  session_id=sid, seq=seq, role=role, name=name, content=content,
1064
1109
  tool_calls=None, tool_call_id=None, round_index=None,
1065
- state=MessageState.ACTIVE, meta={}, created_at=0, send_index=send_index,
1110
+ state=MessageState.ACTIVE, meta=meta or {}, created_at=0, send_index=send_index,
1066
1111
  ),
1067
1112
  new_next_seq=seq + 1,
1068
1113
  )
@@ -1088,6 +1133,10 @@ class StatefulAgentLoop:
1088
1133
  return
1089
1134
  # Initialize sink's in-memory unresolved set so auto-resolve works.
1090
1135
  await self._prime_sink_from_pending(sid, sink)
1136
+ # The in-flight send's index (inherited, not re-bumped on resume): stamp it on every
1137
+ # replayed tool row so projection mode pairs the result with its assistant tool_call
1138
+ # instead of orphaning it in the NULL-send_index legacy prefix. See H1.
1139
+ send_index = await self._current_send_index(store, sid)
1091
1140
  for tc in tool_calls:
1092
1141
  cid = str(tc.get("id") or "")
1093
1142
  name = _tool_call_name(tc)
@@ -1100,6 +1149,7 @@ class StatefulAgentLoop:
1100
1149
  "tool_call_id": cid,
1101
1150
  "name": None,
1102
1151
  "content": "<aborted: unrecoverable tool_call on resume>",
1152
+ "send_index": send_index,
1103
1153
  },
1104
1154
  round_index=round_index,
1105
1155
  )
@@ -1124,6 +1174,7 @@ class StatefulAgentLoop:
1124
1174
  "tool_call_id": cid,
1125
1175
  "name": name,
1126
1176
  "content": _truncate_result(output),
1177
+ "send_index": send_index,
1127
1178
  },
1128
1179
  round_index=round_index,
1129
1180
  )
@@ -1159,18 +1210,10 @@ class StatefulAgentLoop:
1159
1210
  fold_strategy = self.config.fold_strategy
1160
1211
  # The current send's authoritative index (set by _persist_user_input; inherited by
1161
1212
  # resume()/follow-up). Read up-front so projection mode can partition history by it.
1162
- si = await store.get_runtime_state(sid, "send_index", default=0)
1163
- # send_index is allocated >= 1 by _persist_user_input and persists across resume();
1164
- # 0 is the unallocated/legacy default. Treat ONLY a real allocation (>= 1) as the current
1165
- # send explicit (matches the `is not None` convention used below), never conflating the
1166
- # unallocated 0 with a (hypothetical) explicit send 0. Coerce defensively: a corrupted
1167
- # runtime_state value (non-numeric / inf / nan) must degrade to "unallocated", never crash
1168
- # the reader with int()'s ValueError/OverflowError.
1169
- try:
1170
- si_int = int(si)
1171
- except (TypeError, ValueError, OverflowError):
1172
- si_int = 0
1173
- current_send_index = si_int if si_int >= 1 else None
1213
+ # The current send (>= 1) or None when unallocated/legacy — same coercion the out-of-band
1214
+ # tool appends (submit_input/resume/abort_pending) use to stamp send_index, so the reader's
1215
+ # partition and the writer's stamp can never disagree.
1216
+ current_send_index = await self._current_send_index(store, sid)
1174
1217
  # Cache only the plain-send path: resume()/submit_input() pass a pre-primed sink built
1175
1218
  # from pending state (NOT a full init_history_seqs), so they must neither read from nor
1176
1219
  # write to the window cache — they self-invalidate via the next_seq bump from their own
@@ -1776,7 +1819,16 @@ class StatefulAgentLoop:
1776
1819
  def _row_to_loop_message(row: MessageRow) -> LoopMessage:
1777
1820
  msg: LoopMessage = {"role": row.role}
1778
1821
  if row.content is not None:
1779
- msg["content"] = row.content
1822
+ content: Any = row.content
1823
+ # Reconstruct structured (multimodal) content that was JSON-encoded on persist, so the
1824
+ # model receives the original list/dict — not a literal JSON string. See H6. A corrupt
1825
+ # marker / unparseable payload degrades to the raw text rather than raising.
1826
+ if (row.meta or {}).get(CONTENT_ENCODING_META_KEY) == CONTENT_ENCODING_JSON:
1827
+ try:
1828
+ content = json.loads(row.content)
1829
+ except (ValueError, TypeError):
1830
+ content = row.content
1831
+ msg["content"] = content
1780
1832
  if row.tool_calls:
1781
1833
  msg["tool_calls"] = list(row.tool_calls)
1782
1834
  if row.tool_call_id:
@@ -1786,12 +1838,6 @@ def _row_to_loop_message(row: MessageRow) -> LoopMessage:
1786
1838
  return msg
1787
1839
 
1788
1840
 
1789
- def _as_text(content: Any) -> str | None:
1790
- if content is None or isinstance(content, str):
1791
- return content
1792
- return json.dumps(content, ensure_ascii=False)
1793
-
1794
-
1795
1841
  def _as_tool_result_text(value: Any) -> str:
1796
1842
  if isinstance(value, str):
1797
1843
  return value
@@ -45,8 +45,11 @@ class AgentMessage:
45
45
  }
46
46
  if self.role == "assistant" and self.tool_calls:
47
47
  payload["tool_calls"] = [call.to_openai_tool_call() for call in self.tool_calls]
48
- if self.role == "tool" and self.tool_call_id:
49
- payload["tool_call_id"] = self.tool_call_id
48
+ if self.role == "tool":
49
+ # A tool-role message MUST carry tool_call_id emit it unconditionally (empty string if
50
+ # unset) so we never produce a structurally-invalid tool message a provider rejects with
51
+ # an opaque error (exec-skills-structured-7).
52
+ payload["tool_call_id"] = self.tool_call_id or ""
50
53
  if self.name:
51
54
  payload["name"] = self.name
52
55
  return payload
@@ -44,5 +44,13 @@ class HookManagerProtocol(Protocol):
44
44
 
45
45
 
46
46
  class ToolArgsValidator(Protocol):
47
+ """Pre-execution tool-argument validator: return an error string to reject the call, or ``None``
48
+ to allow it (may be async).
49
+
50
+ RESERVED / PROVISIONAL (exec-skills-structured-6): this is a typed seam published for forward
51
+ compatibility, but the runtime does NOT yet consume it — there is currently no
52
+ ``ToolRegistry`` / ``AgentLoopConfig`` hook that calls a ToolArgsValidator. Validate tool args
53
+ inside the tool handler itself for now. (Tracked for a future wiring; not STABLE_API.)"""
54
+
47
55
  def __call__(self, tool_name: str, args: dict[str, Any]) -> str | None | Awaitable[str | None]:
48
56
  ...
@@ -0,0 +1,84 @@
1
+ """Shared payload sanitization for contrib event sinks (logging, JSONL).
2
+
3
+ Truncates long strings and redacts secret-looking keys so event payloads can be logged
4
+ or persisted without leaking credentials or blowing up volume. Used by both
5
+ ``logging_sink`` and ``jsonl_sink`` so the redaction policy is defined once.
6
+
7
+ REDACTION SCOPE (important): by default redaction is **key-name based** — a value is
8
+ replaced only when its *key* matches the denylist. Secrets embedded in string VALUES under
9
+ benign keys (a ``Bearer …`` header inside a bash command string, an ``sk-…`` key pasted into
10
+ a tool argument) are NOT scrubbed by the default policy. Opt into value-content scrubbing with
11
+ ``redact_value_secrets=True`` on the sink, which additionally regex-redacts common secret shapes
12
+ (see :data:`DEFAULT_VALUE_PATTERNS`) inside string values.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Iterable
19
+ from typing import Any
20
+
21
+ # Keys whose VALUE is replaced with "***" anywhere in a payload (case-insensitive
22
+ # substring match on the key name). Tool inputs and request messages can carry secrets.
23
+ # NB: bare "token" is intentionally NOT here — it would redact the non-secret usage
24
+ # counts (prompt_tokens / completion_tokens / total_tokens). Specific token names are.
25
+ DEFAULT_REDACT_KEYS: tuple[str, ...] = (
26
+ "api_key", "api-key", "apikey",
27
+ "authorization", "bearer",
28
+ "password", "passwd",
29
+ "secret", "secret_key",
30
+ "access_key", "private_key",
31
+ "access_token", "refresh_token", "auth_token", "id_token",
32
+ )
33
+ REDACTED = "***"
34
+
35
+ #: Regexes for secret-shaped substrings scrubbed from string VALUES when value-content redaction
36
+ #: is enabled (opt-in). Conservative shapes only, to avoid mangling ordinary text:
37
+ DEFAULT_VALUE_PATTERNS: tuple[re.Pattern[str], ...] = (
38
+ re.compile(r"\bBearer\s+[A-Za-z0-9._~+/\-]{12,}=*", re.IGNORECASE), # Authorization: Bearer …
39
+ re.compile(r"\b(?:sk|rk|pk|xoxb|xoxp|ghp|gho|github_pat)[-_][A-Za-z0-9_\-]{16,}"), # provider keys
40
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id
41
+ re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), # JWT
42
+ re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), # Google API key
43
+ )
44
+
45
+
46
+ def resolve_redact(redact_keys: Iterable[str] | None) -> tuple[str, ...]:
47
+ """Lower-cased redaction key substrings. ``None`` → the default denylist; ``()``
48
+ disables redaction; any iterable overrides."""
49
+ keys = tuple(redact_keys if redact_keys is not None else DEFAULT_REDACT_KEYS)
50
+ return tuple(k.lower() for k in keys)
51
+
52
+
53
+ def scrub_value_secrets(text: str, patterns: tuple[re.Pattern[str], ...]) -> str:
54
+ """Replace every secret-shaped substring matched by ``patterns`` with :data:`REDACTED`."""
55
+ for pat in patterns:
56
+ text = pat.sub(REDACTED, text)
57
+ return text
58
+
59
+
60
+ def sanitize(
61
+ value: Any, limit: int, redact_lower: tuple[str, ...], *, max_list: int = 50,
62
+ value_patterns: tuple[re.Pattern[str], ...] | None = None,
63
+ ) -> Any:
64
+ """Recursively truncate long strings to ``limit`` and redact values under keys whose
65
+ (lower-cased) name contains any ``redact_lower`` substring. Lists are capped at ``max_list``
66
+ items. When ``value_patterns`` is given, secret-shaped substrings inside string VALUES are
67
+ scrubbed too (key-name redaction alone misses secrets embedded in values — M-observability-6)."""
68
+ if isinstance(value, str):
69
+ text = value if len(value) <= limit else value[:limit] + f"…(+{len(value) - limit})"
70
+ return scrub_value_secrets(text, value_patterns) if value_patterns else text
71
+ if isinstance(value, dict):
72
+ out: dict[Any, Any] = {}
73
+ for k, v in value.items():
74
+ kl = str(k).lower()
75
+ out[k] = REDACTED if any(r in kl for r in redact_lower) else sanitize(
76
+ v, limit, redact_lower, max_list=max_list, value_patterns=value_patterns
77
+ )
78
+ return out
79
+ if isinstance(value, list):
80
+ return [
81
+ sanitize(v, limit, redact_lower, max_list=max_list, value_patterns=value_patterns)
82
+ for v in value[:max_list]
83
+ ]
84
+ return value
@@ -28,7 +28,7 @@ from collections.abc import Iterable, Iterator
28
28
  from pathlib import Path
29
29
 
30
30
  from power_loop.contracts.events import AgentEvent, AgentEventType
31
- from power_loop.contrib._redact import resolve_redact, sanitize
31
+ from power_loop.contrib._redact import DEFAULT_VALUE_PATTERNS, resolve_redact, sanitize
32
32
  from power_loop.core.events import AgentEventBus
33
33
 
34
34
  __all__ = ["attach_jsonl_sink", "replay", "JsonlSink"]
@@ -36,7 +36,15 @@ __all__ = ["attach_jsonl_sink", "replay", "JsonlSink"]
36
36
 
37
37
  class JsonlSink:
38
38
  """A size-rotated JSON-lines writer. One ``AgentEvent.to_dict()`` per line; rotates
39
- to ``path.1``, ``path.2``, … (oldest dropped past ``backup_count``). Thread-safe."""
39
+ to ``path.1``, ``path.2``, … (oldest dropped past ``backup_count``). Thread-safe.
40
+
41
+ ``backup_count<=0`` disables size rotation entirely (the file grows unbounded) — previously it
42
+ truncated the file on every rotation, silently discarding ALL history (contrib-observability-5).
43
+
44
+ PERF (contrib-observability-4): ``write_line`` writes + ``flush()`` INLINE. If attached to a bus
45
+ that dispatches subscribers synchronously on the agent's own thread, that disk I/O stalls the
46
+ loop. For durability without stalling, attach this sink to a bus configured with threaded/async
47
+ dispatch (so writes happen off the loop thread)."""
40
48
 
41
49
  def __init__(self, path: str | Path, *, max_bytes: int = 10 * 1024 * 1024, backup_count: int = 5) -> None:
42
50
  self.path = Path(path)
@@ -50,7 +58,10 @@ class JsonlSink:
50
58
  with self._lock:
51
59
  if self._fh.closed:
52
60
  return
53
- if self.max_bytes and self._fh.tell() > 0 and self._fh.tell() + len(line) + 1 > self.max_bytes:
61
+ # Only rotate when we actually keep backups backup_count<=0 means "no rotation" (let
62
+ # the file grow), NOT "rotate by truncating to nothing" (contrib-observability-5).
63
+ if (self.max_bytes and self.backup_count > 0 and self._fh.tell() > 0
64
+ and self._fh.tell() + len(line) + 1 > self.max_bytes):
54
65
  self._rotate()
55
66
  self._fh.write(line)
56
67
  self._fh.write("\n")
@@ -84,6 +95,7 @@ def attach_jsonl_sink(
84
95
  backup_count: int = 5,
85
96
  max_field_len: int = 2000,
86
97
  redact_keys: Iterable[str] | None = None,
98
+ redact_value_secrets: bool = False,
87
99
  ) -> JsonlSink:
88
100
  """Persist events from ``bus`` to a rotating JSONL file at ``path``.
89
101
 
@@ -91,17 +103,20 @@ def attach_jsonl_sink(
91
103
  :param max_bytes/backup_count: rotation (``0`` disables size rotation).
92
104
  :param max_field_len: truncate long string payload values.
93
105
  :param redact_keys: secret-key denylist (``None`` = default; ``()`` = no redaction).
106
+ :param redact_value_secrets: also scrub secret-shaped substrings inside string VALUES
107
+ (off by default; key-name redaction alone misses value-embedded secrets — M-observability-6).
94
108
  Returns the :class:`JsonlSink`; call ``.close()`` to flush + release the file.
95
109
  """
96
110
  sink = JsonlSink(path, max_bytes=max_bytes, backup_count=backup_count)
97
111
  wanted = set(events) if events is not None else None
98
112
  redact_lower = resolve_redact(redact_keys)
113
+ value_patterns = DEFAULT_VALUE_PATTERNS if redact_value_secrets else None
99
114
 
100
115
  def _handler(event: AgentEvent) -> None:
101
116
  if wanted is not None and event.type not in wanted:
102
117
  return
103
118
  d = event.to_dict()
104
- d["payload"] = sanitize(d.get("payload") or {}, max_field_len, redact_lower)
119
+ d["payload"] = sanitize(d.get("payload") or {}, max_field_len, redact_lower, value_patterns=value_patterns)
105
120
  sink.write_line(json.dumps(d, ensure_ascii=False, default=str))
106
121
 
107
122
  if wanted is None:
@@ -35,6 +35,7 @@ from typing import Any
35
35
  from power_loop.contracts.events import AgentEvent, AgentEventType
36
36
  from power_loop.contrib._redact import (
37
37
  DEFAULT_REDACT_KEYS,
38
+ DEFAULT_VALUE_PATTERNS,
38
39
  REDACTED,
39
40
  resolve_redact,
40
41
  sanitize,
@@ -58,6 +59,7 @@ def attach_logging_sink(
58
59
  events: Iterable[AgentEventType] | None = None,
59
60
  max_field_len: int = 500,
60
61
  redact_keys: Iterable[str] | None = None,
62
+ redact_value_secrets: bool = False,
61
63
  ) -> None:
62
64
  """Subscribe a JSON-lines logger to ``bus``.
63
65
 
@@ -66,10 +68,14 @@ def attach_logging_sink(
66
68
  :param redact_keys: key-name substrings whose values are replaced with ``***``.
67
69
  Defaults to a common secret denylist (api_key/token/password/…); pass
68
70
  ``()`` to disable redaction, or your own iterable to override.
71
+ :param redact_value_secrets: also scrub secret-shaped substrings (Bearer/sk-/AKIA/JWT/…)
72
+ inside string VALUES, not just denylisted keys. Off by default (M-observability-6) —
73
+ key-name redaction alone misses secrets embedded in tool args / command strings.
69
74
  """
70
75
  log = logger if logger is not None else logging.getLogger(DEFAULT_LOGGER_NAME)
71
76
  wanted = set(events) if events is not None else None
72
77
  redact_lower = resolve_redact(redact_keys)
78
+ value_patterns = DEFAULT_VALUE_PATTERNS if redact_value_secrets else None
73
79
 
74
80
  def _handler(event: AgentEvent) -> None:
75
81
  if wanted is not None and event.type not in wanted:
@@ -89,7 +95,7 @@ def attach_logging_sink(
89
95
  if event.source:
90
96
  record["source"] = event.source
91
97
  payload = event.payload or {}
92
- record["payload"] = sanitize(payload, max_field_len, redact_lower)
98
+ record["payload"] = sanitize(payload, max_field_len, redact_lower, value_patterns=value_patterns)
93
99
  log.log(level, json.dumps(record, ensure_ascii=False, default=str))
94
100
 
95
101
  if wanted is None:
@@ -24,6 +24,7 @@ Metrics emitted (prefix ``power_loop`` by default):
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import logging
27
28
  from collections.abc import Iterable
28
29
  from typing import Any, Protocol, runtime_checkable
29
30
 
@@ -32,6 +33,8 @@ from power_loop.core.events import AgentEventBus
32
33
 
33
34
  __all__ = ["MetricsBackend", "attach_metrics_sink", "PrometheusBackend", "StatsDBackend"]
34
35
 
36
+ logger = logging.getLogger(__name__)
37
+
35
38
 
36
39
  @runtime_checkable
37
40
  class MetricsBackend(Protocol):
@@ -57,6 +60,17 @@ def attach_metrics_sink(
57
60
  return wanted is None or t in wanted
58
61
 
59
62
  def _handler(event: AgentEvent) -> None:
63
+ # Observability must NEVER break the loop: a real backend can raise (StatsD socket
64
+ # OSError, prometheus_client ValueError on a bad/duplicate metric or label), and on a
65
+ # bus with suppress_subscriber_errors=False (the default, incl. DEFAULT_EVENT_BUS) an
66
+ # unhandled subscriber exception unwinds through publish() and aborts the agent run.
67
+ # Log-and-swallow here, mirroring otel_sink's guards. See H7 (BUG_REVIEW_3.4).
68
+ try:
69
+ _dispatch(event)
70
+ except Exception: # noqa: BLE001 — a metrics hiccup is a dropped data point, not a failed run
71
+ logger.exception("metrics sink backend failed; dropping this metric and continuing")
72
+
73
+ def _dispatch(event: AgentEvent) -> None:
60
74
  p = event.payload or {}
61
75
  t = event.type
62
76
  if not _on(t):