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.
- {power_loop-3.4.0 → power_loop-3.6.0}/PKG-INFO +1 -1
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/__init__.py +1 -1
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/llm_factory.py +13 -3
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/sink.py +61 -10
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/stateful_loop.py +71 -25
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/messages.py +5 -2
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/protocols.py +8 -0
- power_loop-3.6.0/power_loop/contrib/_redact.py +84 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/jsonl_sink.py +19 -4
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/logging_sink.py +7 -1
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/metrics_sink.py +14 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/otel_sink.py +13 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/phase.py +18 -6
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/pipeline.py +71 -58
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/budget.py +16 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/compact.py +10 -2
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/memory.py +18 -9
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/provider.py +7 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/representation.py +39 -4
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/session_store.py +17 -2
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/skills.py +10 -1
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/spec.py +13 -2
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/dialect.py +43 -15
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/schema.py +22 -2
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/store.py +17 -6
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/structured.py +30 -13
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/timers.py +14 -2
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/default_manifest.py +23 -8
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/default_tools.py +89 -9
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/engine.py +67 -12
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/journal.py +5 -2
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/resume.py +23 -1
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/runner.py +15 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/spec.py +119 -6
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/subprocess_executor.py +77 -18
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/PKG-INFO +1 -1
- power_loop-3.4.0/power_loop/contrib/_redact.py +0 -51
- {power_loop-3.4.0 → power_loop-3.6.0}/LICENSE +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/README.md +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/agent/types.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/events.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/events.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/hooks.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/runner.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/core/state.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/py.typed +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/env.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/store/types.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/registry.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/api.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/result.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/SOURCES.txt +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/pyproject.toml +0 -0
- {power_loop-3.4.0 → power_loop-3.6.0}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "3.
|
|
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
|
|
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
|
-
#
|
|
645
|
-
|
|
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
|
|
30
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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":
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1163
|
-
#
|
|
1164
|
-
#
|
|
1165
|
-
|
|
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
|
-
|
|
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"
|
|
49
|
-
|
|
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
|
-
|
|
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):
|