AbstractRuntime 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. abstractruntime/__init__.py +7 -2
  2. abstractruntime/core/config.py +14 -1
  3. abstractruntime/core/event_keys.py +62 -0
  4. abstractruntime/core/models.py +12 -1
  5. abstractruntime/core/runtime.py +2444 -14
  6. abstractruntime/core/vars.py +95 -0
  7. abstractruntime/evidence/__init__.py +10 -0
  8. abstractruntime/evidence/recorder.py +325 -0
  9. abstractruntime/integrations/abstractcore/__init__.py +3 -0
  10. abstractruntime/integrations/abstractcore/constants.py +19 -0
  11. abstractruntime/integrations/abstractcore/default_tools.py +134 -0
  12. abstractruntime/integrations/abstractcore/effect_handlers.py +255 -6
  13. abstractruntime/integrations/abstractcore/factory.py +95 -10
  14. abstractruntime/integrations/abstractcore/llm_client.py +456 -52
  15. abstractruntime/integrations/abstractcore/mcp_worker.py +586 -0
  16. abstractruntime/integrations/abstractcore/observability.py +80 -0
  17. abstractruntime/integrations/abstractcore/summarizer.py +154 -0
  18. abstractruntime/integrations/abstractcore/tool_executor.py +481 -24
  19. abstractruntime/memory/__init__.py +21 -0
  20. abstractruntime/memory/active_context.py +746 -0
  21. abstractruntime/memory/active_memory.py +452 -0
  22. abstractruntime/memory/compaction.py +105 -0
  23. abstractruntime/rendering/__init__.py +17 -0
  24. abstractruntime/rendering/agent_trace_report.py +256 -0
  25. abstractruntime/rendering/json_stringify.py +136 -0
  26. abstractruntime/scheduler/scheduler.py +93 -2
  27. abstractruntime/storage/__init__.py +3 -1
  28. abstractruntime/storage/artifacts.py +20 -5
  29. abstractruntime/storage/json_files.py +15 -2
  30. abstractruntime/storage/observable.py +99 -0
  31. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/METADATA +5 -1
  32. abstractruntime-0.4.0.dist-info/RECORD +49 -0
  33. abstractruntime-0.4.0.dist-info/entry_points.txt +2 -0
  34. abstractruntime-0.2.0.dist-info/RECORD +0 -32
  35. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/WHEEL +0 -0
  36. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,746 @@
