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.
- abstractruntime/__init__.py +83 -3
- abstractruntime/core/config.py +82 -2
- abstractruntime/core/event_keys.py +62 -0
- abstractruntime/core/models.py +17 -1
- abstractruntime/core/policy.py +74 -3
- abstractruntime/core/runtime.py +3334 -28
- abstractruntime/core/vars.py +103 -2
- abstractruntime/evidence/__init__.py +10 -0
- abstractruntime/evidence/recorder.py +325 -0
- abstractruntime/history_bundle.py +772 -0
- abstractruntime/integrations/abstractcore/__init__.py +6 -0
- abstractruntime/integrations/abstractcore/constants.py +19 -0
- abstractruntime/integrations/abstractcore/default_tools.py +258 -0
- abstractruntime/integrations/abstractcore/effect_handlers.py +2622 -32
- abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
- abstractruntime/integrations/abstractcore/factory.py +149 -16
- abstractruntime/integrations/abstractcore/llm_client.py +891 -55
- abstractruntime/integrations/abstractcore/mcp_worker.py +587 -0
- abstractruntime/integrations/abstractcore/observability.py +80 -0
- abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
- abstractruntime/integrations/abstractcore/summarizer.py +154 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +509 -31
- abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
- abstractruntime/integrations/abstractmemory/__init__.py +3 -0
- abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
- abstractruntime/memory/__init__.py +21 -0
- abstractruntime/memory/active_context.py +751 -0
- abstractruntime/memory/active_memory.py +452 -0
- abstractruntime/memory/compaction.py +105 -0
- abstractruntime/memory/kg_packets.py +164 -0
- abstractruntime/memory/memact_composer.py +175 -0
- abstractruntime/memory/recall_levels.py +163 -0
- abstractruntime/memory/token_budget.py +86 -0
- abstractruntime/rendering/__init__.py +17 -0
- abstractruntime/rendering/agent_trace_report.py +256 -0
- abstractruntime/rendering/json_stringify.py +136 -0
- abstractruntime/scheduler/scheduler.py +93 -2
- abstractruntime/storage/__init__.py +7 -2
- abstractruntime/storage/artifacts.py +175 -32
- abstractruntime/storage/base.py +17 -1
- abstractruntime/storage/commands.py +339 -0
- abstractruntime/storage/in_memory.py +41 -1
- abstractruntime/storage/json_files.py +210 -14
- abstractruntime/storage/observable.py +136 -0
- abstractruntime/storage/offloading.py +433 -0
- abstractruntime/storage/sqlite.py +836 -0
- abstractruntime/visualflow_compiler/__init__.py +29 -0
- abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
- abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
- abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
- abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
- abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
- abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
- abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
- abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
- abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
- abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
- abstractruntime/visualflow_compiler/compiler.py +3832 -0
- abstractruntime/visualflow_compiler/flow.py +247 -0
- abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
- abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
- abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
- abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
- abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
- abstractruntime/visualflow_compiler/visual/models.py +211 -0
- abstractruntime/workflow_bundle/__init__.py +52 -0
- abstractruntime/workflow_bundle/models.py +236 -0
- abstractruntime/workflow_bundle/packer.py +317 -0
- abstractruntime/workflow_bundle/reader.py +87 -0
- abstractruntime/workflow_bundle/registry.py +587 -0
- abstractruntime-0.4.1.dist-info/METADATA +177 -0
- abstractruntime-0.4.1.dist-info/RECORD +86 -0
- abstractruntime-0.4.1.dist-info/entry_points.txt +2 -0
- abstractruntime-0.2.0.dist-info/METADATA +0 -163
- abstractruntime-0.2.0.dist-info/RECORD +0 -32
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
- {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())
|