AbstractRuntime 0.4.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 (65) hide show
  1. abstractruntime/__init__.py +76 -1
  2. abstractruntime/core/config.py +68 -1
  3. abstractruntime/core/models.py +5 -0
  4. abstractruntime/core/policy.py +74 -3
  5. abstractruntime/core/runtime.py +1002 -126
  6. abstractruntime/core/vars.py +8 -2
  7. abstractruntime/evidence/recorder.py +1 -1
  8. abstractruntime/history_bundle.py +772 -0
  9. abstractruntime/integrations/abstractcore/__init__.py +3 -0
  10. abstractruntime/integrations/abstractcore/default_tools.py +127 -3
  11. abstractruntime/integrations/abstractcore/effect_handlers.py +2440 -99
  12. abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
  13. abstractruntime/integrations/abstractcore/factory.py +68 -20
  14. abstractruntime/integrations/abstractcore/llm_client.py +447 -15
  15. abstractruntime/integrations/abstractcore/mcp_worker.py +1 -0
  16. abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
  17. abstractruntime/integrations/abstractcore/tool_executor.py +31 -10
  18. abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
  19. abstractruntime/integrations/abstractmemory/__init__.py +3 -0
  20. abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
  21. abstractruntime/memory/active_context.py +6 -1
  22. abstractruntime/memory/kg_packets.py +164 -0
  23. abstractruntime/memory/memact_composer.py +175 -0
  24. abstractruntime/memory/recall_levels.py +163 -0
  25. abstractruntime/memory/token_budget.py +86 -0
  26. abstractruntime/storage/__init__.py +4 -1
  27. abstractruntime/storage/artifacts.py +158 -30
  28. abstractruntime/storage/base.py +17 -1
  29. abstractruntime/storage/commands.py +339 -0
  30. abstractruntime/storage/in_memory.py +41 -1
  31. abstractruntime/storage/json_files.py +195 -12
  32. abstractruntime/storage/observable.py +38 -1
  33. abstractruntime/storage/offloading.py +433 -0
  34. abstractruntime/storage/sqlite.py +836 -0
  35. abstractruntime/visualflow_compiler/__init__.py +29 -0
  36. abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
  37. abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
  38. abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
  39. abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
  40. abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
  41. abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
  42. abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
  43. abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
  44. abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
  45. abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
  46. abstractruntime/visualflow_compiler/compiler.py +3832 -0
  47. abstractruntime/visualflow_compiler/flow.py +247 -0
  48. abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
  49. abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
  50. abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
  51. abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
  52. abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
  53. abstractruntime/visualflow_compiler/visual/models.py +211 -0
  54. abstractruntime/workflow_bundle/__init__.py +52 -0
  55. abstractruntime/workflow_bundle/models.py +236 -0
  56. abstractruntime/workflow_bundle/packer.py +317 -0
  57. abstractruntime/workflow_bundle/reader.py +87 -0
  58. abstractruntime/workflow_bundle/registry.py +587 -0
  59. abstractruntime-0.4.1.dist-info/METADATA +177 -0
  60. abstractruntime-0.4.1.dist-info/RECORD +86 -0
  61. abstractruntime-0.4.0.dist-info/METADATA +0 -167
  62. abstractruntime-0.4.0.dist-info/RECORD +0 -49
  63. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
  64. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/entry_points.txt +0 -0
  65. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,946 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import unicodedata
