AbstractRuntime 0.2.0__py3-none-any.whl → 0.4.1__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 (77) hide show
  1. abstractruntime/__init__.py +83 -3
  2. abstractruntime/core/config.py +82 -2
  3. abstractruntime/core/event_keys.py +62 -0
  4. abstractruntime/core/models.py +17 -1
  5. abstractruntime/core/policy.py +74 -3
  6. abstractruntime/core/runtime.py +3334 -28
  7. abstractruntime/core/vars.py +103 -2
  8. abstractruntime/evidence/__init__.py +10 -0
  9. abstractruntime/evidence/recorder.py +325 -0
  10. abstractruntime/history_bundle.py +772 -0
  11. abstractruntime/integrations/abstractcore/__init__.py +6 -0
  12. abstractruntime/integrations/abstractcore/constants.py +19 -0
  13. abstractruntime/integrations/abstractcore/default_tools.py +258 -0
  14. abstractruntime/integrations/abstractcore/effect_handlers.py +2622 -32
  15. abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
  16. abstractruntime/integrations/abstractcore/factory.py +149 -16
  17. abstractruntime/integrations/abstractcore/llm_client.py +891 -55
  18. abstractruntime/integrations/abstractcore/mcp_worker.py +587 -0
  19. abstractruntime/integrations/abstractcore/observability.py +80 -0
  20. abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
  21. abstractruntime/integrations/abstractcore/summarizer.py +154 -0
  22. abstractruntime/integrations/abstractcore/tool_executor.py +509 -31
  23. abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
  24. abstractruntime/integrations/abstractmemory/__init__.py +3 -0
  25. abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
  26. abstractruntime/memory/__init__.py +21 -0
  27. abstractruntime/memory/active_context.py +751 -0
  28. abstractruntime/memory/active_memory.py +452 -0
  29. abstractruntime/memory/compaction.py +105 -0
  30. abstractruntime/memory/kg_packets.py +164 -0
  31. abstractruntime/memory/memact_composer.py +175 -0
  32. abstractruntime/memory/recall_levels.py +163 -0
  33. abstractruntime/memory/token_budget.py +86 -0
  34. abstractruntime/rendering/__init__.py +17 -0
  35. abstractruntime/rendering/agent_trace_report.py +256 -0
  36. abstractruntime/rendering/json_stringify.py +136 -0
  37. abstractruntime/scheduler/scheduler.py +93 -2
  38. abstractruntime/storage/__init__.py +7 -2
  39. abstractruntime/storage/artifacts.py +175 -32
  40. abstractruntime/storage/base.py +17 -1
  41. abstractruntime/storage/commands.py +339 -0
  42. abstractruntime/storage/in_memory.py +41 -1
  43. abstractruntime/storage/json_files.py +210 -14
  44. abstractruntime/storage/observable.py +136 -0
  45. abstractruntime/storage/offloading.py +433 -0
  46. abstractruntime/storage/sqlite.py +836 -0
  47. abstractruntime/visualflow_compiler/__init__.py +29 -0
  48. abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
  49. abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
  50. abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
  51. abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
  52. abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
  53. abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
  54. abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
  55. abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
  56. abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
  57. abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
  58. abstractruntime/visualflow_compiler/compiler.py +3832 -0
  59. abstractruntime/visualflow_compiler/flow.py +247 -0
  60. abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
  61. abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
  62. abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
  63. abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
  64. abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
  65. abstractruntime/visualflow_compiler/visual/models.py +211 -0
  66. abstractruntime/workflow_bundle/__init__.py +52 -0
  67. abstractruntime/workflow_bundle/models.py +236 -0
  68. abstractruntime/workflow_bundle/packer.py +317 -0
  69. abstractruntime/workflow_bundle/reader.py +87 -0
  70. abstractruntime/workflow_bundle/registry.py +587 -0
  71. abstractruntime-0.4.1.dist-info/METADATA +177 -0
  72. abstractruntime-0.4.1.dist-info/RECORD +86 -0
  73. abstractruntime-0.4.1.dist-info/entry_points.txt +2 -0
  74. abstractruntime-0.2.0.dist-info/METADATA +0 -163
  75. abstractruntime-0.2.0.dist-info/RECORD +0 -32
  76. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
  77. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,751 @@
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
+ #[WARNING:TRUNCATION] bounded preview for UI/observability
361
+ marker = "… (truncated)"
362
+ keep = max(0, 360 - len(marker))
363
+ if keep <= 0:
364
+ return marker[:360].rstrip()
365
+ return text[:keep].rstrip() + marker
366
+ return text
367
+
368
+ for artifact_id in resolved_artifacts:
369
+ if max_messages_int is not None and inserted_total >= max_messages_int:
370
+ # Deterministic: stop inserting once the global cap is reached.
371
+ per_artifact.append(
372
+ {"artifact_id": artifact_id, "inserted": 0, "skipped": 0, "error": "max_messages"}
373
+ )
374
+ continue
375
+
376
+ archived = self._artifact_store.load_json(artifact_id)
377
+ archived_messages = archived.get("messages") if isinstance(archived, dict) else None
378
+
379
+ # Support rehydrating memory_note spans into context as a single synthetic message.
380
+ # This is the most practical "make recalled memory LLM-visible" behavior for
381
+ # visual workflows (users expect "Recall into context" to work with notes).
382
+ if not isinstance(archived_messages, list):
383
+ note_text = None
384
+ if isinstance(archived, dict):
385
+ raw_note = archived.get("note")
386
+ if isinstance(raw_note, str) and raw_note.strip():
387
+ note_text = raw_note.strip()
388
+ if note_text is not None:
389
+ archived_messages = [
390
+ {
391
+ "role": "system",
392
+ "content": f"[MEMORY NOTE]\n{note_text}",
393
+ "timestamp": str(archived.get("created_at") or "") if isinstance(archived, dict) else "",
394
+ "metadata": {
395
+ "kind": "memory_note",
396
+ "rehydrated": True,
397
+ "source_artifact_id": artifact_id,
398
+ "message_id": f"memory_note:{artifact_id}",
399
+ },
400
+ }
401
+ ]
402
+ else:
403
+ per_artifact.append(
404
+ {"artifact_id": artifact_id, "inserted": 0, "skipped": 0, "error": "missing_messages"}
405
+ )
406
+ continue
407
+
408
+ to_insert: List[Dict[str, Any]] = []
409
+ skipped = 0
410
+ for m in archived_messages:
411
+ if not isinstance(m, dict):
412
+ continue
413
+ m_copy = dict(m)
414
+ meta_copy = m_copy.get("metadata")
415
+ if not isinstance(meta_copy, dict):
416
+ meta_copy = {}
417
+ m_copy["metadata"] = meta_copy
418
+ # Mark as rehydrated view (do not mutate the archived artifact payload).
419
+ meta_copy.setdefault("rehydrated", True)
420
+ meta_copy.setdefault("source_artifact_id", artifact_id)
421
+
422
+ key = self._message_key(m_copy, dedup_by=dedup_by)
423
+ if key and key in existing_keys:
424
+ skipped += 1
425
+ continue
426
+ if key:
427
+ existing_keys.add(key)
428
+ to_insert.append(m_copy)
429
+
430
+ if max_messages_int is not None:
431
+ remaining = max(0, max_messages_int - inserted_total)
432
+ if remaining <= 0:
433
+ per_artifact.append(
434
+ {"artifact_id": artifact_id, "inserted": 0, "skipped": 0, "error": "max_messages"}
435
+ )
436
+ continue
437
+ if len(to_insert) > remaining:
438
+ to_insert = to_insert[:remaining]
439
+
440
+ idx = self._insertion_index(active, artifact_id=artifact_id, placement=placement)
441
+ active[idx:idx] = to_insert
442
+
443
+ inserted_total += len(to_insert)
444
+ skipped_total += skipped
445
+ entry: Dict[str, Any] = {"artifact_id": artifact_id, "inserted": len(to_insert), "skipped": skipped}
446
+ preview = _preview_inserted(to_insert)
447
+ if preview:
448
+ entry["preview"] = preview
449
+ per_artifact.append(entry)
450
+
451
+ ctx["messages"] = active
452
+ if isinstance(getattr(run, "output", None), dict):
453
+ run.output["messages"] = active
454
+
455
+ return {"inserted": inserted_total, "skipped": skipped_total, "artifacts": per_artifact}
456
+
457
+ # ------------------------------------------------------------------
458
+ # Deriving what the LLM sees
459
+ # ------------------------------------------------------------------
460
+
461
+ def select_active_messages_for_llm(
462
+ self,
463
+ run_id: str,
464
+ *,
465
+ max_history_messages: Optional[int] = None,
466
+ ) -> List[Dict[str, Any]]:
467
+ """Return the active-context view that should be sent to an LLM.
468
+
469
+ This does NOT mutate the run; it returns a derived view.
470
+
471
+ Rules (minimal, stable):
472
+ - Always preserve system messages
473
+ - Apply `max_history_messages` to non-system messages only
474
+ - If max_history_messages is None, read it from `_limits.max_history_messages`
475
+ """
476
+ run = self._require_run(run_id)
477
+ return self.select_active_messages_for_llm_from_run(
478
+ run,
479
+ max_history_messages=max_history_messages,
480
+ )
481
+
482
+ @staticmethod
483
+ def select_active_messages_for_llm_from_run(
484
+ run: RunState,
485
+ *,
486
+ max_history_messages: Optional[int] = None,
487
+ ) -> List[Dict[str, Any]]:
488
+ """Like `select_active_messages_for_llm`, but operates on an in-memory RunState."""
489
+ ctx = get_context(run.vars)
490
+ messages = ctx.get("messages")
491
+ if not isinstance(messages, list):
492
+ return []
493
+
494
+ if max_history_messages is None:
495
+ limits = get_limits(run.vars)
496
+ try:
497
+ max_history_messages = int(limits.get("max_history_messages", -1))
498
+ except Exception:
499
+ max_history_messages = -1
500
+
501
+ return ActiveContextPolicy.select_messages_view(messages, max_history_messages=max_history_messages)
502
+
503
+ @staticmethod
504
+ def select_messages_view(
505
+ messages: Sequence[Any],
506
+ *,
507
+ max_history_messages: int,
508
+ ) -> List[Dict[str, Any]]:
509
+ """Select an LLM-visible view from a message list under a simple history limit."""
510
+ system_msgs: List[Dict[str, Any]] = [m for m in messages if isinstance(m, dict) and m.get("role") == "system"]
511
+ convo_msgs: List[Dict[str, Any]] = [m for m in messages if isinstance(m, dict) and m.get("role") != "system"]
512
+
513
+ if max_history_messages == -1:
514
+ return system_msgs + convo_msgs
515
+ if max_history_messages < 0:
516
+ return system_msgs + convo_msgs
517
+ if max_history_messages == 0:
518
+ return system_msgs
519
+ return system_msgs + convo_msgs[-max_history_messages:]
520
+
521
+ # ------------------------------------------------------------------
522
+ # Internals
523
+ # ------------------------------------------------------------------
524
+
525
+ def _require_run(self, run_id: str) -> RunState:
526
+ run = self._run_store.load(run_id)
527
+ if run is None:
528
+ raise KeyError(f"Unknown run_id: {run_id}")
529
+ return run
530
+
531
+ @staticmethod
532
+ def resolve_span_ids_from_spans(span_ids: Sequence[SpanId], spans: Sequence[Dict[str, Any]]) -> List[str]:
533
+ resolved: List[str] = []
534
+ for sid in span_ids:
535
+ if isinstance(sid, int):
536
+ idx = sid - 1
537
+ if 0 <= idx < len(spans):
538
+ artifact_id = spans[idx].get("artifact_id")
539
+ if isinstance(artifact_id, str) and artifact_id:
540
+ resolved.append(artifact_id)
541
+ continue
542
+ if isinstance(sid, str):
543
+ s = sid.strip()
544
+ if not s:
545
+ continue
546
+ # If it's a digit string, treat as 1-based index.
547
+ if s.isdigit():
548
+ idx = int(s) - 1
549
+ if 0 <= idx < len(spans):
550
+ artifact_id = spans[idx].get("artifact_id")
551
+ if isinstance(artifact_id, str) and artifact_id:
552
+ resolved.append(artifact_id)
553
+ continue
554
+ resolved.append(s)
555
+ # Preserve order but dedup
556
+ seen = set()
557
+ out: List[str] = []
558
+ for a in resolved:
559
+ if a in seen:
560
+ continue
561
+ seen.add(a)
562
+ out.append(a)
563
+ return out
564
+
565
+ def _insertion_index(self, active: List[Any], *, artifact_id: str, placement: str) -> int:
566
+ if placement == "end":
567
+ return len(active)
568
+
569
+ if placement == "after_system":
570
+ i = 0
571
+ while i < len(active):
572
+ m = active[i]
573
+ if not isinstance(m, dict) or m.get("role") != "system":
574
+ break
575
+ i += 1
576
+ return i
577
+
578
+ # default: after_summary
579
+ for i, m in enumerate(active):
580
+ if not isinstance(m, dict):
581
+ continue
582
+ if m.get("role") != "system":
583
+ continue
584
+ meta = m.get("metadata") if isinstance(m.get("metadata"), dict) else {}
585
+ if meta.get("kind") == "memory_summary" and meta.get("source_artifact_id") == artifact_id:
586
+ return i + 1
587
+
588
+ # Fallback: after system messages.
589
+ return self._insertion_index(active, artifact_id=artifact_id, placement="after_system")
590
+
591
+ def _collect_message_keys(self, messages: Iterable[Any], *, dedup_by: str) -> set[str]:
592
+ keys: set[str] = set()
593
+ for m in messages:
594
+ if not isinstance(m, dict):
595
+ continue
596
+ key = self._message_key(m, dedup_by=dedup_by)
597
+ if key:
598
+ keys.add(key)
599
+ return keys
600
+
601
+ def _message_key(self, message: Dict[str, Any], *, dedup_by: str) -> Optional[str]:
602
+ if dedup_by == "message_id":
603
+ meta = message.get("metadata")
604
+ if isinstance(meta, dict):
605
+ mid = meta.get("message_id")
606
+ if isinstance(mid, str) and mid:
607
+ return mid
608
+ return None
609
+ # Unknown dedup key: disable dedup
610
+ return None
611
+
612
+ @staticmethod
613
+ def summary_text_by_artifact_id_from_run(run: RunState) -> Dict[str, str]:
614
+ ctx = get_context(run.vars)
615
+ messages = ctx.get("messages")
616
+ if not isinstance(messages, list):
617
+ return {}
618
+ out: Dict[str, str] = {}
619
+ for m in messages:
620
+ if not isinstance(m, dict):
621
+ continue
622
+ if m.get("role") != "system":
623
+ continue
624
+ meta = m.get("metadata")
625
+ if not isinstance(meta, dict):
626
+ continue
627
+ if meta.get("kind") != "memory_summary":
628
+ continue
629
+ artifact_id = meta.get("source_artifact_id")
630
+ if isinstance(artifact_id, str) and artifact_id:
631
+ out[artifact_id] = str(m.get("content") or "")
632
+ return out
633
+
634
+ @staticmethod
635
+ def _span_haystack(
636
+ *,
637
+ span: Dict[str, Any],
638
+ meta: Optional[ArtifactMetadata],
639
+ summary: Optional[str],
640
+ note: Optional[str] = None,
641
+ ) -> str:
642
+ parts: List[str] = []
643
+ if summary:
644
+ parts.append(summary)
645
+ if note:
646
+ parts.append(note)
647
+ for k in ("kind", "compression_mode", "focus", "from_timestamp", "to_timestamp", "created_by", "location"):
648
+ v = span.get(k)
649
+ if isinstance(v, str) and v:
650
+ parts.append(v)
651
+ # Span tags are persisted in run vars (topic/person/project, etc).
652
+ span_tags = span.get("tags")
653
+ if isinstance(span_tags, dict):
654
+ for k, v in span_tags.items():
655
+ if isinstance(v, str) and v:
656
+ parts.append(str(k))
657
+ parts.append(v)
658
+
659
+ if meta is not None:
660
+ parts.append(meta.content_type or "")
661
+ for k, v in (meta.tags or {}).items():
662
+ parts.append(k)
663
+ parts.append(v)
664
+ return " ".join(parts).lower()
665
+
666
+ @staticmethod
667
+ def _tags_match(
668
+ *,
669
+ span: Dict[str, Any],
670
+ meta: Optional[ArtifactMetadata],
671
+ required: Dict[str, Any],
672
+ mode: str = "all",
673
+ ) -> bool:
674
+ def _norm(s: str) -> str:
675
+ return str(s or "").strip().lower()
676
+
677
+ tags: Dict[str, str] = {}
678
+ if meta is not None and meta.tags:
679
+ for k, v in meta.tags.items():
680
+ if not isinstance(k, str) or not isinstance(v, str):
681
+ continue
682
+ kk = _norm(k)
683
+ vv = _norm(v)
684
+ if kk and vv and kk not in tags:
685
+ tags[kk] = vv
686
+
687
+ span_tags = span.get("tags")
688
+ if isinstance(span_tags, dict):
689
+ for k, v in span_tags.items():
690
+ if not isinstance(k, str):
691
+ continue
692
+ kk = _norm(k)
693
+ if not kk or kk in tags:
694
+ continue
695
+ if isinstance(v, str):
696
+ vv = _norm(v)
697
+ if vv:
698
+ tags[kk] = vv
699
+
700
+ # Derived tags from span ref (cheap and keeps filtering usable even
701
+ # if artifact metadata is missing).
702
+ for k in ("kind", "compression_mode", "focus"):
703
+ v = span.get(k)
704
+ if isinstance(v, str) and v:
705
+ kk = _norm(k)
706
+ if kk and kk not in tags:
707
+ tags[kk] = _norm(v)
708
+
709
+ required_norm: Dict[str, List[str]] = {}
710
+ for k, v in (required or {}).items():
711
+ if not isinstance(k, str):
712
+ continue
713
+ kk = _norm(k)
714
+ if not kk or kk == "kind":
715
+ continue
716
+ values: List[str] = []
717
+ if isinstance(v, str):
718
+ vv = _norm(v)
719
+ if vv:
720
+ values.append(vv)
721
+ elif isinstance(v, (list, tuple)):
722
+ for it in v:
723
+ if isinstance(it, str) and it.strip():
724
+ values.append(_norm(it))
725
+ if values:
726
+ # preserve order but dedup
727
+ seen: set[str] = set()
728
+ deduped = []
729
+ for x in values:
730
+ if x in seen:
731
+ continue
732
+ seen.add(x)
733
+ deduped.append(x)
734
+ required_norm[kk] = deduped
735
+
736
+ if not required_norm:
737
+ return True
738
+
739
+ def _key_matches(key: str) -> bool:
740
+ cand = tags.get(key)
741
+ if cand is None:
742
+ return False
743
+ allowed = required_norm.get(key) or []
744
+ return cand in allowed
745
+
746
+ op = str(mode or "all").strip().lower() or "all"
747
+ if op not in {"all", "any"}:
748
+ op = "all"
749
+ if op == "any":
750
+ return any(_key_matches(k) for k in required_norm.keys())
751
+ return all(_key_matches(k) for k in required_norm.keys())