1
+ """Active context policy + provenance-based recall utilities.
2
+
3
+ Goal: keep a strict separation between:
4
+ - Stored memory (durable): RunStore/LedgerStore/ArtifactStore
5
+ - Active context (LLM-visible view): RunState.vars["context"]["messages"]
6
+
7
+ This module is intentionally small and JSON-safe. It does not implement semantic
8
+ search or "graph compression"; it establishes the contracts needed for those.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
16
+
17
+ from ..core.models import RunState
18
+ from ..core.vars import get_context, get_limits, get_runtime
19
+ from ..storage.artifacts import ArtifactMetadata, ArtifactStore
20
+ from ..storage.base import RunStore
21
+
22
+
23
+ def _parse_iso(ts: str) -> datetime:
24
+ value = (ts or "").strip()
25
+ if not value:
26
+ raise ValueError("empty timestamp")
27
+ # Accept both "+00:00" and "Z"
28
+ if value.endswith("Z"):
29
+ value = value[:-1] + "+00:00"
30
+ return datetime.fromisoformat(value)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TimeRange:
35
+ """A closed interval [start, end] in ISO8601 strings.
36
+
37
+ If start or end is None, the range is unbounded on that side.
38
+ """
39
+
40
+ start: Optional[str] = None
41
+ end: Optional[str] = None
42
+
43
+ def contains(self, *, start: Optional[str], end: Optional[str]) -> bool:
44
+ """Return True if [start,end] intersects this range.
45
+
46
+ Spans missing timestamps are treated as non-matching when a range is used,
47
+ because we cannot prove they belong to the requested interval.
48
+ """
49
+
50
+ if self.start is None and self.end is None:
51
+ return True
52
+ if not start or not end:
53
+ return False
54
+
55
+ span_start = _parse_iso(start)
56
+ span_end = _parse_iso(end)
57
+ range_start = _parse_iso(self.start) if self.start else None
58
+ range_end = _parse_iso(self.end) if self.end else None
59
+
60
+ # Intersection test for closed ranges:
61
+ # span_end >= range_start AND span_start <= range_end
62
+ if range_start is not None and span_end < range_start:
63
+ return False
64
+ if range_end is not None and span_start > range_end:
65
+ return False
66
+ return True
67
+
68
+
69
+ SpanId = Union[str, int] # artifact_id or 1-based index into _runtime.memory_spans
70
+
71
+
72
+ class ActiveContextPolicy:
73
+ """Runtime-owned utilities for memory spans and active context."""
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ run_store: RunStore,
79
+ artifact_store: ArtifactStore,
80
+ ) -> None:
81
+ self._run_store = run_store
82
+ self._artifact_store = artifact_store
83
+
84
+ # ------------------------------------------------------------------
85
+ # Spans: list + filter
86
+ # ------------------------------------------------------------------
87
+
88
+ def list_memory_spans(self, run_id: str) -> List[Dict[str, Any]]:
89
+ """Return the run's archived span index (`_runtime.memory_spans`)."""
90
+ run = self._require_run(run_id)
91
+ return self.list_memory_spans_from_run(run)
92
+
93
+ @staticmethod
94
+ def list_memory_spans_from_run(run: RunState) -> List[Dict[str, Any]]:
95
+ """Return the archived span index (`_runtime.memory_spans`) from an in-memory RunState."""
96
+ runtime_ns = get_runtime(run.vars)
97
+ spans = runtime_ns.get("memory_spans")
98
+ if not isinstance(spans, list):
99
+ return []
100
+ out: List[Dict[str, Any]] = []
101
+ for s in spans:
102
+ if isinstance(s, dict):
103
+ out.append(dict(s))
104
+ out.sort(key=lambda d: str(d.get("created_at") or ""), reverse=True)
105
+ return out
106
+
107
+ def filter_spans(
108
+ self,
109
+ run_id: str,
110
+ *,
111
+ time_range: Optional[TimeRange] = None,
112
+ tags: Optional[Dict[str, Any]] = None,
113
+ tags_mode: str = "all",
114
+ authors: Optional[List[str]] = None,
115
+ locations: Optional[List[str]] = None,
116
+ query: Optional[str] = None,
117
+ limit: int = 1000,
118
+ ) -> List[Dict[str, Any]]:
119
+ """Filter archived spans by time range, tags, and a basic keyword query.
120
+
121
+ Notes:
122
+ - This is a metadata filter, not semantic retrieval.
123
+ - `query` matches against summary messages (if present) and metadata tags.
124
+ """
125
+ run = self._require_run(run_id)
126
+ return self.filter_spans_from_run(
127
+ run,
128
+ artifact_store=self._artifact_store,
129
+ time_range=time_range,
130
+ tags=tags,
131
+ tags_mode=tags_mode,
132
+ authors=authors,
133
+ locations=locations,
134
+ query=query,
135
+ limit=limit,
136
+ )
137
+
138
+ @staticmethod
139
+ def filter_spans_from_run(
140
+ run: RunState,
141
+ *,
142
+ artifact_store: ArtifactStore,
143
+ time_range: Optional[TimeRange] = None,
144
+ tags: Optional[Dict[str, Any]] = None,
145
+ tags_mode: str = "all",
146
+ authors: Optional[List[str]] = None,
147
+ locations: Optional[List[str]] = None,
148
+ query: Optional[str] = None,
149
+ limit: int = 1000,
150
+ ) -> List[Dict[str, Any]]:
151
+ """Like `filter_spans`, but operates on an in-memory RunState."""
152
+ spans = ActiveContextPolicy.list_memory_spans_from_run(run)
153
+ if not spans:
154
+ return []
155
+
156
+ summary_by_artifact = ActiveContextPolicy.summary_text_by_artifact_id_from_run(run)
157
+ lowered_query = (query or "").strip().lower() if query else None
158
+
159
+ # Notes are small; for keyword filtering we can load their text safely.
160
+ # IMPORTANT: only do this when we actually have a query (avoids unnecessary I/O).
161
+ #
162
+ # Also include summary text from the note's linked conversation span(s) when available,
163
+ # so searching for a topic can surface both the span *and* the derived note.
164
+ note_by_artifact: Dict[str, str] = {}
165
+ if lowered_query:
166
+ for span in spans:
167
+ if not isinstance(span, dict):
168
+ continue
169
+ if str(span.get("kind") or "") != "memory_note":
170
+ continue
171
+ artifact_id = str(span.get("artifact_id") or "")
172
+ if not artifact_id:
173
+ continue
174
+ try:
175
+ payload = artifact_store.load_json(artifact_id)
176
+ except Exception:
177
+ continue
178
+ if not isinstance(payload, dict):
179
+ continue
180
+
181
+ parts: list[str] = []
182
+ note = payload.get("note")
183
+ if isinstance(note, str) and note.strip():
184
+ parts.append(note.strip())
185
+
186
+ sources = payload.get("sources")
187
+ if isinstance(sources, dict):
188
+ span_ids = sources.get("span_ids")
189
+ if isinstance(span_ids, list):
190
+ for sid in span_ids:
191
+ if not isinstance(sid, str) or not sid.strip():
192
+ continue
193
+ summary = summary_by_artifact.get(sid.strip())
194
+ if isinstance(summary, str) and summary.strip():
195
+ parts.append(summary.strip())
196
+
197
+ if parts:
198
+ note_by_artifact[artifact_id] = "\n".join(parts).strip()
199
+
200
+ def _artifact_meta(artifact_id: str) -> Optional[ArtifactMetadata]:
201
+ try:
202
+ return artifact_store.get_metadata(artifact_id)
203
+ except Exception:
204
+ return None
205
+
206
+ mode = str(tags_mode or "all").strip().lower() or "all"
207
+ if mode in {"and"}:
208
+ mode = "all"
209
+ if mode in {"or"}:
210
+ mode = "any"
211
+ if mode not in {"all", "any"}:
212
+ mode = "all"
213
+
214
+ authors_norm = {str(a).strip().lower() for a in (authors or []) if isinstance(a, str) and a.strip()}
215
+ locations_norm = {str(l).strip().lower() for l in (locations or []) if isinstance(l, str) and l.strip()}
216
+
217
+ out: List[Dict[str, Any]] = []
218
+ for span in spans:
219
+ artifact_id = str(span.get("artifact_id") or "")
220
+ if not artifact_id:
221
+ continue
222
+
223
+ if time_range is not None:
224
+ if not time_range.contains(
225
+ start=span.get("from_timestamp"),
226
+ end=span.get("to_timestamp"),
227
+ ):
228
+ continue
229
+
230
+ meta = _artifact_meta(artifact_id)
231
+
232
+ if tags:
233
+ if not ActiveContextPolicy._tags_match(span=span, meta=meta, required=tags, mode=mode):
234
+ continue
235
+
236
+ if authors_norm:
237
+ created_by = span.get("created_by")
238
+ author = str(created_by).strip().lower() if isinstance(created_by, str) else ""
239
+ if not author or author not in authors_norm:
240
+ continue
241
+
242
+ if locations_norm:
243
+ loc = span.get("location")
244
+ loc_str = str(loc).strip().lower() if isinstance(loc, str) else ""
245
+ if not loc_str:
246
+ # Fallback: allow location to be stored as a tag.
247
+ span_tags = span.get("tags") if isinstance(span.get("tags"), dict) else {}
248
+ tag_loc = span_tags.get("location") if isinstance(span_tags, dict) else None
249
+ loc_str = str(tag_loc).strip().lower() if isinstance(tag_loc, str) else ""
250
+ if not loc_str or loc_str not in locations_norm:
251
+ continue
252
+
253
+ if lowered_query:
254
+ haystack = ActiveContextPolicy._span_haystack(
255
+ span=span,
256
+ meta=meta,
257
+ summary=summary_by_artifact.get(artifact_id),
258
+ note=note_by_artifact.get(artifact_id),
259
+ )
260
+ if lowered_query not in haystack:
261
+ continue
262
+
263
+ out.append(span)
264
+ if len(out) >= limit:
265
+ break
266
+ return out
267
+
268
+ # ------------------------------------------------------------------
269
+ # Rehydration: stored span -> active context
270
+ # ------------------------------------------------------------------
271
+
272
+ def rehydrate_into_context(
273
+ self,
274
+ run_id: str,
275
+ *,
276
+ span_ids: Sequence[SpanId],
277
+ placement: str = "after_summary",
278
+ dedup_by: str = "message_id",
279
+ max_messages: Optional[int] = None,
280
+ ) -> Dict[str, Any]:
281
+ """Rehydrate archived span(s) into `context.messages` and persist the run.
282
+
283
+ Args:
284
+ run_id: Run to mutate.
285
+ span_ids: Sequence of artifact_ids or 1-based indices into `_runtime.memory_spans`.
286
+ placement: Where to insert. Supported: "after_summary" (default), "after_system", "end".
287
+ dedup_by: Dedup key (default: metadata.message_id).
288
+ max_messages: Optional cap on inserted messages across all spans (None = unlimited).
289
+ """
290
+ run = self._require_run(run_id)
291
+ out = self.rehydrate_into_context_from_run(
292
+ run,
293
+ span_ids=span_ids,
294
+ placement=placement,
295
+ dedup_by=dedup_by,
296
+ max_messages=max_messages,
297
+ )
298
+
299
+ self._run_store.save(run)
300
+ return out
301
+
302
+ def rehydrate_into_context_from_run(
303
+ self,
304
+ run: RunState,
305
+ *,
306
+ span_ids: Sequence[SpanId],
307
+ placement: str = "after_summary",
308
+ dedup_by: str = "message_id",
309
+ max_messages: Optional[int] = None,
310
+ ) -> Dict[str, Any]:
311
+ """Like `rehydrate_into_context`, but operates on an in-memory RunState.
312
+
313
+ This mutates `run.vars["context"]["messages"]` (and `run.output["messages"]` when present),
314
+ but does NOT persist the run.
315
+ """
316
+ spans = self.list_memory_spans_from_run(run)
317
+ resolved_artifacts: List[str] = self.resolve_span_ids_from_spans(span_ids, spans)
318
+ if not resolved_artifacts:
319
+ return {"inserted": 0, "skipped": 0, "artifacts": []}
320
+
321
+ ctx = get_context(run.vars)
322
+ active = ctx.get("messages")
323
+ if not isinstance(active, list):
324
+ active = []
325
+
326
+ inserted_total = 0
327
+ skipped_total = 0
328
+ per_artifact: List[Dict[str, Any]] = []
329
+
330
+ # Build a dedup set for active context.
331
+ existing_keys = self._collect_message_keys(active, dedup_by=dedup_by)
332
+
333
+ # Normalize cap.
334
+ try:
335
+ max_messages_int = int(max_messages) if max_messages is not None else None
336
+ except Exception:
337
+ max_messages_int = None
338
+ if max_messages_int is not None and max_messages_int < 0:
339
+ max_messages_int = None
340
+
341
+ def _preview_inserted(messages: Sequence[Dict[str, Any]]) -> str:
342
+ """Build a small, human-friendly preview for UI/observability."""
343
+ if not messages:
344
+ return ""
345
+ lines: list[str] = []
346
+ for m in messages[:3]:
347
+ if not isinstance(m, dict):
348
+ continue
349
+ role = str(m.get("role") or "").strip()
350
+ content = str(m.get("content") or "").strip()
351
+ if not content:
352
+ continue
353
+ # If the synthetic memory note marker is present, keep it as-is (no "role:" prefix).
354
+ if content.startswith("[MEMORY NOTE]"):
355
+ lines.append(content)
356
+ else:
357
+ lines.append(f"{role}: {content}" if role else content)
358
+ text = "\n".join([l for l in lines if l]).strip()
359
+ if len(text) > 360:
360
+ return text[:357] + "…"
361
+ return text
362
+
363
+ for artifact_id in resolved_artifacts:
364
+ if max_messages_int is not None and inserted_total >= max_messages_int:
365
+ # Deterministic: stop inserting once the global cap is reached.
366
+ per_artifact.append(
367
+ {"artifact_id": artifact_id, "inserted": 0, "skipped": 0, "error": "max_messages"}
368
+ )
369
+ continue
370
+
371
+ archived = self._artifact_store.load_json(artifact_id)
372
+ archived_messages = archived.get("messages") if isinstance(archived, dict) else None
373
+
374
+ # Support rehydrating memory_note spans into context as a single synthetic message.
375
+ # This is the most practical "make recalled memory LLM-visible" behavior for
376
+ # visual workflows (users expect "Recall into context" to work with notes).
377
+ if not isinstance(archived_messages, list):
378
+ note_text = None
379
+ if isinstance(archived, dict):
380
+ raw_note = archived.get("note")
381
+ if isinstance(raw_note, str) and raw_note.strip():
382
+ note_text = raw_note.strip()
383
+ if note_text is not None:
384
+ archived_messages = [
385
+ {
386
+ "role": "system",
387
+ "content": f"[MEMORY NOTE]\n{note_text}",
388
+ "timestamp": str(archived.get("created_at") or "") if isinstance(archived, dict) else "",
389
+ "metadata": {
390
+ "kind": "memory_note",
391
+ "rehydrated": True,
392
+ "source_artifact_id": artifact_id,
393
+ "message_id": f"memory_note:{artifact_id}",
394
+ },
395
+ }
396
+ ]
397
+ else:
398
+ per_artifact.append(
399
+ {"artifact_id": artifact_id, "inserted": 0, "skipped": 0, "error": "missing_messages"}
400
+ )
401
+ continue
402
+
403
+ to_insert: List[Dict[str, Any]] = []
404
+ skipped = 0
405
+ for m in archived_messages:
406
+ if not isinstance(m, dict):
407
+ continue
408
+ m_copy = dict(m)
409
+ meta_copy = m_copy.get("metadata")
410
+ if not isinstance(meta_copy, dict):
411
+ meta_copy = {}
412
+ m_copy["metadata"] = meta_copy
413
+ # Mark as rehydrated view (do not mutate the archived artifact payload).
414
+ meta_copy.setdefault("rehydrated", True)
415
+ meta_copy.setdefault("source_artifact_id", artifact_id)
416
+
417
+ key = self._message_key(m_copy, dedup_by=dedup_by)
418
+ if key and key in existing_keys:
419
+ skipped += 1
420
+ continue
421
+ if key:
422
+ existing_keys.add(key)
423
+ to_insert.append(m_copy)
424
+
425
+ if max_messages_int is not None:
426
+ remaining = max(0, max_messages_int - inserted_total)
427
+ if remaining <= 0:
428
+ per_artifact.append(
429
+ {"artifact_id": artifact_id, "inserted": 0, "skipped": 0, "error": "max_messages"}
430
+ )
431
+ continue
432
+ if len(to_insert) > remaining:
433
+ to_insert = to_insert[:remaining]
434
+
435
+ idx = self._insertion_index(active, artifact_id=artifact_id, placement=placement)
436
+ active[idx:idx] = to_insert
437
+
438
+ inserted_total += len(to_insert)
439
+ skipped_total += skipped
440
+ entry: Dict[str, Any] = {"artifact_id": artifact_id, "inserted": len(to_insert), "skipped": skipped}
441
+ preview = _preview_inserted(to_insert)
442
+ if preview:
443
+ entry["preview"] = preview
444
+ per_artifact.append(entry)
445
+
446
+ ctx["messages"] = active
447
+ if isinstance(getattr(run, "output", None), dict):
448
+ run.output["messages"] = active
449
+
450
+ return {"inserted": inserted_total, "skipped": skipped_total, "artifacts": per_artifact}
451
+
452
+ # ------------------------------------------------------------------
453
+ # Deriving what the LLM sees
454
+ # ------------------------------------------------------------------
455
+
456
+ def select_active_messages_for_llm(
457
+ self,
458
+ run_id: str,
459
+ *,
460
+ max_history_messages: Optional[int] = None,
461
+ ) -> List[Dict[str, Any]]:
462
+ """Return the active-context view that should be sent to an LLM.
463
+
464
+ This does NOT mutate the run; it returns a derived view.
465
+
466
+ Rules (minimal, stable):
467
+ - Always preserve system messages
468
+ - Apply `max_history_messages` to non-system messages only
469
+ - If max_history_messages is None, read it from `_limits.max_history_messages`
470
+ """
471
+ run = self._require_run(run_id)
472
+ return self.select_active_messages_for_llm_from_run(
473
+ run,
474
+ max_history_messages=max_history_messages,
475
+ )
476
+
477
+ @staticmethod
478
+ def select_active_messages_for_llm_from_run(
479
+ run: RunState,
480
+ *,
481
+ max_history_messages: Optional[int] = None,
482
+ ) -> List[Dict[str, Any]]:
483
+ """Like `select_active_messages_for_llm`, but operates on an in-memory RunState."""
484
+ ctx = get_context(run.vars)
485
+ messages = ctx.get("messages")
486
+ if not isinstance(messages, list):
487
+ return []
488
+
489
+ if max_history_messages is None:
490
+ limits = get_limits(run.vars)
491
+ try:
492
+ max_history_messages = int(limits.get("max_history_messages", -1))
493
+ except Exception:
494
+ max_history_messages = -1
495
+
496
+ return ActiveContextPolicy.select_messages_view(messages, max_history_messages=max_history_messages)
497
+
498
+ @staticmethod
499
+ def select_messages_view(
500
+ messages: Sequence[Any],
501
+ *,
502
+ max_history_messages: int,
503
+ ) -> List[Dict[str, Any]]:
504
+ """Select an LLM-visible view from a message list under a simple history limit."""
505
+ system_msgs: List[Dict[str, Any]] = [m for m in messages if isinstance(m, dict) and m.get("role") == "system"]
506
+ convo_msgs: List[Dict[str, Any]] = [m for m in messages if isinstance(m, dict) and m.get("role") != "system"]
507
+
508
+ if max_history_messages == -1:
509
+ return system_msgs + convo_msgs
510
+ if max_history_messages < 0:
511
+ return system_msgs + convo_msgs
512
+ if max_history_messages == 0:
513
+ return system_msgs
514
+ return system_msgs + convo_msgs[-max_history_messages:]
515
+
516
+ # ------------------------------------------------------------------
517
+ # Internals
518
+ # ------------------------------------------------------------------
519
+
520
+ def _require_run(self, run_id: str) -> RunState:
521
+ run = self._run_store.load(run_id)
522
+ if run is None:
523
+ raise KeyError(f"Unknown run_id: {run_id}")
524
+ return run
525
+
526
+ @staticmethod
527
+ def resolve_span_ids_from_spans(span_ids: Sequence[SpanId], spans: Sequence[Dict[str, Any]]) -> List[str]:
528
+ resolved: List[str] = []
529
+ for sid in span_ids:
530
+ if isinstance(sid, int):
531
+ idx = sid - 1
532
+ if 0 <= idx < len(spans):
533
+ artifact_id = spans[idx].get("artifact_id")
534
+ if isinstance(artifact_id, str) and artifact_id:
535
+ resolved.append(artifact_id)
536
+ continue
537
+ if isinstance(sid, str):
538
+ s = sid.strip()
539
+ if not s:
540
+ continue
541
+ # If it's a digit string, treat as 1-based index.
542
+ if s.isdigit():
543
+ idx = int(s) - 1
544
+ if 0 <= idx < len(spans):
545
+ artifact_id = spans[idx].get("artifact_id")
546
+ if isinstance(artifact_id, str) and artifact_id:
547
+ resolved.append(artifact_id)
548
+ continue
549
+ resolved.append(s)
550
+ # Preserve order but dedup
551
+ seen = set()
552
+ out: List[str] = []
553
+ for a in resolved:
554
+ if a in seen:
555
+ continue
556
+ seen.add(a)
557
+ out.append(a)
558
+ return out
559
+
560
+ def _insertion_index(self, active: List[Any], *, artifact_id: str, placement: str) -> int:
561
+ if placement == "end":
562
+ return len(active)
563
+
564
+ if placement == "after_system":
565
+ i = 0
566
+ while i < len(active):
567
+ m = active[i]
568
+ if not isinstance(m, dict) or m.get("role") != "system":
569
+ break
570
+ i += 1
571
+ return i
572
+
573
+ # default: after_summary
574
+ for i, m in enumerate(active):
575
+ if not isinstance(m, dict):
576
+ continue
577
+ if m.get("role") != "system":
578
+ continue
579
+ meta = m.get("metadata") if isinstance(m.get("metadata"), dict) else {}
580
+ if meta.get("kind") == "memory_summary" and meta.get("source_artifact_id") == artifact_id:
581
+ return i + 1
582
+
583
+ # Fallback: after system messages.
584
+ return self._insertion_index(active, artifact_id=artifact_id, placement="after_system")
585
+
586
+ def _collect_message_keys(self, messages: Iterable[Any], *, dedup_by: str) -> set[str]:
587
+ keys: set[str] = set()
588
+ for m in messages:
589
+ if not isinstance(m, dict):
590
+ continue
591
+ key = self._message_key(m, dedup_by=dedup_by)
592
+ if key:
593
+ keys.add(key)
594
+ return keys
595
+
596
+ def _message_key(self, message: Dict[str, Any], *, dedup_by: str) -> Optional[str]:
597
+ if dedup_by == "message_id":
598
+ meta = message.get("metadata")
599
+ if isinstance(meta, dict):
600
+ mid = meta.get("message_id")
601
+ if isinstance(mid, str) and mid:
602
+ return mid
603
+ return None
604
+ # Unknown dedup key: disable dedup
605
+ return None
606
+
607
+ @staticmethod
608
+ def summary_text_by_artifact_id_from_run(run: RunState) -> Dict[str, str]:
609
+ ctx = get_context(run.vars)
610
+ messages = ctx.get("messages")
611
+ if not isinstance(messages, list):
612
+ return {}
613
+ out: Dict[str, str] = {}
614
+ for m in messages:
615
+ if not isinstance(m, dict):
616
+ continue
617
+ if m.get("role") != "system":
618
+ continue
619
+ meta = m.get("metadata")
620
+ if not isinstance(meta, dict):
621
+ continue
622
+ if meta.get("kind") != "memory_summary":
623
+ continue
624
+ artifact_id = meta.get("source_artifact_id")
625
+ if isinstance(artifact_id, str) and artifact_id:
626
+ out[artifact_id] = str(m.get("content") or "")
627
+ return out
628
+
629
+ @staticmethod
630
+ def _span_haystack(
631
+ *,
632
+ span: Dict[str, Any],
633
+ meta: Optional[ArtifactMetadata],
634
+ summary: Optional[str],
635
+ note: Optional[str] = None,
636
+ ) -> str:
637
+ parts: List[str] = []
638
+ if summary:
639
+ parts.append(summary)
640
+ if note:
641
+ parts.append(note)
642
+ for k in ("kind", "compression_mode", "focus", "from_timestamp", "to_timestamp", "created_by", "location"):
643
+ v = span.get(k)
644
+ if isinstance(v, str) and v:
645
+ parts.append(v)
646
+ # Span tags are persisted in run vars (topic/person/project, etc).
647
+ span_tags = span.get("tags")
648
+ if isinstance(span_tags, dict):
649
+ for k, v in span_tags.items():
650
+ if isinstance(v, str) and v:
651
+ parts.append(str(k))
652
+ parts.append(v)
653
+
654
+ if meta is not None:
655
+ parts.append(meta.content_type or "")
656
+ for k, v in (meta.tags or {}).items():
657
+ parts.append(k)
658
+ parts.append(v)
659
+ return " ".join(parts).lower()
660
+
661
+ @staticmethod
662
+ def _tags_match(
663
+ *,
664
+ span: Dict[str, Any],
665
+ meta: Optional[ArtifactMetadata],
666
+ required: Dict[str, Any],
667
+ mode: str = "all",
668
+ ) -> bool:
669
+ def _norm(s: str) -> str:
670
+ return str(s or "").strip().lower()
671
+
672
+ tags: Dict[str, str] = {}
673
+ if meta is not None and meta.tags:
674
+ for k, v in meta.tags.items():
675
+ if not isinstance(k, str) or not isinstance(v, str):
676
+ continue
677
+ kk = _norm(k)
678
+ vv = _norm(v)
679
+ if kk and vv and kk not in tags:
680
+ tags[kk] = vv
681
+
682
+ span_tags = span.get("tags")
683
+ if isinstance(span_tags, dict):
684
+ for k, v in span_tags.items():
685
+ if not isinstance(k, str):
686
+ continue
687
+ kk = _norm(k)
688
+ if not kk or kk in tags:
689
+ continue
690
+ if isinstance(v, str):
691
+ vv = _norm(v)
692
+ if vv:
693
+ tags[kk] = vv
694
+
695
+ # Derived tags from span ref (cheap and keeps filtering usable even
696
+ # if artifact metadata is missing).
697
+ for k in ("kind", "compression_mode", "focus"):
698
+ v = span.get(k)
699
+ if isinstance(v, str) and v:
700
+ kk = _norm(k)
701
+ if kk and kk not in tags:
702
+ tags[kk] = _norm(v)
703
+
704
+ required_norm: Dict[str, List[str]] = {}
705
+ for k, v in (required or {}).items():
706
+ if not isinstance(k, str):
707
+ continue
708
+ kk = _norm(k)
709
+ if not kk or kk == "kind":
710
+ continue
711
+ values: List[str] = []
712
+ if isinstance(v, str):
713
+ vv = _norm(v)
714
+ if vv:
715
+ values.append(vv)
716
+ elif isinstance(v, (list, tuple)):
717
+ for it in v:
718
+ if isinstance(it, str) and it.strip():
719
+ values.append(_norm(it))
720
+ if values:
721
+ # preserve order but dedup
722
+ seen: set[str] = set()
723
+ deduped = []
724
+ for x in values:
725
+ if x in seen:
726
+ continue
727
+ seen.add(x)
728
+ deduped.append(x)
729
+ required_norm[kk] = deduped
730
+
731
+ if not required_norm:
732
+ return True
733
+
734
+ def _key_matches(key: str) -> bool:
735
+ cand = tags.get(key)
736
+ if cand is None:
737
+ return False
738
+ allowed = required_norm.get(key) or []
739
+ return cand in allowed
740
+
741
+ op = str(mode or "all").strip().lower() or "all"
742
+ if op not in {"all", "any"}:
743
+ op = "all"
744
+ if op == "any":
745
+ return any(_key_matches(k) for k in required_norm.keys())
746
+ return all(_key_matches(k) for k in required_norm.keys())