7
+ from typing import Any, Callable, Dict, Iterable, Optional
8
+
9
+ from ...core.models import Effect, EffectType, RunState
10
+ from ...core.runtime import EffectHandler, EffectOutcome
11
+ from ...storage.base import RunStore
12
+
13
+ _DEFAULT_GLOBAL_MEMORY_RUN_ID = "global_memory"
14
+ _DEFAULT_SESSION_MEMORY_RUN_PREFIX = "session_memory_"
15
+ _SAFE_RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
16
+
17
+ _ALLOWED_PREDICATE_IDS: set[str] | None = None
18
+
19
+
20
+ def _allowed_predicate_ids() -> set[str]:
21
+ global _ALLOWED_PREDICATE_IDS
22
+ if _ALLOWED_PREDICATE_IDS is not None:
23
+ return _ALLOWED_PREDICATE_IDS
24
+ try:
25
+ from abstractsemantics import load_semantics_registry # type: ignore
26
+ except Exception as e: # pragma: no cover
27
+ raise RuntimeError(
28
+ "Semantics registry is required for MEMORY_KG_ASSERT validation. "
29
+ "Install `abstractsemantics` into the same environment as the runtime/gateway (e.g. `pip install -e ./abstractsemantics`)."
30
+ ) from e
31
+ reg = load_semantics_registry()
32
+ ids = getattr(reg, "predicate_ids", None)
33
+ allowed = ids() if callable(ids) else set()
34
+ if not isinstance(allowed, set) or not allowed:
35
+ raise RuntimeError("Semantics registry loaded but returned no predicate ids")
36
+ # Canonical predicate ids are compared case-insensitively to avoid accidental casing drift.
37
+ _ALLOWED_PREDICATE_IDS = {str(x).strip().lower() for x in allowed if isinstance(x, str) and x.strip()}
38
+ return _ALLOWED_PREDICATE_IDS
39
+
40
+
41
+ _PREDICATE_ALIAS_MAP: dict[str, str] = {
42
+ # Common schema.org-ish synonyms → canonical minimal semantics (v4).
43
+ #
44
+ # Rationale: extraction models often use `schema:description` / `schema:creator` by default,
45
+ # but the framework's canonical set is deliberately small and uses `dcterms:*` for metadata.
46
+ "schema:description": "dcterms:description",
47
+ "schema:creator": "dcterms:creator",
48
+ # Awareness is a synonym for the canonical `schema:knowsAbout` predicate.
49
+ "schema:awareness": "schema:knowsabout",
50
+ # Structural / membership-y variants seen in practice.
51
+ "schema:hasparent": "dcterms:ispartof",
52
+ "schema:hasmember": "dcterms:haspart",
53
+ # Identity / reference-ish variants (normalize to the canonical set).
54
+ "schema:recognizedas": "skos:closematch",
55
+ "schema:hasmemorysource": "dcterms:references",
56
+ # Namespace drift + common typos.
57
+ "schema:haspart": "dcterms:haspart",
58
+ "schema:ispartof": "dcterms:ispartof",
59
+ "dcterms:has_part": "dcterms:haspart",
60
+ "dcterms:is_part_of": "dcterms:ispartof",
61
+ }
62
+
63
+
64
+ def _normalize_predicate_id(raw: Any) -> str:
65
+ value = raw if isinstance(raw, str) else str(raw or "")
66
+ value2 = value.strip()
67
+ if not value2:
68
+ return ""
69
+ key = value2.lower()
70
+ return _PREDICATE_ALIAS_MAP.get(key, value2)
71
+
72
+
73
+ _EX_PREFIX_RE = re.compile(r"^ex:", re.IGNORECASE)
74
+ _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
75
+ _DASH_RUN_RE = re.compile(r"-{2,}")
76
+
77
+
78
+ def _slugify_kebab(value: str) -> str:
79
+ raw = str(value or "").strip()
80
+ if not raw:
81
+ return ""
82
+ folded = (
83
+ unicodedata.normalize("NFKD", raw)
84
+ .encode("ascii", "ignore")
85
+ .decode("ascii", errors="ignore")
86
+ .strip()
87
+ )
88
+ lowered = folded.lower()
89
+ # Replace any run of non-alphanumerics with a dash.
90
+ dashed = _NON_ALNUM_RE.sub("-", lowered)
91
+ dashed = _DASH_RUN_RE.sub("-", dashed).strip("-")
92
+ return dashed
93
+
94
+
95
+ def _normalize_ex_curie(raw: Any) -> Any:
96
+ """Normalize `ex:` instance identifiers to `ex:{kind}-{kebab-case}` formatting.
97
+
98
+ Notes:
99
+ - We intentionally do not attempt semantic entity resolution here (synonyms, honorific stripping, etc).
100
+ This is a formatting normalization layer to reduce accidental drift (spaces/underscores/punctuation).
101
+ - Returns the original value when normalization cannot be applied safely.
102
+ """
103
+ if not isinstance(raw, str):
104
+ return raw
105
+ value = raw.strip()
106
+ if not value or not _EX_PREFIX_RE.match(value):
107
+ return raw
108
+ local = value.split(":", 1)[1].strip()
109
+ slug = _slugify_kebab(local)
110
+ if not slug:
111
+ return raw
112
+ return f"ex:{slug}"
113
+
114
+
115
+ def _global_memory_owner_id() -> str:
116
+ rid = os.environ.get("ABSTRACTRUNTIME_GLOBAL_MEMORY_RUN_ID")
117
+ rid = str(rid or "").strip()
118
+ if rid and _SAFE_RUN_ID_PATTERN.match(rid):
119
+ return rid
120
+ return _DEFAULT_GLOBAL_MEMORY_RUN_ID
121
+
122
+
123
+ def _session_memory_owner_id(session_id: str) -> str:
124
+ sid = str(session_id or "").strip()
125
+ if not sid:
126
+ raise ValueError("session_id is required")
127
+ if _SAFE_RUN_ID_PATTERN.match(sid):
128
+ rid = f"{_DEFAULT_SESSION_MEMORY_RUN_PREFIX}{sid}"
129
+ if _SAFE_RUN_ID_PATTERN.match(rid):
130
+ return rid
131
+ digest = hashlib.sha256(sid.encode("utf-8")).hexdigest()[:32]
132
+ return f"{_DEFAULT_SESSION_MEMORY_RUN_PREFIX}sha_{digest}"
133
+
134
+
135
+ def _resolve_run_tree_root_run_id(run: RunState, *, run_store: RunStore) -> str:
136
+ cur = run
137
+ seen: set[str] = set()
138
+ while True:
139
+ parent_id = getattr(cur, "parent_run_id", None)
140
+ if not isinstance(parent_id, str) or not parent_id.strip():
141
+ return str(getattr(cur, "run_id", "") or "")
142
+ pid = parent_id.strip()
143
+ if pid in seen:
144
+ return str(getattr(cur, "run_id", "") or "")
145
+ seen.add(pid)
146
+ parent = run_store.load(pid)
147
+ if parent is None:
148
+ return str(getattr(cur, "run_id", "") or "")
149
+ cur = parent
150
+
151
+
152
+ def resolve_scope_owner_id(run: RunState, *, scope: str, run_store: RunStore) -> str:
153
+ s = str(scope or "").strip().lower() or "run"
154
+ if s == "run":
155
+ return str(getattr(run, "run_id", "") or "")
156
+ if s == "session":
157
+ sid = getattr(run, "session_id", None)
158
+ if isinstance(sid, str) and sid.strip():
159
+ return _session_memory_owner_id(sid.strip())
160
+ return _resolve_run_tree_root_run_id(run, run_store=run_store)
161
+ if s == "global":
162
+ return _global_memory_owner_id()
163
+ raise ValueError(f"Unknown memory scope: {scope}")
164
+
165
+
166
+ def _import_abstractmemory():
167
+ try:
168
+ from abstractmemory import TripleAssertion, TripleQuery # type: ignore
169
+
170
+ return TripleAssertion, TripleQuery
171
+ except Exception as e: # pragma: no cover
172
+ raise RuntimeError(
173
+ "AbstractMemory is not available. Install and ensure it is importable (e.g. `pip install -e abstractmemory`)."
174
+ ) from e
175
+
176
+
177
+ def build_memory_kg_effect_handlers(
178
+ *,
179
+ store: Any,
180
+ run_store: RunStore,
181
+ now_iso: Callable[[], str],
182
+ ) -> Dict[EffectType, EffectHandler]:
183
+ """Build effect handlers for `memory_kg_*` effects.
184
+
185
+ These are host-provided handlers that bridge VisualFlow nodes to AbstractMemory stores.
186
+ """
187
+ TripleAssertion, TripleQuery = _import_abstractmemory()
188
+
189
+ # Avoid `isinstance` checks against Protocols; use duck-typing instead.
190
+ if not callable(getattr(store, "add", None)) or not callable(getattr(store, "query", None)):
191
+ raise TypeError("store must provide add(...) and query(...) methods (abstractmemory TripleStore contract)")
192
+
193
+ def _store_warning() -> Optional[str]:
194
+ name = store.__class__.__name__
195
+ if name == "InMemoryTripleStore":
196
+ return (
197
+ "AbstractMemory KG store is running in-memory (process-local, non-durable). "
198
+ "Install `AbstractMemory[lancedb]` (or `lancedb`) for persistence across server restarts."
199
+ )
200
+ return None
201
+
202
+ def _normalize_assertions(raw: Any) -> list[dict[str, Any]]:
203
+ if raw is None:
204
+ return []
205
+ if isinstance(raw, dict):
206
+ return [dict(raw)]
207
+ if isinstance(raw, list):
208
+ out: list[dict[str, Any]] = []
209
+ for x in raw:
210
+ if isinstance(x, dict):
211
+ out.append(dict(x))
212
+ return out
213
+ return []
214
+
215
+ def _handle_assert(run: RunState, effect: Effect, default_next_node: Optional[str]) -> EffectOutcome:
216
+ del default_next_node
217
+ payload = dict(effect.payload or {})
218
+
219
+ raw_assertions = payload.get("assertions")
220
+ if raw_assertions is None:
221
+ raw_assertions = payload.get("triples")
222
+ if raw_assertions is None:
223
+ raw_assertions = payload.get("items")
224
+ if raw_assertions is None:
225
+ return EffectOutcome.failed("MEMORY_KG_ASSERT requires payload.assertions (list[object])")
226
+
227
+ # Empty assertion lists are a valid no-op (e.g. extractor found no facts).
228
+ # This should not fail the entire run/workflow.
229
+ if isinstance(raw_assertions, list) and len(raw_assertions) == 0:
230
+ return EffectOutcome.completed({"ok": True, "count": 0, "assertion_ids": [], "skipped": True})
231
+
232
+ assertions = _normalize_assertions(raw_assertions)
233
+ if not assertions:
234
+ return EffectOutcome.failed("MEMORY_KG_ASSERT requires payload.assertions (list[object])")
235
+
236
+ # Apply predicate alias normalization before validation so common synonyms are accepted.
237
+ for a in assertions:
238
+ if not isinstance(a, dict):
239
+ continue
240
+ if "predicate" in a:
241
+ a["predicate"] = _normalize_predicate_id(a.get("predicate"))
242
+
243
+ allow_custom = bool(payload.get("allow_custom_predicates") or payload.get("allow_custom"))
244
+ allowed_predicates = _allowed_predicate_ids()
245
+ invalid_predicates: list[str] = []
246
+ for a in assertions:
247
+ pred = a.get("predicate") if isinstance(a, dict) else None
248
+ pred = pred if isinstance(pred, str) else ""
249
+ pred2 = pred.strip()
250
+ if not pred2:
251
+ invalid_predicates.append("<missing>")
252
+ continue
253
+ pred_norm = pred2.lower()
254
+ if pred_norm in allowed_predicates:
255
+ continue
256
+ if allow_custom and pred_norm.startswith("ex:"):
257
+ continue
258
+ invalid_predicates.append(pred2)
259
+
260
+ if invalid_predicates:
261
+ uniq = []
262
+ seen: set[str] = set()
263
+ for p in invalid_predicates:
264
+ if p in seen:
265
+ continue
266
+ uniq.append(p)
267
+ seen.add(p)
268
+ preview = ", ".join(uniq[:12])
269
+ suffix = " …" if len(uniq) > 12 else ""
270
+ return EffectOutcome.failed(
271
+ "MEMORY_KG_ASSERT rejected unknown predicates. "
272
+ f"Got: {preview}{suffix}. "
273
+ "Update the extractor to use allowed semantics (or set allow_custom_predicates=true for ex:* predicates)."
274
+ )
275
+
276
+ scope_default = str(payload.get("scope") or "run").strip().lower() or "run"
277
+ owner_default = payload.get("owner_id")
278
+ owner_default = str(owner_default).strip() if isinstance(owner_default, str) and owner_default.strip() else None
279
+ span_id_default = payload.get("span_id")
280
+ span_id_default = str(span_id_default).strip() if isinstance(span_id_default, str) and span_id_default.strip() else None
281
+ attributes_defaults_raw = payload.get("attributes_defaults")
282
+ attributes_defaults = dict(attributes_defaults_raw) if isinstance(attributes_defaults_raw, dict) else {}
283
+
284
+ observed_at = now_iso()
285
+ out_rows: list[Any] = []
286
+ parse_errors: list[str] = []
287
+ for a in assertions:
288
+ try:
289
+ merged = dict(a)
290
+ if "subject" in merged:
291
+ merged["subject"] = _normalize_ex_curie(merged.get("subject"))
292
+ if "object" in merged:
293
+ merged["object"] = _normalize_ex_curie(merged.get("object"))
294
+ if "scope" not in merged:
295
+ merged["scope"] = scope_default
296
+ if "owner_id" not in merged:
297
+ merged["owner_id"] = owner_default or resolve_scope_owner_id(run, scope=merged.get("scope") or scope_default, run_store=run_store)
298
+ if "observed_at" not in merged:
299
+ merged["observed_at"] = observed_at
300
+ provenance = merged.get("provenance")
301
+ prov2: dict[str, Any] = dict(provenance) if isinstance(provenance, dict) else {}
302
+ if span_id_default and "span_id" not in prov2:
303
+ prov2["span_id"] = span_id_default
304
+ prov2.setdefault("writer_run_id", str(getattr(run, "run_id", "") or ""))
305
+ prov2.setdefault("writer_workflow_id", str(getattr(run, "workflow_id", "") or ""))
306
+ merged["provenance"] = prov2
307
+ if attributes_defaults:
308
+ attrs = merged.get("attributes")
309
+ attrs2: dict[str, Any] = dict(attrs) if isinstance(attrs, dict) else {}
310
+ for k, v in attributes_defaults.items():
311
+ if k not in attrs2:
312
+ attrs2[k] = v
313
+ merged["attributes"] = attrs2
314
+ out_rows.append(TripleAssertion.from_dict(merged))
315
+ except Exception as e:
316
+ parse_errors.append(str(e))
317
+
318
+ if parse_errors:
319
+ preview = " | ".join([e for e in parse_errors[:5] if str(e).strip()])
320
+ suffix = " …" if len(parse_errors) > 5 else ""
321
+ return EffectOutcome.failed(f"MEMORY_KG_ASSERT contained invalid assertions: {preview}{suffix}")
322
+
323
+ if not out_rows:
324
+ return EffectOutcome.failed("MEMORY_KG_ASSERT contained no valid assertions")
325
+
326
+ dedupe_raw = payload.get("dedupe")
327
+ if dedupe_raw is None:
328
+ dedupe_raw = payload.get("deduplicate")
329
+ dedupe = True
330
+ if isinstance(dedupe_raw, bool):
331
+ dedupe = dedupe_raw
332
+ elif isinstance(dedupe_raw, str) and dedupe_raw.strip():
333
+ dedupe = dedupe_raw.strip().lower() not in {"0", "false", "no", "off"}
334
+
335
+ skipped_duplicates = 0
336
+ to_insert = out_rows
337
+ if dedupe:
338
+ to_insert = []
339
+ for a in out_rows:
340
+ try:
341
+ existing = store.query(
342
+ TripleQuery(
343
+ subject=a.subject,
344
+ predicate=a.predicate,
345
+ object=a.object,
346
+ scope=a.scope,
347
+ owner_id=a.owner_id,
348
+ limit=1,
349
+ order="desc",
350
+ )
351
+ )
352
+ except Exception:
353
+ # Dedupe is best-effort; if the store can't query, fall back to insert.
354
+ existing = []
355
+ if existing:
356
+ skipped_duplicates += 1
357
+ continue
358
+ to_insert.append(a)
359
+
360
+ ids: list[str] = []
361
+ if to_insert:
362
+ try:
363
+ ids = store.add(to_insert)
364
+ except Exception as e:
365
+ return EffectOutcome.failed(f"MEMORY_KG_ASSERT store.add failed: {e}")
366
+
367
+ result: dict[str, Any] = {
368
+ "ok": True,
369
+ "count": len(ids),
370
+ "assertion_ids": ids,
371
+ "count_attempted": len(out_rows),
372
+ "skipped_duplicates": skipped_duplicates,
373
+ }
374
+ warn = _store_warning()
375
+ if warn:
376
+ result["warnings"] = [warn]
377
+ return EffectOutcome.completed(result)
378
+
379
+ def _handle_query(run: RunState, effect: Effect, default_next_node: Optional[str]) -> EffectOutcome:
380
+ del default_next_node
381
+ payload = dict(effect.payload or {})
382
+
383
+ recall_level_raw = payload.get("recall_level")
384
+ if recall_level_raw is None:
385
+ recall_level_raw = payload.get("recallLevel")
386
+ try:
387
+ from abstractruntime.memory.recall_levels import parse_recall_level, policy_for
388
+
389
+ recall_level = parse_recall_level(recall_level_raw)
390
+ except Exception as e:
391
+ return EffectOutcome.failed(str(e))
392
+
393
+ recall_warnings: list[str] = []
394
+ recall_effort: dict[str, Any] = {}
395
+
396
+ scope_raw = payload.get("scope")
397
+ scope = str(scope_raw or "run").strip().lower() or "run"
398
+ owner_id_raw = payload.get("owner_id")
399
+ owner_id = str(owner_id_raw).strip() if isinstance(owner_id_raw, str) and owner_id_raw.strip() else None
400
+
401
+ if scope not in {"run", "session", "global", "all"}:
402
+ return EffectOutcome.completed({"ok": False, "count": 0, "items": [], "error": f"Unknown memory scope: {scope}"})
403
+
404
+ query_text_raw = payload.get("query_text")
405
+ is_semantic = isinstance(query_text_raw, str) and query_text_raw.strip().lower() != ""
406
+
407
+ # Apply recall budgets when explicitly requested.
408
+ limit_value: int = 100
409
+ min_score_value: Optional[float] = None
410
+ budget_value: Optional[int] = None
411
+ if recall_level is not None:
412
+ pol = policy_for(recall_level)
413
+
414
+ raw_limit = payload.get("limit")
415
+ if raw_limit is None:
416
+ limit_value = pol.kg.limit_default
417
+ else:
418
+ try:
419
+ limit_value = int(raw_limit)
420
+ except Exception:
421
+ limit_value = pol.kg.limit_default
422
+ if limit_value < 1:
423
+ limit_value = 1
424
+ if limit_value > pol.kg.limit_max:
425
+ recall_warnings.append(
426
+ f"recall_level={recall_level.value}: clamped limit from {limit_value} to {pol.kg.limit_max}"
427
+ )
428
+ limit_value = pol.kg.limit_max
429
+
430
+ if is_semantic:
431
+ raw_ms = payload.get("min_score")
432
+ if raw_ms is None:
433
+ min_score_value = float(pol.kg.min_score_default)
434
+ else:
435
+ try:
436
+ min_score_value = float(raw_ms)
437
+ except Exception:
438
+ min_score_value = float(pol.kg.min_score_default)
439
+ if not (min_score_value == min_score_value): # NaN
440
+ min_score_value = float(pol.kg.min_score_default)
441
+ if min_score_value < pol.kg.min_score_floor:
442
+ recall_warnings.append(
443
+ f"recall_level={recall_level.value}: raised min_score from {min_score_value} to {pol.kg.min_score_floor}"
444
+ )
445
+ min_score_value = float(pol.kg.min_score_floor)
446
+ else:
447
+ min_score_value = None
448
+
449
+ raw_budget = payload.get("max_input_tokens")
450
+ if raw_budget is None:
451
+ raw_budget = payload.get("max_in_tokens")
452
+ if raw_budget is None:
453
+ budget_value = pol.kg.max_input_tokens_default
454
+ else:
455
+ try:
456
+ bf = float(raw_budget)
457
+ except Exception:
458
+ bf = None
459
+ if bf is None or not (bf == bf): # NaN
460
+ budget_value = pol.kg.max_input_tokens_default
461
+ elif bf == 0:
462
+ # Explicitly disable packetization/Active Memory packing.
463
+ budget_value = 0
464
+ elif bf < 1:
465
+ budget_value = pol.kg.max_input_tokens_default
466
+ else:
467
+ budget_value = int(bf)
468
+
469
+ if budget_value > 0 and budget_value > pol.kg.max_input_tokens_max:
470
+ recall_warnings.append(
471
+ f"recall_level={recall_level.value}: clamped max_input_tokens from {budget_value} to {pol.kg.max_input_tokens_max}"
472
+ )
473
+ budget_value = pol.kg.max_input_tokens_max
474
+
475
+ recall_effort = {
476
+ "recall_level": recall_level.value,
477
+ "applied": {
478
+ "limit": int(limit_value),
479
+ "min_score": float(min_score_value) if min_score_value is not None else None,
480
+ "max_input_tokens": int(budget_value) if budget_value is not None else None,
481
+ },
482
+ }
483
+ else:
484
+ # Backward-compatible defaults (no policy).
485
+ try:
486
+ limit_value = int(payload.get("limit") or 100)
487
+ except Exception:
488
+ limit_value = 100
489
+ limit_value = max(1, min(limit_value, 10_000))
490
+ raw_ms = payload.get("min_score")
491
+ if raw_ms is not None:
492
+ try:
493
+ v = float(raw_ms)
494
+ except Exception:
495
+ v = None
496
+ if v is not None and (v == v):
497
+ min_score_value = v
498
+
499
+ raw_budget = payload.get("max_input_tokens")
500
+ if raw_budget is None:
501
+ raw_budget = payload.get("max_in_tokens")
502
+ if raw_budget is not None and not isinstance(raw_budget, bool):
503
+ try:
504
+ b = int(float(raw_budget))
505
+ except Exception:
506
+ b = None
507
+ if isinstance(b, int) and b > 0:
508
+ budget_value = b
509
+
510
+ def _one_query(*, scope_label: str, owner_id2: str) -> list[Any]:
511
+ subject_raw = str(payload.get("subject")).strip() if isinstance(payload.get("subject"), str) else None
512
+ object_raw = str(payload.get("object")).strip() if isinstance(payload.get("object"), str) else None
513
+ q = TripleQuery(
514
+ subject=_normalize_ex_curie(subject_raw) if isinstance(subject_raw, str) else subject_raw,
515
+ predicate=_normalize_predicate_id(payload.get("predicate")) if payload.get("predicate") is not None else None,
516
+ object=_normalize_ex_curie(object_raw) if isinstance(object_raw, str) else object_raw,
517
+ scope=scope_label,
518
+ owner_id=owner_id2,
519
+ since=str(payload.get("since")).strip() if isinstance(payload.get("since"), str) else None,
520
+ until=str(payload.get("until")).strip() if isinstance(payload.get("until"), str) else None,
521
+ active_at=str(payload.get("active_at")).strip() if isinstance(payload.get("active_at"), str) else None,
522
+ query_text=str(payload.get("query_text")).strip() if isinstance(payload.get("query_text"), str) else None,
523
+ limit=int(limit_value),
524
+ order=str(payload.get("order") or "desc"),
525
+ min_score=min_score_value,
526
+ )
527
+ return store.query(q)
528
+
529
+ results: list[Any] = []
530
+ errors: list[str] = []
531
+ if scope == "all":
532
+ owners: list[tuple[str, str]] = []
533
+ try:
534
+ owners.append(("run", resolve_scope_owner_id(run, scope="run", run_store=run_store)))
535
+ owners.append(("session", resolve_scope_owner_id(run, scope="session", run_store=run_store)))
536
+ owners.append(("global", resolve_scope_owner_id(run, scope="global", run_store=run_store)))
537
+ except Exception as e:
538
+ errors.append(str(e))
539
+ for label, oid in owners:
540
+ try:
541
+ results.extend(_one_query(scope_label=label, owner_id2=oid))
542
+ except Exception as e:
543
+ errors.append(f"{label}: {e}")
544
+ else:
545
+ try:
546
+ owner = owner_id or resolve_scope_owner_id(run, scope=scope, run_store=run_store)
547
+ results.extend(_one_query(scope_label=scope, owner_id2=owner))
548
+ except Exception as e:
549
+ errors.append(str(e))
550
+
551
+ # Normalize output to JSON-safe dicts.
552
+ out_items: list[dict[str, Any]] = []
553
+ for a in results:
554
+ to_dict = getattr(a, "to_dict", None)
555
+ if callable(to_dict):
556
+ d = to_dict()
557
+ if isinstance(d, dict):
558
+ out_items.append(d)
559
+
560
+ # Ordering:
561
+ # - Pattern queries (no query_text/query_vector): observed_at (asc/desc)
562
+ # - Semantic queries: preserve similarity ranking via `_retrieval.score` (desc),
563
+ # tie-break by observed_at desc for stability.
564
+ query_text_raw = payload.get("query_text")
565
+ is_semantic = isinstance(query_text_raw, str) and query_text_raw.strip().lower() != ""
566
+
567
+ def _observed_at_key(d: dict[str, Any]) -> str:
568
+ return str(d.get("observed_at") or "")
569
+
570
+ if is_semantic:
571
+ def _score(d: dict[str, Any]) -> float:
572
+ attrs = d.get("attributes")
573
+ if isinstance(attrs, dict):
574
+ ret = attrs.get("_retrieval")
575
+ if isinstance(ret, dict):
576
+ s = ret.get("score")
577
+ if isinstance(s, (int, float)):
578
+ return float(s)
579
+ return float("-inf")
580
+
581
+ out_items.sort(key=lambda d: (_score(d), _observed_at_key(d)), reverse=True)
582
+ else:
583
+ order = str(payload.get("order") or "desc").strip().lower()
584
+ reverse = order != "asc"
585
+ out_items.sort(key=_observed_at_key, reverse=reverse)
586
+ out_items = out_items[: max(1, min(int(limit_value), 10_000))]
587
+
588
+ if not out_items and errors:
589
+ return EffectOutcome.completed(
590
+ {
591
+ "ok": False,
592
+ "count": 0,
593
+ "items": [],
594
+ "error": " | ".join([e for e in errors if str(e).strip()]),
595
+ }
596
+ )
597
+
598
+ result: dict[str, Any] = {"ok": True, "count": len(out_items), "items": out_items}
599
+ if recall_effort:
600
+ result["effort"] = recall_effort
601
+
602
+ # Optional: packetize + pack into an LLM-friendly Active Memory block.
603
+ #
604
+ # This is used by `ltm-ai-kg-map-to-active` and chat-like flows that inject
605
+ # KG recall into the system prompt without blowing up token budgets.
606
+ budget = budget_value
607
+ if isinstance(budget, int) and budget > 0:
608
+ try:
609
+ model_name = payload.get("model")
610
+ model_name = str(model_name).strip() if isinstance(model_name, str) and model_name.strip() else None
611
+
612
+ from abstractruntime.memory.kg_packets import packetize_assertions, pack_active_memory_text
613
+
614
+ packets_all = packetize_assertions(out_items)
615
+ active_text, kept_packets, est_tokens, dropped = pack_active_memory_text(
616
+ packets_all,
617
+ scope=scope,
618
+ max_input_tokens=int(budget),
619
+ model=model_name,
620
+ include_scores=bool(is_semantic),
621
+ )
622
+
623
+ result["packets_version"] = 0
624
+ result["packets"] = kept_packets
625
+ result["packed_count"] = len(kept_packets)
626
+ result["active_memory_text"] = active_text
627
+ result["estimated_tokens"] = est_tokens
628
+ result["dropped"] = dropped
629
+ except Exception as e:
630
+ return EffectOutcome.failed(f"MEMORY_KG_QUERY packetize failed: {e}")
631
+
632
+ warn = _store_warning()
633
+ if warn:
634
+ warnings = result.get("warnings")
635
+ if isinstance(warnings, list):
636
+ warnings.append(warn)
637
+ else:
638
+ result["warnings"] = [warn]
639
+ if errors:
640
+ cur = result.get("warnings")
641
+ merged: list[str] = []
642
+ if isinstance(cur, list):
643
+ merged.extend([str(x) for x in cur if str(x).strip()])
644
+ merged.extend([e for e in errors if str(e).strip()])
645
+ result["warnings"] = merged
646
+ if recall_warnings:
647
+ cur = result.get("warnings")
648
+ merged: list[str] = []
649
+ if isinstance(cur, list):
650
+ merged.extend([str(x) for x in cur if str(x).strip()])
651
+ merged.extend([w for w in recall_warnings if str(w).strip()])
652
+ result["warnings"] = merged
653
+ return EffectOutcome.completed(result)
654
+
655
+ def _handle_resolve(run: RunState, effect: Effect, default_next_node: Optional[str]) -> EffectOutcome:
656
+ """Resolve candidate entity ids by label (+ optional rdf:type filter)."""
657
+ del default_next_node
658
+ payload = dict(effect.payload or {})
659
+
660
+ label_raw = payload.get("label")
661
+ if label_raw is None:
662
+ label_raw = payload.get("query")
663
+ if label_raw is None:
664
+ label_raw = payload.get("query_text")
665
+
666
+ label = str(label_raw or "").strip()
667
+ if not label:
668
+ return EffectOutcome.completed({"ok": False, "count": 0, "candidates": [], "error": "label is required"})
669
+
670
+ expected_type_raw = payload.get("expected_type")
671
+ if expected_type_raw is None:
672
+ expected_type_raw = payload.get("expectedType")
673
+ if expected_type_raw is None:
674
+ expected_type_raw = payload.get("type")
675
+
676
+ expected_type = str(expected_type_raw or "").strip().lower() if expected_type_raw is not None else ""
677
+ expected_type = expected_type if expected_type else ""
678
+
679
+ recall_level_raw = payload.get("recall_level")
680
+ if recall_level_raw is None:
681
+ recall_level_raw = payload.get("recallLevel")
682
+ try:
683
+ from abstractruntime.memory.recall_levels import parse_recall_level
684
+
685
+ recall_level = parse_recall_level(recall_level_raw)
686
+ except Exception as e:
687
+ return EffectOutcome.failed(str(e))
688
+
689
+ scope_raw = payload.get("scope")
690
+ scope = str(scope_raw or "run").strip().lower() or "run"
691
+ owner_id_raw = payload.get("owner_id")
692
+ owner_id = str(owner_id_raw).strip() if isinstance(owner_id_raw, str) and owner_id_raw.strip() else None
693
+
694
+ if scope not in {"run", "session", "global", "all"}:
695
+ return EffectOutcome.completed({"ok": False, "count": 0, "candidates": [], "error": f"Unknown memory scope: {scope}"})
696
+
697
+ def _normalize_label(value: str) -> str:
698
+ # Collapse whitespace + lowercase to match extractor normalization.
699
+ return " ".join(str(value or "").split()).strip().lower()
700
+
701
+ label_norm = _normalize_label(label)
702
+
703
+ # Budgets / behavior flags.
704
+ max_candidates_raw = payload.get("max_candidates")
705
+ if max_candidates_raw is None:
706
+ max_candidates_raw = payload.get("maxCandidates")
707
+ if max_candidates_raw is None:
708
+ max_candidates_raw = payload.get("limit")
709
+
710
+ default_max = 5
711
+ semantic_mode: str = "fallback"
712
+ if recall_level is not None:
713
+ if recall_level.value == "urgent":
714
+ default_max = 3
715
+ semantic_mode = "none"
716
+ elif recall_level.value == "deep":
717
+ default_max = 10
718
+ semantic_mode = "always"
719
+ else:
720
+ default_max = 5
721
+ semantic_mode = "fallback"
722
+
723
+ try:
724
+ max_candidates = int(float(max_candidates_raw)) if max_candidates_raw is not None else int(default_max)
725
+ except Exception:
726
+ max_candidates = int(default_max)
727
+ if max_candidates < 1:
728
+ max_candidates = 1
729
+ if max_candidates > 50:
730
+ max_candidates = 50
731
+
732
+ include_semantic_raw = payload.get("include_semantic")
733
+ if include_semantic_raw is None:
734
+ include_semantic_raw = payload.get("includeSemantic")
735
+ include_semantic = None
736
+ if include_semantic_raw is not None:
737
+ include_semantic = bool(include_semantic_raw) if isinstance(include_semantic_raw, bool) else None
738
+ if include_semantic is not None:
739
+ semantic_mode = "fallback" if include_semantic else "none"
740
+
741
+ min_score_raw = payload.get("min_score")
742
+ if min_score_raw is None:
743
+ min_score_raw = payload.get("minScore")
744
+ min_score_value: Optional[float] = None
745
+ if min_score_raw is not None and not isinstance(min_score_raw, bool):
746
+ try:
747
+ min_score_value = float(min_score_raw)
748
+ except Exception:
749
+ min_score_value = None
750
+
751
+ label_predicates = [
752
+ "schema:name",
753
+ "skos:preflabel",
754
+ "skos:altlabel",
755
+ "dcterms:title",
756
+ "dcterms:identifier",
757
+ ]
758
+
759
+ warnings: list[str] = []
760
+
761
+ def _score_from_dict(d: dict[str, Any]) -> Optional[float]:
762
+ attrs = d.get("attributes")
763
+ if isinstance(attrs, dict):
764
+ ret = attrs.get("_retrieval")
765
+ if isinstance(ret, dict):
766
+ s = ret.get("score")
767
+ if isinstance(s, (int, float)):
768
+ return float(s)
769
+ return None
770
+
771
+ def _one_query(
772
+ *,
773
+ scope_label: str,
774
+ owner_id2: str,
775
+ predicate_id: str,
776
+ object_value: Optional[str] = None,
777
+ query_text: Optional[str] = None,
778
+ ) -> list[dict[str, Any]]:
779
+ q = TripleQuery(
780
+ subject=None,
781
+ predicate=predicate_id,
782
+ object=object_value,
783
+ scope=scope_label,
784
+ owner_id=owner_id2,
785
+ query_text=query_text,
786
+ limit=max(10, min(max_candidates * 10, 200)),
787
+ order="desc",
788
+ min_score=min_score_value,
789
+ )
790
+ rows = store.query(q)
791
+ out: list[dict[str, Any]] = []
792
+ for a in rows:
793
+ to_dict = getattr(a, "to_dict", None)
794
+ if callable(to_dict):
795
+ d = to_dict()
796
+ if isinstance(d, dict):
797
+ out.append(d)
798
+ return out
799
+
800
+ owners: list[tuple[str, str]] = []
801
+ if scope == "all":
802
+ try:
803
+ owners.append(("run", resolve_scope_owner_id(run, scope="run", run_store=run_store)))
804
+ owners.append(("session", resolve_scope_owner_id(run, scope="session", run_store=run_store)))
805
+ owners.append(("global", resolve_scope_owner_id(run, scope="global", run_store=run_store)))
806
+ except Exception as e:
807
+ warnings.append(str(e))
808
+ else:
809
+ try:
810
+ owners.append((scope, owner_id or resolve_scope_owner_id(run, scope=scope, run_store=run_store)))
811
+ except Exception as e:
812
+ warnings.append(str(e))
813
+
814
+ exact_hits: list[dict[str, Any]] = []
815
+ for scope_label, owner2 in owners:
816
+ for pid in label_predicates:
817
+ try:
818
+ exact_hits.extend(_one_query(scope_label=scope_label, owner_id2=owner2, predicate_id=pid, object_value=label_norm))
819
+ except Exception as e:
820
+ warnings.append(f"{scope_label}.{pid}: {e}")
821
+
822
+ semantic_hits: list[dict[str, Any]] = []
823
+ if semantic_mode != "none":
824
+ allow = semantic_mode == "always" or (semantic_mode == "fallback" and not exact_hits)
825
+ if allow:
826
+ for scope_label, owner2 in owners:
827
+ for pid in label_predicates:
828
+ try:
829
+ semantic_hits.extend(_one_query(scope_label=scope_label, owner_id2=owner2, predicate_id=pid, query_text=label_norm))
830
+ except Exception as e:
831
+ warnings.append(f"semantic {scope_label}.{pid}: {e}")
832
+
833
+ # Candidate aggregation.
834
+ cand_by_key: dict[tuple[str, Optional[str], str], dict[str, Any]] = {}
835
+ for d in list(exact_hits) + list(semantic_hits):
836
+ subj = d.get("subject")
837
+ if not isinstance(subj, str) or not subj.strip().lower().startswith("ex:"):
838
+ continue
839
+ scope_val = str(d.get("scope") or "").strip().lower() or "run"
840
+ owner_val = d.get("owner_id") if isinstance(d.get("owner_id"), str) and d.get("owner_id").strip() else None
841
+ key = (scope_val, owner_val, subj.strip())
842
+
843
+ score = _score_from_dict(d)
844
+ observed_at = str(d.get("observed_at") or "")
845
+ obj_val = d.get("object")
846
+ label_val = obj_val if isinstance(obj_val, str) and obj_val.strip() else label_norm
847
+
848
+ c = cand_by_key.get(key)
849
+ if c is None:
850
+ c = {
851
+ "id": key[2],
852
+ "label": label_val,
853
+ "scope": key[0],
854
+ "owner_id": key[1],
855
+ "score": score if score is not None else (1.0 if d in exact_hits else None),
856
+ "_observed_at": observed_at,
857
+ "_evidence": [d],
858
+ }
859
+ cand_by_key[key] = c
860
+ else:
861
+ if isinstance(label_val, str) and label_val.strip() and not str(c.get("label") or "").strip():
862
+ c["label"] = label_val
863
+ cur_score = c.get("score")
864
+ if score is not None and (cur_score is None or (isinstance(cur_score, (int, float)) and score > float(cur_score))):
865
+ c["score"] = float(score)
866
+ if observed_at and observed_at > str(c.get("_observed_at") or ""):
867
+ c["_observed_at"] = observed_at
868
+ ev = c.get("_evidence")
869
+ if isinstance(ev, list) and len(ev) < 3:
870
+ ev.append(d)
871
+
872
+ candidates = list(cand_by_key.values())
873
+ candidates.sort(
874
+ key=lambda c: (
875
+ float(c.get("score")) if isinstance(c.get("score"), (int, float)) else -1.0,
876
+ str(c.get("_observed_at") or ""),
877
+ ),
878
+ reverse=True,
879
+ )
880
+
881
+ # Bound the number of rdf:type lookups (and the final output).
882
+ candidates = candidates[: max_candidates * 2]
883
+
884
+ out: list[dict[str, Any]] = []
885
+ for c in candidates:
886
+ cid = c.get("id")
887
+ if not isinstance(cid, str) or not cid.strip():
888
+ continue
889
+ scope_val = str(c.get("scope") or "run").strip().lower() or "run"
890
+ owner_val = c.get("owner_id") if isinstance(c.get("owner_id"), str) and c.get("owner_id").strip() else None
891
+
892
+ type_rows: list[dict[str, Any]] = []
893
+ try:
894
+ q = TripleQuery(
895
+ subject=cid.strip(),
896
+ predicate="rdf:type",
897
+ object=None,
898
+ scope=scope_val,
899
+ owner_id=owner_val,
900
+ limit=50,
901
+ order="desc",
902
+ )
903
+ for a in store.query(q):
904
+ to_dict = getattr(a, "to_dict", None)
905
+ if callable(to_dict):
906
+ d = to_dict()
907
+ if isinstance(d, dict):
908
+ type_rows.append(d)
909
+ except Exception as e:
910
+ warnings.append(f"type {scope_val}.{cid}: {e}")
911
+
912
+ types: list[str] = []
913
+ for d in type_rows:
914
+ obj = d.get("object")
915
+ if isinstance(obj, str) and obj.strip():
916
+ types.append(obj.strip().lower())
917
+ types = list(dict.fromkeys(types)) # stable unique
918
+
919
+ if expected_type:
920
+ if expected_type not in types:
921
+ continue
922
+
923
+ out.append(
924
+ {
925
+ "id": cid.strip(),
926
+ "label": str(c.get("label") or label_norm),
927
+ "types": types,
928
+ "scope": scope_val,
929
+ "owner_id": owner_val,
930
+ "score": c.get("score"),
931
+ "evidence": c.get("_evidence"),
932
+ }
933
+ )
934
+ if len(out) >= max_candidates:
935
+ break
936
+
937
+ result: dict[str, Any] = {"ok": True, "count": len(out), "candidates": out, "raw": {"label": label_norm, "expected_type": expected_type or None}}
938
+ if warnings:
939
+ result["warnings"] = [w for w in warnings if str(w).strip()]
940
+ return EffectOutcome.completed(result)
941
+
942
+ return {
943
+ EffectType.MEMORY_KG_ASSERT: _handle_assert,
944
+ EffectType.MEMORY_KG_QUERY: _handle_query,
945
+ EffectType.MEMORY_KG_RESOLVE: _handle_resolve,
946
+ }