graphrefly 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2132 @@
1
+ """AI surface patterns (roadmap §4.4).
2
+
3
+ Domain-layer factories for LLM-backed agents, chat, tool registries, and
4
+ agentic memory. Composed from core + extra + Phase 3–4.3 primitives.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ import json
11
+ import math
12
+ import threading
13
+ from collections.abc import AsyncIterable
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
16
+
17
+ from graphrefly.core.clock import monotonic_ns
18
+ from graphrefly.core.node import Node
19
+ from graphrefly.core.protocol import MessageType, batch
20
+ from graphrefly.core.sugar import derived, effect, producer, state
21
+ from graphrefly.extra.composite import distill
22
+ from graphrefly.extra.data_structures import reactive_log
23
+ from graphrefly.extra.sources import first_value_from, from_any, from_timer
24
+ from graphrefly.extra.tier2 import switch_map
25
+ from graphrefly.graph.graph import Graph
26
+ from graphrefly.patterns.memory import (
27
+ KnowledgeGraph,
28
+ VectorIndex,
29
+ decay,
30
+ knowledge_graph,
31
+ light_collection,
32
+ vector_index,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable, Mapping, Sequence
37
+
38
+ from graphrefly.core.node import NodeImpl
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Types
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class ChatMessage:
48
+ """A single chat message in a conversation."""
49
+
50
+ role: str # "system" | "user" | "assistant" | "tool"
51
+ content: str
52
+ name: str | None = None
53
+ tool_call_id: str | None = None
54
+ tool_calls: tuple[ToolCall, ...] | None = None
55
+ metadata: dict[str, Any] | None = None
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ToolCall:
60
+ """A tool invocation request from an LLM."""
61
+
62
+ id: str
63
+ name: str
64
+ arguments: dict[str, Any] = field(default_factory=dict)
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class LLMResponse:
69
+ """The response from an LLM invocation."""
70
+
71
+ content: str
72
+ tool_calls: tuple[ToolCall, ...] | None = None
73
+ usage: dict[str, int] | None = None
74
+ finish_reason: str | None = None
75
+ metadata: dict[str, Any] | None = None
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class LLMInvokeOptions:
80
+ """Options for :meth:`LLMAdapter.invoke`."""
81
+
82
+ model: str | None = None
83
+ temperature: float | None = None
84
+ max_tokens: int | None = None
85
+ tools: tuple[ToolDefinition, ...] | None = None
86
+ system_prompt: str | None = None
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class ToolDefinition:
91
+ """A tool definition for LLM consumption."""
92
+
93
+ name: str
94
+ description: str
95
+ parameters: dict[str, Any] # JSON Schema
96
+ handler: Callable[..., Any] = field(default=lambda args: None)
97
+ version: dict[str, Any] | None = field(default=None)
98
+ """V0 version of the backing node at ``knobs_as_tools()`` call time (§6.0b).
99
+ Snapshot — re-call ``knobs_as_tools()`` to refresh."""
100
+
101
+
102
+ @runtime_checkable
103
+ class LLMAdapter(Protocol):
104
+ """Provider-agnostic LLM client adapter protocol."""
105
+
106
+ def invoke(
107
+ self,
108
+ messages: Sequence[ChatMessage],
109
+ opts: LLMInvokeOptions | None = None,
110
+ ) -> Any:
111
+ """Invoke the LLM. Returns NodeInput[LLMResponse]."""
112
+ ...
113
+
114
+
115
+ AgentLoopStatus = str # "idle" | "thinking" | "acting" | "done" | "error"
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Meta helpers
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def _ai_meta(kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
124
+ out: dict[str, Any] = {"ai": True, "ai_type": kind}
125
+ if extra:
126
+ out.update(extra)
127
+ return out
128
+
129
+
130
+ def _keepalive(n: Any) -> Any:
131
+ """Subscribe to keep derived node wired; returns unsubscribe handle."""
132
+ return n.subscribe(lambda _msgs: None)
133
+
134
+
135
+ _DEFAULT_TIMEOUT = 30.0 # seconds
136
+
137
+
138
+ def _resolve_node_input(raw: Any, *, timeout: float = _DEFAULT_TIMEOUT) -> Any:
139
+ """Resolve tool handler output via ``from_any`` / ``get()`` and first ``DATA``."""
140
+ if isinstance(raw, Node):
141
+ # Only trust get() when node is in settled state
142
+ if getattr(raw, "status", None) == "settled":
143
+ cached = raw.get()
144
+ if cached is not None:
145
+ return cached
146
+ try:
147
+ return first_value_from(raw, timeout=timeout)
148
+ except StopIteration:
149
+ msg = "tool_registry: handler completed without producing a value"
150
+ raise ValueError(msg) from None
151
+ if inspect.isawaitable(raw):
152
+ try:
153
+ return first_value_from(from_any(raw), timeout=timeout)
154
+ except StopIteration:
155
+ msg = "tool_registry: awaitable handler completed without producing a value"
156
+ raise ValueError(msg) from None
157
+ if isinstance(raw, AsyncIterable):
158
+ try:
159
+ return first_value_from(from_any(raw), timeout=timeout)
160
+ except StopIteration:
161
+ msg = "tool_registry: async iterable handler completed without producing a value"
162
+ raise ValueError(msg) from None
163
+ return raw
164
+
165
+
166
+ def _tuple_snapshot(raw: Any) -> tuple[Any, ...]:
167
+ if isinstance(raw, tuple):
168
+ return raw
169
+ if isinstance(raw, list):
170
+ return tuple(raw)
171
+ return ()
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # from_llm
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def from_llm(
180
+ adapter: LLMAdapter,
181
+ messages: Any,
182
+ *,
183
+ model: str | None = None,
184
+ temperature: float | None = None,
185
+ max_tokens: int | None = None,
186
+ tools: Sequence[ToolDefinition] | None = None,
187
+ system_prompt: str | None = None,
188
+ name: str | None = None,
189
+ ) -> Any:
190
+ """Reactive LLM invocation adapter.
191
+
192
+ Returns a derived node that re-invokes the LLM whenever the messages
193
+ dep changes. Uses ``switch_map`` internally — new invocations cancel
194
+ stale in-flight ones.
195
+ """
196
+ msgs_node = from_any(messages)
197
+ invoke_opts = LLMInvokeOptions(
198
+ model=model,
199
+ temperature=temperature,
200
+ max_tokens=max_tokens,
201
+ tools=tuple(tools) if tools else None,
202
+ system_prompt=system_prompt,
203
+ )
204
+
205
+ def _invoke(msgs: Any) -> Any:
206
+ if not msgs:
207
+ return state(None)
208
+ return adapter.invoke(msgs, invoke_opts)
209
+
210
+ return switch_map(_invoke)(msgs_node)
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # chat_stream
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ class ChatStreamGraph(Graph):
219
+ """Reactive chat message stream with role tracking."""
220
+
221
+ __slots__ = ("_keepalive_subs", "_log", "messages", "latest", "message_count")
222
+
223
+ def __init__(
224
+ self,
225
+ name: str,
226
+ *,
227
+ opts: dict[str, Any] | None = None,
228
+ max_messages: int | None = None,
229
+ ) -> None:
230
+ super().__init__(name, opts)
231
+ self._keepalive_subs: list[Any] = []
232
+ self._log = reactive_log(max_size=max_messages, name="messages")
233
+ self.messages = self._log.entries
234
+ self.add("messages", self.messages)
235
+
236
+ def compute_latest(deps: list[Any], _actions: Any) -> Any:
237
+ raw = deps[0]
238
+ entries = _tuple_snapshot(raw.value if hasattr(raw, "value") else ())
239
+ return entries[-1] if entries else None
240
+
241
+ self.latest: NodeImpl[ChatMessage | None] = derived(
242
+ [self.messages],
243
+ compute_latest,
244
+ name="latest",
245
+ meta=_ai_meta("chat_latest"),
246
+ initial=None,
247
+ )
248
+ self.add("latest", self.latest)
249
+ self.connect("messages", "latest")
250
+ self._keepalive_subs.append(_keepalive(self.latest))
251
+
252
+ def compute_count(deps: list[Any], _actions: Any) -> int:
253
+ raw = deps[0]
254
+ entries = _tuple_snapshot(raw.value if hasattr(raw, "value") else ())
255
+ return len(entries)
256
+
257
+ self.message_count: NodeImpl[int] = derived(
258
+ [self.messages],
259
+ compute_count,
260
+ name="messageCount",
261
+ meta=_ai_meta("chat_message_count"),
262
+ initial=0,
263
+ )
264
+ self.add("messageCount", self.message_count)
265
+ self.connect("messages", "messageCount")
266
+ self._keepalive_subs.append(_keepalive(self.message_count))
267
+
268
+ def append(
269
+ self,
270
+ role: str,
271
+ content: str,
272
+ *,
273
+ name: str | None = None,
274
+ tool_call_id: str | None = None,
275
+ tool_calls: tuple[ToolCall, ...] | None = None,
276
+ metadata: dict[str, Any] | None = None,
277
+ ) -> None:
278
+ """Append a message to the chat stream."""
279
+ self._log.append(
280
+ ChatMessage(
281
+ role=role,
282
+ content=content,
283
+ name=name,
284
+ tool_call_id=tool_call_id,
285
+ tool_calls=tool_calls,
286
+ metadata=metadata,
287
+ )
288
+ )
289
+
290
+ def append_tool_result(self, call_id: str, content: str) -> None:
291
+ """Append a tool result message."""
292
+ self._log.append(ChatMessage(role="tool", content=content, tool_call_id=call_id))
293
+
294
+ def clear(self) -> None:
295
+ """Clear all messages."""
296
+ self._log.clear()
297
+
298
+ def all_messages(self) -> tuple[ChatMessage, ...]:
299
+ """Return all messages as a tuple."""
300
+ raw = self.messages.get()
301
+ if raw is None or not hasattr(raw, "value"):
302
+ return ()
303
+ return _tuple_snapshot(raw.value)
304
+
305
+ def destroy(self) -> None:
306
+ for unsub in self._keepalive_subs:
307
+ unsub()
308
+ self._keepalive_subs.clear()
309
+ super().destroy()
310
+
311
+
312
+ def chat_stream(
313
+ name: str,
314
+ *,
315
+ opts: dict[str, Any] | None = None,
316
+ max_messages: int | None = None,
317
+ ) -> ChatStreamGraph:
318
+ """Create a reactive chat stream graph."""
319
+ return ChatStreamGraph(name, opts=opts, max_messages=max_messages)
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # tool_registry
324
+ # ---------------------------------------------------------------------------
325
+
326
+
327
+ class ToolRegistryGraph(Graph):
328
+ """Tool definition store + dispatch."""
329
+
330
+ __slots__ = ("_keepalive_subs", "definitions", "schemas")
331
+
332
+ def __init__(
333
+ self,
334
+ name: str,
335
+ *,
336
+ opts: dict[str, Any] | None = None,
337
+ ) -> None:
338
+ super().__init__(name, opts)
339
+ self._keepalive_subs: list[Any] = []
340
+
341
+ self.definitions: NodeImpl[Mapping[str, ToolDefinition]] = state(
342
+ {},
343
+ name="definitions",
344
+ describe_kind="state",
345
+ meta=_ai_meta("tool_definitions"),
346
+ )
347
+ self.add("definitions", self.definitions)
348
+
349
+ def compute_schemas(deps: list[Any], _actions: Any) -> tuple[ToolDefinition, ...]:
350
+ defs = deps[0]
351
+ if defs is None:
352
+ return ()
353
+ return tuple(defs.values()) if isinstance(defs, dict) else ()
354
+
355
+ self.schemas: NodeImpl[tuple[ToolDefinition, ...]] = derived(
356
+ [self.definitions],
357
+ compute_schemas,
358
+ name="schemas",
359
+ meta=_ai_meta("tool_schemas"),
360
+ initial=(),
361
+ )
362
+ self.add("schemas", self.schemas)
363
+ self.connect("definitions", "schemas")
364
+ self._keepalive_subs.append(_keepalive(self.schemas))
365
+
366
+ def register(self, tool: ToolDefinition) -> None:
367
+ """Register a tool definition."""
368
+ current = self.definitions.get() or {}
369
+ next_defs = dict(current)
370
+ next_defs[tool.name] = tool
371
+ self.definitions.down([(MessageType.DATA, next_defs)])
372
+
373
+ def unregister(self, name: str) -> None:
374
+ """Unregister a tool by name."""
375
+ current = self.definitions.get() or {}
376
+ if name not in current:
377
+ return
378
+ next_defs = dict(current)
379
+ del next_defs[name]
380
+ self.definitions.down([(MessageType.DATA, next_defs)])
381
+
382
+ def execute(self, name: str, args: dict[str, Any]) -> Any:
383
+ """Execute a tool by name. Resolves async/reactive handler results."""
384
+ defs = self.definitions.get() or {}
385
+ tool = defs.get(name)
386
+ if tool is None:
387
+ msg = f'tool_registry: unknown tool "{name}"'
388
+ raise ValueError(msg)
389
+ return _resolve_node_input(tool.handler(args))
390
+
391
+ def get_definition(self, name: str) -> ToolDefinition | None:
392
+ """Get a tool definition by name."""
393
+ defs = self.definitions.get() or {}
394
+ return defs.get(name)
395
+
396
+ def destroy(self) -> None:
397
+ for unsub in self._keepalive_subs:
398
+ unsub()
399
+ self._keepalive_subs.clear()
400
+ super().destroy()
401
+
402
+
403
+ def tool_registry(
404
+ name: str,
405
+ *,
406
+ opts: dict[str, Any] | None = None,
407
+ ) -> ToolRegistryGraph:
408
+ """Create a tool registry graph."""
409
+ return ToolRegistryGraph(name, opts=opts)
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # system_prompt_builder
414
+ # ---------------------------------------------------------------------------
415
+
416
+
417
+ class SystemPromptHandle:
418
+ """A system prompt node with a ``dispose()`` method for cleanup."""
419
+
420
+ __slots__ = ("_node", "_unsub")
421
+
422
+ def __init__(self, node: Any, unsub: Any) -> None:
423
+ self._node = node
424
+ self._unsub = unsub
425
+
426
+ def get(self) -> str:
427
+ return self._node.get()
428
+
429
+ def subscribe(self, listener: Any) -> Any:
430
+ return self._node.subscribe(listener)
431
+
432
+ def down(self, msgs: Any) -> None:
433
+ self._node.down(msgs)
434
+
435
+ def dispose(self) -> None:
436
+ self._unsub()
437
+
438
+ def describe(self) -> Any:
439
+ return self._node.describe() if hasattr(self._node, "describe") else {}
440
+
441
+ def __getattr__(self, name: str) -> Any:
442
+ return getattr(self._node, name)
443
+
444
+
445
+ def system_prompt_builder(
446
+ sections: Sequence[Any],
447
+ *,
448
+ separator: str = "\n\n",
449
+ name: str | None = None,
450
+ ) -> SystemPromptHandle:
451
+ """Assemble a system prompt from reactive sections.
452
+
453
+ Each section is a ``NodeInput[str]`` — the prompt updates when any
454
+ section changes.
455
+ """
456
+ section_nodes = [state(s) if isinstance(s, str) else from_any(s) for s in sections]
457
+
458
+ def compute_prompt(deps: list[Any], _actions: Any) -> str:
459
+ return separator.join(str(v) for v in deps if v is not None and v != "")
460
+
461
+ result = derived(
462
+ section_nodes,
463
+ compute_prompt,
464
+ name=name or "systemPrompt",
465
+ meta=_ai_meta("system_prompt"),
466
+ initial="",
467
+ )
468
+ unsub = _keepalive(result)
469
+ return SystemPromptHandle(result, unsub)
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # llm_extractor / llm_consolidator
474
+ # ---------------------------------------------------------------------------
475
+
476
+
477
+ def llm_extractor(
478
+ system_prompt: str,
479
+ *,
480
+ adapter: LLMAdapter,
481
+ model: str | None = None,
482
+ temperature: float | None = None,
483
+ max_tokens: int | None = None,
484
+ ) -> Callable[[Any, Mapping[str, Any]], Any]:
485
+ """Return an ``extract_fn`` callback for :func:`distill`.
486
+
487
+ The system prompt should instruct the LLM to return JSON matching
488
+ ``Extraction`` shape: ``{ "upsert": [{ "key": ..., "value": ... }], "remove": [...] }``.
489
+ """
490
+
491
+ def extract_fn(raw: Any, existing: Mapping[str, Any]) -> Any:
492
+ existing_keys = list(existing.keys())[:100]
493
+ messages = [
494
+ ChatMessage(role="system", content=system_prompt),
495
+ ChatMessage(
496
+ role="user",
497
+ content=json.dumps({"input": raw, "existingKeys": existing_keys}, default=str),
498
+ ),
499
+ ]
500
+
501
+ def _produce(deps: Any, actions: Any) -> Any:
502
+ result = adapter.invoke(
503
+ messages,
504
+ LLMInvokeOptions(
505
+ model=model,
506
+ temperature=temperature if temperature is not None else 0,
507
+ max_tokens=max_tokens,
508
+ ),
509
+ )
510
+ resolved = from_any(result)
511
+ active = True
512
+
513
+ def _on_msg(msgs: Any) -> None:
514
+ nonlocal active
515
+ if not active:
516
+ return
517
+ done = False
518
+ for msg in msgs:
519
+ if done:
520
+ break
521
+ if msg[0] == MessageType.DATA:
522
+ response = msg[1]
523
+ try:
524
+ parsed = json.loads(response.content)
525
+ actions.emit(parsed)
526
+ actions.down([(MessageType.COMPLETE,)])
527
+ except Exception:
528
+ actions.down(
529
+ [
530
+ (
531
+ MessageType.ERROR,
532
+ ValueError("llm_extractor: failed to parse LLM response"),
533
+ )
534
+ ]
535
+ )
536
+ done = True
537
+ elif msg[0] == MessageType.ERROR:
538
+ actions.down([(MessageType.ERROR, msg[1])])
539
+ done = True
540
+ elif msg[0] == MessageType.COMPLETE:
541
+ actions.down([(MessageType.COMPLETE,)])
542
+ done = True
543
+ else:
544
+ # Forward unknown message types (spec §1.3.6)
545
+ actions.down([(msg[0], msg[1] if len(msg) > 1 else None)])
546
+
547
+ unsub = resolved.subscribe(_on_msg)
548
+
549
+ def cleanup() -> None:
550
+ nonlocal active
551
+ unsub()
552
+ active = False
553
+
554
+ return cleanup
555
+
556
+ return producer(_produce)
557
+
558
+ return extract_fn
559
+
560
+
561
+ def llm_consolidator(
562
+ system_prompt: str,
563
+ *,
564
+ adapter: LLMAdapter,
565
+ model: str | None = None,
566
+ temperature: float | None = None,
567
+ max_tokens: int | None = None,
568
+ ) -> Callable[[Mapping[str, Any]], Any]:
569
+ """Return a ``consolidate_fn`` callback for :func:`distill`.
570
+
571
+ The system prompt should instruct the LLM to cluster and merge
572
+ related memories.
573
+ """
574
+
575
+ def consolidate_fn(entries: Mapping[str, Any]) -> Any:
576
+ entries_list = [{"key": k, "value": v} for k, v in entries.items()]
577
+ messages = [
578
+ ChatMessage(role="system", content=system_prompt),
579
+ ChatMessage(role="user", content=json.dumps({"memories": entries_list}, default=str)),
580
+ ]
581
+
582
+ def _produce(deps: Any, actions: Any) -> Any:
583
+ result = adapter.invoke(
584
+ messages,
585
+ LLMInvokeOptions(
586
+ model=model,
587
+ temperature=temperature if temperature is not None else 0,
588
+ max_tokens=max_tokens,
589
+ ),
590
+ )
591
+ resolved = from_any(result)
592
+ active = True
593
+
594
+ def _on_msg(msgs: Any) -> None:
595
+ nonlocal active
596
+ if not active:
597
+ return
598
+ done = False
599
+ for msg in msgs:
600
+ if done:
601
+ break
602
+ if msg[0] == MessageType.DATA:
603
+ response = msg[1]
604
+ try:
605
+ parsed = json.loads(response.content)
606
+ actions.emit(parsed)
607
+ actions.down([(MessageType.COMPLETE,)])
608
+ except Exception:
609
+ actions.down(
610
+ [
611
+ (
612
+ MessageType.ERROR,
613
+ ValueError(
614
+ "llm_consolidator: failed to parse LLM response"
615
+ ),
616
+ )
617
+ ]
618
+ )
619
+ done = True
620
+ elif msg[0] == MessageType.ERROR:
621
+ actions.down([(MessageType.ERROR, msg[1])])
622
+ done = True
623
+ elif msg[0] == MessageType.COMPLETE:
624
+ actions.down([(MessageType.COMPLETE,)])
625
+ done = True
626
+ else:
627
+ # Forward unknown message types (spec §1.3.6)
628
+ actions.down([(msg[0], msg[1] if len(msg) > 1 else None)])
629
+
630
+ unsub = resolved.subscribe(_on_msg)
631
+
632
+ def cleanup() -> None:
633
+ nonlocal active
634
+ unsub()
635
+ active = False
636
+
637
+ return cleanup
638
+
639
+ return producer(_produce)
640
+
641
+ return consolidate_fn
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # 3D Admission Scoring
646
+ # ---------------------------------------------------------------------------
647
+
648
+
649
+ @dataclass(frozen=True, slots=True)
650
+ class AdmissionScores:
651
+ """Scores for the three admission dimensions (each 0–1)."""
652
+
653
+ persistence: float
654
+ structure: float
655
+ personal_value: float
656
+
657
+
658
+ def _default_admission_scorer(_raw: Any) -> AdmissionScores:
659
+ return AdmissionScores(persistence=0.5, structure=0.5, personal_value=0.5)
660
+
661
+
662
+ def admission_filter_3d(
663
+ *,
664
+ score_fn: Callable[[Any], AdmissionScores] | None = None,
665
+ persistence_threshold: float = 0.3,
666
+ personal_value_threshold: float = 0.3,
667
+ require_structured: bool = False,
668
+ ) -> Callable[[Any], bool]:
669
+ """Create a 3D admission filter for ``agent_memory``'s ``admission_filter``."""
670
+ scorer = score_fn or _default_admission_scorer
671
+
672
+ def _filter(raw: Any) -> bool:
673
+ scores = scorer(raw)
674
+ if scores.persistence < persistence_threshold:
675
+ return False
676
+ if scores.personal_value < personal_value_threshold:
677
+ return False
678
+ if require_structured and scores.structure <= 0:
679
+ return False
680
+ return True
681
+
682
+ return _filter
683
+
684
+
685
+ # ---------------------------------------------------------------------------
686
+ # Memory Tiers
687
+ # ---------------------------------------------------------------------------
688
+
689
+
690
+ @dataclass(frozen=True, slots=True)
691
+ class RetrievalEntry:
692
+ """A single entry in a retrieval result, with causal trace metadata."""
693
+
694
+ key: str
695
+ value: Any
696
+ score: float
697
+ sources: tuple[str, ...]
698
+
699
+
700
+ @dataclass(frozen=True, slots=True)
701
+ class RetrievalTrace:
702
+ """Causal trace for a retrieval run."""
703
+
704
+ vector_candidates: tuple[Any, ...]
705
+ graph_expanded: tuple[str, ...]
706
+ ranked: tuple[RetrievalEntry, ...]
707
+ packed: tuple[RetrievalEntry, ...]
708
+
709
+
710
+ @dataclass(frozen=True, slots=True)
711
+ class RetrievalQuery:
712
+ """A retrieval query."""
713
+
714
+ text: str | None = None
715
+ vector: tuple[float, ...] | None = None
716
+ entity_ids: tuple[str, ...] | None = None
717
+
718
+
719
+ _DEFAULT_DECAY_RATE = math.log(2) / (7 * 86_400) # 7-day half-life
720
+
721
+
722
+ # ---------------------------------------------------------------------------
723
+ # agent_memory
724
+ # ---------------------------------------------------------------------------
725
+
726
+
727
+ class AgentMemoryGraph(Graph):
728
+ """Pre-wired agentic memory graph.
729
+
730
+ Composes ``distill()`` with optional ``knowledge_graph()``,
731
+ ``vector_index()``, ``light_collection()`` (permanent tier),
732
+ ``decay()``, and ``auto_checkpoint()`` (archive tier). Supports 3D
733
+ admission scoring, a default retrieval pipeline, periodic reflection,
734
+ and retrieval observability traces.
735
+ """
736
+
737
+ __slots__ = (
738
+ "_keepalive_subs",
739
+ "compact",
740
+ "distill_bundle",
741
+ "kg",
742
+ "memory_tiers",
743
+ "retrieval",
744
+ "retrieval_trace",
745
+ "retrieve",
746
+ "size_node",
747
+ "vectors",
748
+ )
749
+
750
+ def __init__( # noqa: C901, PLR0912, PLR0915
751
+ self,
752
+ name: str,
753
+ source: Any,
754
+ *,
755
+ score: Callable[[Any, Any], float],
756
+ cost: Callable[[Any], float],
757
+ adapter: LLMAdapter | None = None,
758
+ extract_prompt: str | None = None,
759
+ extract_fn: Callable[[Any, Mapping[str, Any]], Any] | None = None,
760
+ consolidate_prompt: str | None = None,
761
+ consolidate_fn: Callable[[Mapping[str, Any]], Any] | None = None,
762
+ consolidate_trigger: Any | None = None,
763
+ budget: float = 2000,
764
+ context: Any | None = None,
765
+ admission_filter: Callable[[Any], bool] | None = None,
766
+ # New: in-factory composition
767
+ vector_dimensions: int | None = None,
768
+ embed_fn: Callable[[Any], tuple[float, ...] | None] | None = None,
769
+ enable_knowledge_graph: bool = False,
770
+ entity_fn: Callable[[str, Any], dict[str, Any] | None] | None = None,
771
+ tiers: dict[str, Any] | None = None,
772
+ retrieval_opts: dict[str, Any] | None = None,
773
+ reflection: dict[str, Any] | None = None,
774
+ opts: dict[str, Any] | None = None,
775
+ ) -> None:
776
+ super().__init__(name, opts)
777
+ self._keepalive_subs: list[Callable[[], None]] = []
778
+
779
+ # --- Extract function resolution ---
780
+ raw_extract: Callable[[Any, Mapping[str, Any]], Any]
781
+ if extract_fn is not None:
782
+ raw_extract = extract_fn
783
+ elif adapter is not None and extract_prompt is not None:
784
+ raw_extract = llm_extractor(extract_prompt, adapter=adapter)
785
+ else:
786
+ msg = "agent_memory: provide either extract_fn or adapter + extract_prompt"
787
+ raise ValueError(msg)
788
+
789
+ def resolved_extract(raw: Any, existing: Mapping[str, Any]) -> Any:
790
+ if raw is None:
791
+ return {"upsert": []}
792
+ return raw_extract(raw, existing)
793
+
794
+ # --- Admission filter ---
795
+ filtered_source = source
796
+ if admission_filter is not None:
797
+ src_node = from_any(source)
798
+ filt = admission_filter
799
+
800
+ def _filter(deps: list[Any], _actions: Any) -> Any:
801
+ raw = deps[0]
802
+ return raw if filt(raw) else None
803
+
804
+ filtered_source = derived([src_node], _filter, name="admissionFilter")
805
+
806
+ # --- Consolidation ---
807
+ resolved_consolidate: Callable[[Mapping[str, Any]], Any] | None = None
808
+ if consolidate_fn is not None:
809
+ resolved_consolidate = consolidate_fn
810
+ elif adapter is not None and consolidate_prompt is not None:
811
+ resolved_consolidate = llm_consolidator(consolidate_prompt, adapter=adapter)
812
+
813
+ # --- Reflection: default consolidate_trigger from from_timer ---
814
+ effective_trigger = consolidate_trigger
815
+ if (
816
+ effective_trigger is None
817
+ and resolved_consolidate is not None
818
+ and (reflection is None or reflection.get("enabled", True))
819
+ ):
820
+ interval_s = (reflection or {}).get("interval", 300.0)
821
+ effective_trigger = from_timer(interval_s, period=interval_s)
822
+
823
+ # --- Build distill bundle ---
824
+ bundle = distill(
825
+ filtered_source,
826
+ resolved_extract,
827
+ score=score,
828
+ cost=cost,
829
+ budget=budget,
830
+ context=context,
831
+ consolidate=resolved_consolidate,
832
+ consolidate_trigger=effective_trigger,
833
+ )
834
+
835
+ self.distill_bundle = bundle
836
+ self.compact = bundle.compact
837
+ self.size_node = bundle.size
838
+
839
+ self.add("store", bundle.store.data)
840
+ self.add("compact", bundle.compact)
841
+ self.add("size", bundle.size)
842
+ self.connect("store", "compact")
843
+ self.connect("store", "size")
844
+
845
+ # --- Vector index (optional) ---
846
+ self.vectors: VectorIndex[Any] | None = None
847
+ if vector_dimensions and vector_dimensions > 0 and embed_fn is not None:
848
+ self.vectors = vector_index(dimension=vector_dimensions)
849
+ self.add("vectorIndex", self.vectors.entries)
850
+
851
+ # --- Knowledge graph (optional) ---
852
+ self.kg: KnowledgeGraph[Any, str] | None = None
853
+ if enable_knowledge_graph:
854
+ self.kg = knowledge_graph(f"{name}-kg")
855
+ self.mount("kg", self.kg)
856
+
857
+ # --- 3-tier storage (optional) ---
858
+ self.memory_tiers: dict[str, Any] | None = None
859
+ if tiers is not None:
860
+ decay_rate = tiers.get("decay_rate", _DEFAULT_DECAY_RATE)
861
+ max_active = tiers.get("max_active", 1000)
862
+ archive_threshold = tiers.get("archive_threshold", 0.1)
863
+ permanent_filter_fn = tiers.get("permanent_filter", lambda _k, _m: False)
864
+
865
+ permanent = light_collection(name="permanent")
866
+ self.add("permanent", permanent.entries)
867
+ permanent_keys: set[str] = set()
868
+
869
+ def _tier_of(key: str) -> str:
870
+ if key in permanent_keys:
871
+ return "permanent"
872
+ snap = bundle.store.data.get()
873
+ store_map = _extract_store_map(snap)
874
+ return "active" if key in store_map else "archived"
875
+
876
+ def _mark_permanent(key: str, value: Any) -> None:
877
+ permanent_keys.add(key)
878
+ permanent.upsert(key, value)
879
+
880
+ store_node = bundle.store.data
881
+ ctx_node = from_any(context) if context is not None else state(None)
882
+
883
+ # Track entry creation times for accurate decay age calculation
884
+ entry_created_at_ns: dict[str, int] = {}
885
+
886
+ def _classify_tiers(deps: list[Any], _actions: Any) -> None:
887
+ snap = deps[0]
888
+ ctx = deps[1]
889
+ store_map = _extract_store_map(snap)
890
+ now_ns = monotonic_ns()
891
+ to_archive: list[str] = []
892
+ to_permanent: list[tuple[str, Any]] = []
893
+
894
+ for key, mem in store_map.items():
895
+ # Track creation time for new entries
896
+ if key not in entry_created_at_ns:
897
+ entry_created_at_ns[key] = now_ns
898
+
899
+ if permanent_filter_fn(key, mem):
900
+ to_permanent.append((key, mem))
901
+ continue
902
+ base_score = score(mem, ctx)
903
+ created_ns = entry_created_at_ns.get(key, now_ns)
904
+ age_seconds = (now_ns - created_ns) / 1e9
905
+ decayed = decay(base_score, age_seconds, decay_rate)
906
+ if decayed < archive_threshold:
907
+ to_archive.append(key)
908
+
909
+ # Clean up creation times for removed entries
910
+ for key in list(entry_created_at_ns):
911
+ if key not in store_map:
912
+ del entry_created_at_ns[key]
913
+
914
+ for k, v in to_permanent:
915
+ if k not in permanent_keys:
916
+ _mark_permanent(k, v)
917
+
918
+ # Exclude permanent keys from active count
919
+ active_count = len(store_map) - len(permanent_keys)
920
+ if active_count > max_active:
921
+ scored = sorted(
922
+ (
923
+ (k, score(m, ctx))
924
+ for k, m in store_map.items()
925
+ if k not in permanent_keys
926
+ ),
927
+ key=lambda x: x[1],
928
+ )
929
+ excess = active_count - max_active
930
+ for i in range(min(excess, len(scored))):
931
+ sk = scored[i][0]
932
+ if sk not in to_archive:
933
+ to_archive.append(sk)
934
+
935
+ if to_archive:
936
+ with batch():
937
+ for key in to_archive:
938
+ bundle.store.delete(key)
939
+
940
+ tier_eff = effect([store_node, ctx_node], _classify_tiers)
941
+ self._keepalive_subs.append(tier_eff.subscribe(lambda _msgs: None))
942
+
943
+ archive_handle = None
944
+ if "archive_adapter" in tiers:
945
+ archive_handle = self.auto_checkpoint(
946
+ tiers["archive_adapter"],
947
+ **(tiers.get("archive_checkpoint_options") or {}),
948
+ )
949
+
950
+ self.memory_tiers = {
951
+ "permanent": permanent,
952
+ "tier_of": _tier_of,
953
+ "mark_permanent": _mark_permanent,
954
+ "archive_handle": archive_handle,
955
+ }
956
+
957
+ # --- Post-extraction hooks: vector + KG indexing ---
958
+ if self.vectors or self.kg:
959
+ _embed_fn = embed_fn
960
+ _entity_fn = entity_fn
961
+ _vectors = self.vectors
962
+ _kg = self.kg
963
+ store_node = bundle.store.data
964
+
965
+ def _index(deps: list[Any], _actions: Any) -> None:
966
+ snap = deps[0]
967
+ store_map = _extract_store_map(snap)
968
+ for key, mem in store_map.items():
969
+ if _vectors and _embed_fn:
970
+ vec = _embed_fn(mem)
971
+ if vec is not None:
972
+ _vectors.upsert(key, list(vec), mem)
973
+ if _kg and _entity_fn:
974
+ extracted = _entity_fn(key, mem)
975
+ if extracted:
976
+ for ent in extracted.get("entities", []):
977
+ _kg.upsert_entity(ent["id"], ent["value"])
978
+ for rel in extracted.get("relations", []):
979
+ _kg.link(
980
+ rel["from"], rel["to"], rel["relation"], rel.get("weight", 1.0)
981
+ )
982
+
983
+ idx_eff = effect([store_node], _index)
984
+ self._keepalive_subs.append(idx_eff.subscribe(lambda _msgs: None))
985
+
986
+ # --- Retrieval pipeline (optional) ---
987
+ self.retrieval: Node[Any] | None = None
988
+ self.retrieval_trace: Node[Any] | None = None
989
+ self.retrieve: Callable[[RetrievalQuery], tuple[RetrievalEntry, ...]] | None = None
990
+
991
+ if self.vectors or self.kg:
992
+ top_k = (retrieval_opts or {}).get("top_k", 20)
993
+ graph_depth = (retrieval_opts or {}).get("graph_depth", 1)
994
+ r_budget = budget
995
+ r_cost = cost
996
+ r_score = score
997
+ _vectors = self.vectors
998
+ _kg = self.kg
999
+
1000
+ query_input: Node[Any] = state(None, name="retrievalQuery")
1001
+ self.add("retrievalQuery", query_input)
1002
+
1003
+ ctx_node = from_any(context) if context is not None else state(None)
1004
+ trace_state: Node[Any] = state(None, name="retrievalTrace")
1005
+ self.add("retrievalTrace", trace_state)
1006
+ self.retrieval_trace = trace_state
1007
+
1008
+ store_node = bundle.store.data
1009
+
1010
+ # Last trace captured during retrieval (no side-effect in derived)
1011
+ last_trace: list[RetrievalTrace | None] = [None]
1012
+
1013
+ def _retrieve(deps: list[Any], _actions: Any) -> Any:
1014
+ query = deps[0]
1015
+ snap = deps[1]
1016
+ ctx = deps[2]
1017
+ if query is None:
1018
+ return ()
1019
+ q: RetrievalQuery = query
1020
+ store_map = _extract_store_map(snap)
1021
+
1022
+ candidate_map: dict[str, tuple[Any, set[str]]] = {}
1023
+
1024
+ # Stage 1: Vector search
1025
+ vector_candidates: list[Any] = []
1026
+ if _vectors and q.vector is not None:
1027
+ vector_candidates = list(_vectors.search(list(q.vector), top_k))
1028
+ for vc in vector_candidates:
1029
+ mem = store_map.get(vc.id)
1030
+ if mem is not None:
1031
+ candidate_map[vc.id] = (mem, {"vector"})
1032
+
1033
+ # Stage 2: KG expansion
1034
+ graph_expanded: list[str] = []
1035
+ if _kg:
1036
+ seed_ids = list(q.entity_ids or ()) + list(candidate_map.keys())
1037
+ visited: set[str] = set()
1038
+ frontier = seed_ids
1039
+ for _depth in range(graph_depth):
1040
+ next_frontier: list[str] = []
1041
+ for eid in frontier:
1042
+ if eid in visited:
1043
+ continue
1044
+ visited.add(eid)
1045
+ for edge in _kg.related(eid):
1046
+ tid = edge.to_id
1047
+ if tid not in visited:
1048
+ next_frontier.append(tid)
1049
+ mem = store_map.get(tid)
1050
+ if mem is not None:
1051
+ if tid in candidate_map:
1052
+ candidate_map[tid][1].add("graph")
1053
+ else:
1054
+ candidate_map[tid] = (mem, {"graph"})
1055
+ graph_expanded.append(tid)
1056
+ frontier = next_frontier
1057
+
1058
+ # Include remaining store entries
1059
+ for key, mem in store_map.items():
1060
+ if key not in candidate_map:
1061
+ candidate_map[key] = (mem, {"store"})
1062
+
1063
+ # Stage 3: Score and rank
1064
+ ranked: list[RetrievalEntry] = []
1065
+ for key, (value, sources) in candidate_map.items():
1066
+ s = r_score(value, ctx)
1067
+ ranked.append(
1068
+ RetrievalEntry(key=key, value=value, score=s, sources=tuple(sources))
1069
+ )
1070
+ ranked.sort(key=lambda e: e.score, reverse=True)
1071
+
1072
+ # Stage 4: Budget packing
1073
+ packed: list[RetrievalEntry] = []
1074
+ used_budget = 0.0
1075
+ for entry in ranked:
1076
+ c = r_cost(entry.value)
1077
+ if used_budget + c > r_budget and packed:
1078
+ break
1079
+ packed.append(entry)
1080
+ used_budget += c
1081
+
1082
+ # Capture trace (no side-effect — stored for retrieval by _do_retrieve)
1083
+ last_trace[0] = RetrievalTrace(
1084
+ vector_candidates=tuple(vector_candidates),
1085
+ graph_expanded=tuple(graph_expanded),
1086
+ ranked=tuple(ranked),
1087
+ packed=tuple(packed),
1088
+ )
1089
+
1090
+ return tuple(packed)
1091
+
1092
+ retrieval_derived = derived(
1093
+ [query_input, store_node, ctx_node],
1094
+ _retrieve,
1095
+ name="retrieval",
1096
+ initial=(),
1097
+ )
1098
+ self.add("retrieval", retrieval_derived)
1099
+ self.connect("retrievalQuery", "retrieval")
1100
+ self.connect("store", "retrieval")
1101
+ self._keepalive_subs.append(retrieval_derived.subscribe(lambda _msgs: None))
1102
+ self.retrieval = retrieval_derived
1103
+
1104
+ def _do_retrieve(query: RetrievalQuery) -> tuple[RetrievalEntry, ...]:
1105
+ query_input.down([(MessageType.DATA, query)])
1106
+ result = retrieval_derived.get() or ()
1107
+ # Update trace node outside derived callback (avoids reactive glitch)
1108
+ if last_trace[0] is not None:
1109
+ trace_state.down([(MessageType.DATA, last_trace[0])])
1110
+ return result
1111
+
1112
+ self.retrieve = _do_retrieve
1113
+
1114
+ def destroy(self) -> None:
1115
+ for unsub in self._keepalive_subs:
1116
+ unsub()
1117
+ self._keepalive_subs.clear()
1118
+ super().destroy()
1119
+
1120
+
1121
+ def _extract_store_map(snap: Any) -> dict[str, Any]:
1122
+ """Extract the key→value mapping from a reactive_map snapshot."""
1123
+ if snap is None:
1124
+ return {}
1125
+ if hasattr(snap, "value") and hasattr(snap.value, "map"):
1126
+ m = snap.value.map
1127
+ return dict(m) if m is not None else {}
1128
+ if isinstance(snap, dict):
1129
+ return snap
1130
+ return {}
1131
+
1132
+
1133
+ def agent_memory(
1134
+ name: str,
1135
+ source: Any,
1136
+ *,
1137
+ score: Callable[[Any, Any], float],
1138
+ cost: Callable[[Any], float],
1139
+ adapter: LLMAdapter | None = None,
1140
+ extract_prompt: str | None = None,
1141
+ extract_fn: Callable[[Any, Mapping[str, Any]], Any] | None = None,
1142
+ consolidate_prompt: str | None = None,
1143
+ consolidate_fn: Callable[[Mapping[str, Any]], Any] | None = None,
1144
+ consolidate_trigger: Any | None = None,
1145
+ budget: float = 2000,
1146
+ context: Any | None = None,
1147
+ admission_filter: Callable[[Any], bool] | None = None,
1148
+ vector_dimensions: int | None = None,
1149
+ embed_fn: Callable[[Any], tuple[float, ...] | None] | None = None,
1150
+ enable_knowledge_graph: bool = False,
1151
+ entity_fn: Callable[[str, Any], dict[str, Any] | None] | None = None,
1152
+ tiers: dict[str, Any] | None = None,
1153
+ retrieval_opts: dict[str, Any] | None = None,
1154
+ reflection: dict[str, Any] | None = None,
1155
+ opts: dict[str, Any] | None = None,
1156
+ ) -> AgentMemoryGraph:
1157
+ """Pre-wired agentic memory graph factory."""
1158
+ return AgentMemoryGraph(
1159
+ name,
1160
+ source,
1161
+ score=score,
1162
+ cost=cost,
1163
+ adapter=adapter,
1164
+ extract_prompt=extract_prompt,
1165
+ extract_fn=extract_fn,
1166
+ consolidate_prompt=consolidate_prompt,
1167
+ consolidate_fn=consolidate_fn,
1168
+ consolidate_trigger=consolidate_trigger,
1169
+ budget=budget,
1170
+ context=context,
1171
+ admission_filter=admission_filter,
1172
+ vector_dimensions=vector_dimensions,
1173
+ embed_fn=embed_fn,
1174
+ enable_knowledge_graph=enable_knowledge_graph,
1175
+ entity_fn=entity_fn,
1176
+ tiers=tiers,
1177
+ retrieval_opts=retrieval_opts,
1178
+ reflection=reflection,
1179
+ opts=opts,
1180
+ )
1181
+
1182
+
1183
+ # ---------------------------------------------------------------------------
1184
+ # agent_loop
1185
+ # ---------------------------------------------------------------------------
1186
+
1187
+
1188
+ class AgentLoopGraph(Graph):
1189
+ """LLM reasoning loop: think → act → observe → repeat."""
1190
+
1191
+ __slots__ = (
1192
+ "_abort_event",
1193
+ "_adapter",
1194
+ "_max_tokens",
1195
+ "_max_turns",
1196
+ "_model",
1197
+ "_on_tool_call",
1198
+ "_running",
1199
+ "_status_state",
1200
+ "_stop_when",
1201
+ "_system_prompt",
1202
+ "_temperature",
1203
+ "_turn_count_state",
1204
+ "chat",
1205
+ "last_response",
1206
+ "status",
1207
+ "tools",
1208
+ "turn_count",
1209
+ )
1210
+
1211
+ def __init__(
1212
+ self,
1213
+ name: str,
1214
+ *,
1215
+ adapter: LLMAdapter,
1216
+ tools: Sequence[ToolDefinition] | None = None,
1217
+ system_prompt: str | None = None,
1218
+ max_turns: int = 10,
1219
+ stop_when: Callable[[LLMResponse], bool] | None = None,
1220
+ on_tool_call: Callable[[ToolCall], None] | None = None,
1221
+ max_messages: int | None = None,
1222
+ model: str | None = None,
1223
+ temperature: float | None = None,
1224
+ max_tokens: int | None = None,
1225
+ opts: dict[str, Any] | None = None,
1226
+ ) -> None:
1227
+ super().__init__(name, opts)
1228
+ self._adapter = adapter
1229
+ self._max_turns = max_turns
1230
+ self._stop_when = stop_when
1231
+ self._on_tool_call = on_tool_call
1232
+ self._system_prompt = system_prompt if isinstance(system_prompt, str) else None
1233
+ self._model = model
1234
+ self._temperature = temperature
1235
+ self._max_tokens = max_tokens
1236
+ self._running = False
1237
+ self._abort_event: threading.Event | None = None
1238
+
1239
+ # Mount chat subgraph
1240
+ self.chat = chat_stream(f"{name}-chat", max_messages=max_messages)
1241
+ self.mount("chat", self.chat)
1242
+
1243
+ # Mount tool registry subgraph
1244
+ self.tools = tool_registry(f"{name}-tools")
1245
+ self.mount("tools", self.tools)
1246
+
1247
+ # Register initial tools
1248
+ if tools:
1249
+ for tool in tools:
1250
+ self.tools.register(tool)
1251
+
1252
+ # Status state
1253
+ self._status_state: NodeImpl[str] = state(
1254
+ "idle",
1255
+ name="status",
1256
+ describe_kind="state",
1257
+ meta=_ai_meta("agent_status"),
1258
+ )
1259
+ self.status = self._status_state
1260
+ self.add("status", self.status)
1261
+
1262
+ # Turn count
1263
+ self._turn_count_state: NodeImpl[int] = state(
1264
+ 0,
1265
+ name="turnCount",
1266
+ describe_kind="state",
1267
+ meta=_ai_meta("agent_turn_count"),
1268
+ )
1269
+ self.turn_count = self._turn_count_state
1270
+ self.add("turnCount", self.turn_count)
1271
+
1272
+ # Last response
1273
+ self.last_response: NodeImpl[LLMResponse | None] = state(
1274
+ None,
1275
+ name="lastResponse",
1276
+ describe_kind="state",
1277
+ meta=_ai_meta("agent_last_response"),
1278
+ )
1279
+ self.add("lastResponse", self.last_response)
1280
+
1281
+ def run(self, user_message: str) -> LLMResponse | None:
1282
+ """Start the agent loop with a user message.
1283
+
1284
+ The loop runs: think (LLM call) → act (tool execution) → repeat
1285
+ until done. Returns the final LLM response.
1286
+
1287
+ Messages accumulate across calls. Call ``chat.clear()`` before
1288
+ ``run()`` to reset conversation history.
1289
+ """
1290
+ if self._running:
1291
+ msg = "agent_loop: already running"
1292
+ raise RuntimeError(msg)
1293
+ self._running = True
1294
+ self._abort_event = threading.Event()
1295
+
1296
+ with batch():
1297
+ self._status_state.down([(MessageType.DATA, "idle")])
1298
+ self._turn_count_state.down([(MessageType.DATA, 0)])
1299
+ self.chat.append("user", user_message)
1300
+
1301
+ try:
1302
+ turns = 0
1303
+ while turns < self._max_turns:
1304
+ if self._abort_event.is_set():
1305
+ msg = "agent_loop: aborted"
1306
+ raise RuntimeError(msg)
1307
+ turns += 1
1308
+ with batch():
1309
+ self._turn_count_state.down([(MessageType.DATA, turns)])
1310
+ self._status_state.down([(MessageType.DATA, "thinking")])
1311
+
1312
+ # Invoke LLM
1313
+ msgs = self.chat.all_messages()
1314
+ tool_schemas = self.tools.schemas.get() or ()
1315
+ response = self._invoke_llm(msgs, tool_schemas)
1316
+ if self._abort_event.is_set():
1317
+ msg = "agent_loop: aborted"
1318
+ raise RuntimeError(msg)
1319
+
1320
+ self.last_response.down([(MessageType.DATA, response)])
1321
+
1322
+ # Append assistant message
1323
+ self.chat.append(
1324
+ "assistant",
1325
+ response.content,
1326
+ tool_calls=response.tool_calls,
1327
+ )
1328
+
1329
+ # Check stop conditions
1330
+ if self._should_stop(response):
1331
+ self._status_state.down([(MessageType.DATA, "done")])
1332
+ self._running = False
1333
+ self._abort_event = None
1334
+ return response
1335
+
1336
+ # Execute tool calls if present
1337
+ if response.tool_calls:
1338
+ self._status_state.down([(MessageType.DATA, "acting")])
1339
+ for call in response.tool_calls:
1340
+ if self._abort_event.is_set():
1341
+ msg = "agent_loop: aborted"
1342
+ raise RuntimeError(msg)
1343
+ if self._on_tool_call:
1344
+ self._on_tool_call(call)
1345
+ try:
1346
+ result = self.tools.execute(call.name, call.arguments)
1347
+ self.chat.append_tool_result(call.id, json.dumps(result, default=str))
1348
+ except Exception as err:
1349
+ self.chat.append_tool_result(call.id, json.dumps({"error": str(err)}))
1350
+ else:
1351
+ # No tool calls and not explicitly stopped → done
1352
+ self._status_state.down([(MessageType.DATA, "done")])
1353
+ self._running = False
1354
+ self._abort_event = None
1355
+ return response
1356
+
1357
+ # Max turns reached
1358
+ self._status_state.down([(MessageType.DATA, "done")])
1359
+ self._running = False
1360
+ self._abort_event = None
1361
+ return self.last_response.get()
1362
+ except Exception:
1363
+ self._status_state.down([(MessageType.DATA, "error")])
1364
+ self._running = False
1365
+ self._abort_event = None
1366
+ raise
1367
+
1368
+ def _invoke_llm(
1369
+ self,
1370
+ msgs: tuple[ChatMessage, ...],
1371
+ tools: tuple[ToolDefinition, ...] | Any,
1372
+ ) -> LLMResponse:
1373
+ result = self._adapter.invoke(
1374
+ list(msgs),
1375
+ LLMInvokeOptions(
1376
+ tools=tuple(tools) if tools else None,
1377
+ system_prompt=self._system_prompt,
1378
+ model=self._model,
1379
+ temperature=self._temperature,
1380
+ max_tokens=self._max_tokens,
1381
+ ),
1382
+ )
1383
+ # Guard: None/null — reject before from_any
1384
+ if result is None:
1385
+ msg = "_invoke_llm: adapter.invoke() returned None"
1386
+ raise RuntimeError(msg)
1387
+ # Guard: str — from_any would iterate characters
1388
+ if isinstance(result, str):
1389
+ msg = "_invoke_llm: adapter.invoke() returned a string, expected LLMResponse"
1390
+ raise RuntimeError(msg)
1391
+ # Guard: dict/Mapping — iterating a dict yields keys, not values;
1392
+ # normalize to LLMResponse if it has a 'content' key.
1393
+ if isinstance(result, dict):
1394
+ if "content" in result:
1395
+ return LLMResponse(
1396
+ content=result.get("content", ""),
1397
+ tool_calls=result.get("tool_calls"),
1398
+ usage=result.get("usage"),
1399
+ finish_reason=result.get("finish_reason"),
1400
+ metadata=result.get("metadata"),
1401
+ )
1402
+ msg = "_invoke_llm: adapter.invoke() returned a dict without 'content' key"
1403
+ raise RuntimeError(msg)
1404
+ if isinstance(result, LLMResponse):
1405
+ return result
1406
+ resolved = from_any(result)
1407
+ val = resolved.get()
1408
+ if isinstance(val, LLMResponse):
1409
+ return val
1410
+ try:
1411
+ first = first_value_from(resolved)
1412
+ except StopIteration as err:
1413
+ msg = "agent_loop: adapter completed without producing an LLMResponse"
1414
+ raise RuntimeError(msg) from err
1415
+ if not isinstance(first, LLMResponse):
1416
+ msg = f"agent_loop: expected LLMResponse, got {type(first).__name__}"
1417
+ raise TypeError(msg)
1418
+ return first
1419
+
1420
+ def _should_stop(self, response: LLMResponse) -> bool:
1421
+ if response.finish_reason == "end_turn" and not response.tool_calls:
1422
+ return True
1423
+ return bool(self._stop_when and self._stop_when(response))
1424
+
1425
+ def destroy(self) -> None:
1426
+ if self._abort_event is not None:
1427
+ self._abort_event.set()
1428
+ self._abort_event = None
1429
+ self._running = False
1430
+ super().destroy()
1431
+
1432
+
1433
+ def agent_loop(
1434
+ name: str,
1435
+ *,
1436
+ adapter: LLMAdapter,
1437
+ tools: Sequence[ToolDefinition] | None = None,
1438
+ system_prompt: str | None = None,
1439
+ max_turns: int = 10,
1440
+ stop_when: Callable[[LLMResponse], bool] | None = None,
1441
+ on_tool_call: Callable[[ToolCall], None] | None = None,
1442
+ max_messages: int | None = None,
1443
+ model: str | None = None,
1444
+ temperature: float | None = None,
1445
+ max_tokens: int | None = None,
1446
+ opts: dict[str, Any] | None = None,
1447
+ ) -> AgentLoopGraph:
1448
+ """Create an agent loop graph."""
1449
+ return AgentLoopGraph(
1450
+ name,
1451
+ adapter=adapter,
1452
+ tools=tools,
1453
+ system_prompt=system_prompt,
1454
+ max_turns=max_turns,
1455
+ stop_when=stop_when,
1456
+ on_tool_call=on_tool_call,
1457
+ max_messages=max_messages,
1458
+ model=model,
1459
+ temperature=temperature,
1460
+ max_tokens=max_tokens,
1461
+ opts=opts,
1462
+ )
1463
+
1464
+
1465
+ # ---------------------------------------------------------------------------
1466
+ # 5.4 — LLM tool integration
1467
+ # ---------------------------------------------------------------------------
1468
+
1469
+
1470
+ @dataclass(frozen=True, slots=True)
1471
+ class OpenAIToolSchema:
1472
+ """OpenAI function-calling tool schema."""
1473
+
1474
+ type: str # always "function"
1475
+ function: dict[str, Any]
1476
+
1477
+
1478
+ @dataclass(frozen=True, slots=True)
1479
+ class McpToolSchema:
1480
+ """MCP (Model Context Protocol) tool schema."""
1481
+
1482
+ name: str
1483
+ description: str
1484
+ input_schema: dict[str, Any]
1485
+
1486
+
1487
+ @dataclass(frozen=True, slots=True)
1488
+ class KnobsAsToolsResult:
1489
+ """Result of :func:`knobs_as_tools`."""
1490
+
1491
+ openai: tuple[OpenAIToolSchema, ...]
1492
+ mcp: tuple[McpToolSchema, ...]
1493
+ definitions: tuple[ToolDefinition, ...]
1494
+
1495
+
1496
+ def _meta_to_json_schema(meta: dict[str, Any]) -> dict[str, Any]:
1497
+ """Build a JSON Schema ``value`` descriptor from a node's meta fields."""
1498
+ schema: dict[str, Any] = {}
1499
+
1500
+ meta_type = meta.get("type")
1501
+ if meta_type == "enum" and isinstance(meta.get("values"), (list, tuple)):
1502
+ schema["type"] = "string"
1503
+ schema["enum"] = list(meta["values"])
1504
+ elif meta_type == "integer":
1505
+ schema["type"] = "integer"
1506
+ elif meta_type == "number":
1507
+ schema["type"] = "number"
1508
+ elif meta_type == "boolean":
1509
+ schema["type"] = "boolean"
1510
+ elif meta_type == "string":
1511
+ schema["type"] = "string"
1512
+ else:
1513
+ schema["type"] = ["string", "number", "boolean"]
1514
+
1515
+ rng = meta.get("range")
1516
+ if isinstance(rng, (list, tuple)) and len(rng) == 2:
1517
+ schema["minimum"] = rng[0]
1518
+ schema["maximum"] = rng[1]
1519
+
1520
+ fmt = meta.get("format")
1521
+ if isinstance(fmt, str):
1522
+ schema["description"] = f"Format: {fmt}"
1523
+
1524
+ unit = meta.get("unit")
1525
+ if isinstance(unit, str):
1526
+ if "description" in schema:
1527
+ schema["description"] += f" ({unit})"
1528
+ else:
1529
+ schema["description"] = f"Unit: {unit}"
1530
+
1531
+ return schema
1532
+
1533
+
1534
+ def knobs_as_tools(
1535
+ graph: Graph,
1536
+ actor: Any | None = None,
1537
+ ) -> KnobsAsToolsResult:
1538
+ """Derive tool schemas from a graph's writable (knob) nodes.
1539
+
1540
+ Knobs are state nodes whose ``meta.access`` is ``"llm"``, ``"both"``, or
1541
+ absent (default: writable). Each knob becomes a tool that calls
1542
+ ``graph.set()``.
1543
+
1544
+ Speaks **domain language** (spec §5.4): the returned schemas use node names
1545
+ and meta descriptions — no protocol internals exposed.
1546
+
1547
+ Args:
1548
+ graph: The graph to introspect.
1549
+ actor: Optional actor for guard-scoped describe.
1550
+
1551
+ Returns:
1552
+ OpenAI, MCP, and GraphReFly tool schemas.
1553
+ """
1554
+ kwargs: dict[str, Any] = {}
1555
+ if actor is not None:
1556
+ kwargs["actor"] = actor
1557
+ described = graph.describe(**kwargs)
1558
+
1559
+ openai_list: list[OpenAIToolSchema] = []
1560
+ mcp_list: list[McpToolSchema] = []
1561
+ definitions_list: list[ToolDefinition] = []
1562
+
1563
+ for path, node_desc in described["nodes"].items():
1564
+ if node_desc.get("type") != "state":
1565
+ continue
1566
+ if "::__meta__::" in path:
1567
+ continue
1568
+
1569
+ # Skip terminal-state nodes (§1.3.4)
1570
+ status = node_desc.get("status")
1571
+ if status in ("completed", "errored"):
1572
+ continue
1573
+
1574
+ meta = node_desc.get("meta", {})
1575
+ access = meta.get("access")
1576
+ if access in ("human", "system"):
1577
+ continue
1578
+
1579
+ description = meta.get("description") or f"Set the value of {path}"
1580
+ value_schema = _meta_to_json_schema(meta)
1581
+
1582
+ parameter_schema: dict[str, Any] = {
1583
+ "type": "object",
1584
+ "required": ["value"],
1585
+ "properties": {"value": value_schema},
1586
+ "additionalProperties": False,
1587
+ }
1588
+
1589
+ # OpenAI requires [a-zA-Z0-9_-] in function names
1590
+ sanitized_name = path.replace("::", "__")
1591
+
1592
+ openai_list.append(
1593
+ OpenAIToolSchema(
1594
+ type="function",
1595
+ function={
1596
+ "name": sanitized_name,
1597
+ "description": description,
1598
+ "parameters": parameter_schema,
1599
+ },
1600
+ )
1601
+ )
1602
+
1603
+ mcp_list.append(
1604
+ McpToolSchema(
1605
+ name=path,
1606
+ description=description,
1607
+ input_schema=parameter_schema,
1608
+ )
1609
+ )
1610
+
1611
+ # Capture for closure
1612
+ _graph = graph
1613
+ _path = path
1614
+ _actor = actor
1615
+
1616
+ def _make_handler(g: Graph, p: str, a: Any | None) -> Callable[[dict[str, Any]], Any]:
1617
+ def handler(args: dict[str, Any]) -> Any:
1618
+ kwargs: dict[str, Any] = {}
1619
+ if a is not None:
1620
+ kwargs["actor"] = a
1621
+ g.set(p, args["value"], **kwargs)
1622
+ return args["value"]
1623
+
1624
+ return handler
1625
+
1626
+ nv = node_desc.get("v")
1627
+ definitions_list.append(
1628
+ ToolDefinition(
1629
+ name=path,
1630
+ description=description,
1631
+ parameters=parameter_schema,
1632
+ handler=_make_handler(_graph, _path, _actor),
1633
+ version={"id": nv["id"], "version": nv["version"]} if nv is not None else None,
1634
+ )
1635
+ )
1636
+
1637
+ return KnobsAsToolsResult(
1638
+ openai=tuple(openai_list),
1639
+ mcp=tuple(mcp_list),
1640
+ definitions=tuple(definitions_list),
1641
+ )
1642
+
1643
+
1644
+ def gauges_as_context(
1645
+ graph: Graph,
1646
+ actor: Any | None = None,
1647
+ *,
1648
+ group_by_tags: bool = True,
1649
+ separator: str = "\n",
1650
+ since_version: dict[str, dict[str, Any]] | None = None,
1651
+ ) -> str:
1652
+ """Format a graph's readable (gauge) nodes as a context string for LLM
1653
+ system prompts.
1654
+
1655
+ Gauges are nodes with ``meta.description`` or ``meta.format``. Values are
1656
+ formatted using ``meta.format`` and ``meta.unit`` hints.
1657
+
1658
+ Args:
1659
+ graph: The graph to introspect.
1660
+ actor: Optional actor for guard-scoped describe.
1661
+ group_by_tags: Group gauges by ``meta.tags`` (default ``True``).
1662
+ separator: Separator between gauge lines (default ``"\\n"``).
1663
+ since_version: V0 delta mode (§6.0b): map of ``path → {"id", "version"}``.
1664
+ Only include nodes whose ``v.version`` exceeds the stored version
1665
+ AND whose ``v.id`` matches. Nodes without V0, not in the map, or
1666
+ with a different id (replacement) are always included.
1667
+
1668
+ Returns:
1669
+ A formatted string ready for system prompt injection.
1670
+ """
1671
+ kwargs: dict[str, Any] = {}
1672
+ if actor is not None:
1673
+ kwargs["actor"] = actor
1674
+ described = graph.describe(**kwargs)
1675
+
1676
+ entries: list[tuple[str, str, str]] = [] # (path, description, formatted)
1677
+
1678
+ for path, node_desc in described["nodes"].items():
1679
+ meta = node_desc.get("meta", {})
1680
+ desc = meta.get("description")
1681
+ fmt = meta.get("format")
1682
+ if not desc and not fmt:
1683
+ continue
1684
+ # V0 delta filter: skip nodes unchanged since last seen version (§6.0b).
1685
+ if since_version is not None:
1686
+ nv = node_desc.get("v")
1687
+ if nv is not None:
1688
+ last_seen = since_version.get(path)
1689
+ if (
1690
+ last_seen is not None
1691
+ and last_seen.get("id") == nv.get("id")
1692
+ and nv.get("version", 0) <= last_seen.get("version", -1)
1693
+ ):
1694
+ continue
1695
+
1696
+ label = desc or path
1697
+ value = node_desc.get("value")
1698
+ unit = meta.get("unit")
1699
+
1700
+ if fmt == "currency" and isinstance(value, (int, float)):
1701
+ formatted = f"${value:.2f}"
1702
+ elif fmt == "percentage" and isinstance(value, (int, float)):
1703
+ formatted = f"{value * 100:.1f}%"
1704
+ elif value is None:
1705
+ formatted = "(no value)"
1706
+ else:
1707
+ formatted = str(value)
1708
+
1709
+ if unit and fmt not in ("currency", "percentage"):
1710
+ formatted = f"{formatted} {unit}"
1711
+
1712
+ entries.append((path, label, formatted))
1713
+
1714
+ if not entries:
1715
+ return ""
1716
+
1717
+ if group_by_tags:
1718
+ tag_groups: dict[str, list[tuple[str, str, str]]] = {}
1719
+ ungrouped: list[tuple[str, str, str]] = []
1720
+
1721
+ for entry in entries:
1722
+ node_desc = described["nodes"][entry[0]]
1723
+ tags = node_desc.get("meta", {}).get("tags")
1724
+ if tags and len(tags) > 0:
1725
+ # Use first tag for grouping to avoid duplicating entries
1726
+ tag_groups.setdefault(tags[0], []).append(entry)
1727
+ else:
1728
+ ungrouped.append(entry)
1729
+
1730
+ if not tag_groups:
1731
+ return separator.join(f"- {e[1]}: {e[2]}" for e in entries)
1732
+
1733
+ sections: list[str] = []
1734
+ for tag in sorted(tag_groups):
1735
+ group = tag_groups[tag]
1736
+ lines = separator.join(f"- {e[1]}: {e[2]}" for e in group)
1737
+ sections.append(f"[{tag}]{separator}{lines}")
1738
+ if ungrouped:
1739
+ sections.append(separator.join(f"- {e[1]}: {e[2]}" for e in ungrouped))
1740
+ return (separator + separator).join(sections)
1741
+
1742
+ return separator.join(f"- {e[1]}: {e[2]}" for e in entries)
1743
+
1744
+
1745
+ # ---------------------------------------------------------------------------
1746
+ # validateGraphDef
1747
+ # ---------------------------------------------------------------------------
1748
+
1749
+ _VALID_NODE_TYPES = frozenset({"state", "derived", "producer", "operator", "effect"})
1750
+
1751
+
1752
+ @dataclass(frozen=True, slots=True)
1753
+ class GraphDefValidation:
1754
+ """Validation result from :func:`validate_graph_def`."""
1755
+
1756
+ valid: bool
1757
+ errors: tuple[str, ...]
1758
+
1759
+
1760
+ def validate_graph_def(definition: Any) -> GraphDefValidation:
1761
+ """Validate an LLM-generated graph definition before passing to
1762
+ ``Graph.from_snapshot()``.
1763
+
1764
+ Checks required fields, node types, edge references, and duplicates.
1765
+
1766
+ Args:
1767
+ definition: The graph definition to validate (parsed JSON).
1768
+
1769
+ Returns:
1770
+ Validation result with errors tuple.
1771
+ """
1772
+ errors: list[str] = []
1773
+
1774
+ if definition is None or not isinstance(definition, dict):
1775
+ return GraphDefValidation(valid=False, errors=("Definition must be a non-null dict",))
1776
+
1777
+ d: dict[str, Any] = definition
1778
+
1779
+ name = d.get("name")
1780
+ if not isinstance(name, str) or len(name) == 0:
1781
+ errors.append("Missing or empty 'name' field")
1782
+
1783
+ nodes = d.get("nodes")
1784
+ if nodes is None or not isinstance(nodes, dict):
1785
+ errors.append("Missing or invalid 'nodes' field (must be a dict)")
1786
+ return GraphDefValidation(valid=False, errors=tuple(errors))
1787
+
1788
+ node_names = set(nodes.keys())
1789
+
1790
+ for nname, raw in nodes.items():
1791
+ if raw is None or not isinstance(raw, dict):
1792
+ errors.append(f'Node "{nname}": must be a dict')
1793
+ continue
1794
+ ntype = raw.get("type")
1795
+ if not isinstance(ntype, str) or ntype not in _VALID_NODE_TYPES:
1796
+ valid_str = ", ".join(sorted(_VALID_NODE_TYPES))
1797
+ errors.append(f'Node "{nname}": invalid type "{ntype}" (expected: {valid_str})')
1798
+ deps = raw.get("deps")
1799
+ if isinstance(deps, list):
1800
+ for dep in deps:
1801
+ if isinstance(dep, str) and dep not in node_names:
1802
+ errors.append(
1803
+ f'Node "{nname}": dep "{dep}" does not reference an existing node'
1804
+ )
1805
+
1806
+ edges = d.get("edges")
1807
+ if edges is not None:
1808
+ if not isinstance(edges, list):
1809
+ errors.append("'edges' must be a list")
1810
+ else:
1811
+ seen: set[str] = set()
1812
+ for i, edge in enumerate(edges):
1813
+ if edge is None or not isinstance(edge, dict):
1814
+ errors.append(f"Edge [{i}]: must be a dict")
1815
+ continue
1816
+ efrom = edge.get("from")
1817
+ if not isinstance(efrom, str) or efrom not in node_names:
1818
+ errors.append(
1819
+ f"Edge [{i}]: 'from' \"{efrom}\" does not reference an existing node"
1820
+ )
1821
+ eto = edge.get("to")
1822
+ if not isinstance(eto, str) or eto not in node_names:
1823
+ errors.append(f"Edge [{i}]: 'to' \"{eto}\" does not reference an existing node")
1824
+ key = f"{efrom}->{eto}"
1825
+ if key in seen:
1826
+ errors.append(f"Edge [{i}]: duplicate edge {key}")
1827
+ seen.add(key)
1828
+
1829
+ return GraphDefValidation(valid=len(errors) == 0, errors=tuple(errors))
1830
+
1831
+
1832
+ # ---------------------------------------------------------------------------
1833
+ # graphFromSpec
1834
+ # ---------------------------------------------------------------------------
1835
+
1836
+ import re as _re
1837
+
1838
+ _FENCE_PATTERN = _re.compile(r"^```(?:json)?\s*([\s\S]*?)\s*```[\s\S]*$")
1839
+
1840
+
1841
+ def _strip_fences(text: str) -> str:
1842
+ """Strip markdown code fences, handling trailing commentary."""
1843
+ m = _FENCE_PATTERN.match(text)
1844
+ return m.group(1) if m else text
1845
+
1846
+
1847
+ _GRAPH_FROM_SPEC_SYSTEM_PROMPT = """\
1848
+ You are a graph architect for GraphReFly, a reactive graph protocol.
1849
+
1850
+ Given a natural-language description, produce a JSON graph definition with this structure:
1851
+
1852
+ {
1853
+ "name": "<graph_name>",
1854
+ "nodes": {
1855
+ "<node_name>": {
1856
+ "type": "state" | "derived" | "producer" | "operator" | "effect",
1857
+ "value": <initial_value_or_null>,
1858
+ "deps": ["<dep_node_name>", ...],
1859
+ "meta": {
1860
+ "description": "<human-readable purpose>",
1861
+ "type": "string" | "number" | "boolean" | "integer" | "enum",
1862
+ "range": [min, max],
1863
+ "values": ["a", "b"],
1864
+ "format": "currency" | "percentage" | "status",
1865
+ "access": "human" | "llm" | "both" | "system",
1866
+ "unit": "<unit>",
1867
+ "tags": ["<tag>"]
1868
+ }
1869
+ }
1870
+ },
1871
+ "edges": [
1872
+ { "from": "<source_node>", "to": "<target_node>" }
1873
+ ]
1874
+ }
1875
+
1876
+ Rules:
1877
+ - "state" nodes have no deps and hold user/LLM-writable values (knobs).
1878
+ - "derived" nodes have deps and compute from them.
1879
+ - "effect" nodes have deps but produce side effects (no return value).
1880
+ - "producer" nodes have no deps but generate values asynchronously.
1881
+ - Edges wire output of one node as input to another. They must match deps.
1882
+ - meta.description is required for every node.
1883
+ - Return ONLY valid JSON, no markdown fences or commentary."""
1884
+
1885
+
1886
+ def graph_from_spec(
1887
+ natural_language: str,
1888
+ adapter: LLMAdapter,
1889
+ *,
1890
+ model: str | None = None,
1891
+ temperature: float | None = None,
1892
+ max_tokens: int | None = None,
1893
+ build: Callable[[Graph], None] | None = None,
1894
+ system_prompt_extra: str | None = None,
1895
+ ) -> Graph:
1896
+ """Ask an LLM to compose a Graph from a natural-language description.
1897
+
1898
+ The LLM returns a JSON graph definition which is validated and then
1899
+ constructed via ``Graph.from_snapshot()``.
1900
+
1901
+ Args:
1902
+ natural_language: The problem/use-case description.
1903
+ adapter: LLM adapter for the generation call.
1904
+ model: Optional model override.
1905
+ temperature: Optional temperature (default 0).
1906
+ max_tokens: Optional max tokens.
1907
+ build: Optional callback to construct topology before values are applied.
1908
+ system_prompt_extra: Extra instructions appended to the system prompt.
1909
+
1910
+ Returns:
1911
+ A constructed Graph.
1912
+
1913
+ Raises:
1914
+ ValueError: On invalid LLM output or validation failure.
1915
+ """
1916
+ sys_prompt = _GRAPH_FROM_SPEC_SYSTEM_PROMPT
1917
+ if system_prompt_extra:
1918
+ sys_prompt = f"{sys_prompt}\n\n{system_prompt_extra}"
1919
+
1920
+ messages = [
1921
+ ChatMessage(role="system", content=sys_prompt),
1922
+ ChatMessage(role="user", content=natural_language),
1923
+ ]
1924
+
1925
+ raw_result = adapter.invoke(
1926
+ messages,
1927
+ LLMInvokeOptions(
1928
+ model=model,
1929
+ temperature=temperature if temperature is not None else 0.0,
1930
+ max_tokens=max_tokens,
1931
+ ),
1932
+ )
1933
+
1934
+ response = _resolve_node_input(raw_result)
1935
+ if not isinstance(response, LLMResponse):
1936
+ msg = f"graph_from_spec: expected LLMResponse, got {type(response).__name__}"
1937
+ raise ValueError(msg)
1938
+
1939
+ content = response.content.strip()
1940
+ if content.startswith("```"):
1941
+ content = _strip_fences(content)
1942
+
1943
+ try:
1944
+ parsed = json.loads(content)
1945
+ except json.JSONDecodeError as exc:
1946
+ msg = f"graph_from_spec: LLM response is not valid JSON: {content[:200]}"
1947
+ raise ValueError(msg) from exc
1948
+
1949
+ validation = validate_graph_def(parsed)
1950
+ if not validation.valid:
1951
+ detail = "\n".join(validation.errors)
1952
+ msg = f"graph_from_spec: invalid graph definition:\n{detail}"
1953
+ raise ValueError(msg)
1954
+
1955
+ # Ensure version and subgraphs fields for from_snapshot
1956
+ if "version" not in parsed:
1957
+ parsed["version"] = 1
1958
+ if "subgraphs" not in parsed or not isinstance(parsed["subgraphs"], list):
1959
+ parsed["subgraphs"] = []
1960
+
1961
+ return Graph.from_snapshot(parsed, build)
1962
+
1963
+
1964
+ # ---------------------------------------------------------------------------
1965
+ # suggestStrategy
1966
+ # ---------------------------------------------------------------------------
1967
+
1968
+
1969
+ @dataclass(frozen=True, slots=True)
1970
+ class StrategyOperation:
1971
+ """A single operation in a strategy plan."""
1972
+
1973
+ type: str
1974
+ name: str | None = None
1975
+ node_type: str | None = None
1976
+ meta: dict[str, Any] | None = None
1977
+ initial: Any = None
1978
+ from_node: str | None = None
1979
+ to_node: str | None = None
1980
+ value: Any = None
1981
+ key: str | None = None
1982
+
1983
+
1984
+ @dataclass(frozen=True, slots=True)
1985
+ class StrategyPlan:
1986
+ """Structured strategy plan returned by :func:`suggest_strategy`."""
1987
+
1988
+ summary: str
1989
+ operations: tuple[StrategyOperation, ...]
1990
+ reasoning: str
1991
+
1992
+
1993
+ _SUGGEST_STRATEGY_SYSTEM_PROMPT = """\
1994
+ You are a reactive graph optimizer for GraphReFly.
1995
+
1996
+ Given a graph's current structure (from describe()) and a problem statement, \
1997
+ suggest topology and parameter changes to solve the problem.
1998
+
1999
+ Return ONLY valid JSON with this structure:
2000
+ {
2001
+ "summary": "<one-line summary of the strategy>",
2002
+ "reasoning": "<explanation of why these changes help>",
2003
+ "operations": [
2004
+ { "type": "add_node", "name": "<name>",
2005
+ "nodeType": "state|derived|effect|producer|operator",
2006
+ "meta": {...}, "initial": <value> },
2007
+ { "type": "remove_node", "name": "<name>" },
2008
+ { "type": "connect", "from": "<source>", "to": "<target>" },
2009
+ { "type": "disconnect", "from": "<source>", "to": "<target>" },
2010
+ { "type": "set_value", "name": "<name>", "value": <new_value> },
2011
+ { "type": "update_meta", "name": "<name>",
2012
+ "key": "<meta_key>", "value": <new_value> }
2013
+ ]
2014
+ }
2015
+
2016
+ Rules:
2017
+ - Only suggest operations that reference existing nodes
2018
+ (for remove/disconnect/set_value/update_meta)
2019
+ or new nodes you define (for add_node).
2020
+ - Keep changes minimal.
2021
+ - Return ONLY valid JSON, no markdown fences or commentary."""
2022
+
2023
+
2024
+ def _parse_strategy_operation(raw: dict[str, Any]) -> StrategyOperation:
2025
+ """Parse a raw operation dict into a StrategyOperation."""
2026
+ return StrategyOperation(
2027
+ type=raw.get("type", ""),
2028
+ name=raw.get("name"),
2029
+ node_type=raw.get("nodeType"),
2030
+ meta=raw.get("meta"),
2031
+ initial=raw.get("initial"),
2032
+ from_node=raw.get("from"),
2033
+ to_node=raw.get("to"),
2034
+ value=raw.get("value"),
2035
+ key=raw.get("key"),
2036
+ )
2037
+
2038
+
2039
+ def suggest_strategy(
2040
+ graph: Graph,
2041
+ problem: str,
2042
+ adapter: LLMAdapter,
2043
+ *,
2044
+ model: str | None = None,
2045
+ temperature: float | None = None,
2046
+ max_tokens: int | None = None,
2047
+ actor: Any | None = None,
2048
+ ) -> StrategyPlan:
2049
+ """Ask an LLM to analyze a graph and suggest topology/parameter changes
2050
+ to solve a stated problem.
2051
+
2052
+ Returns a structured plan — does NOT auto-apply. The caller reviews
2053
+ and selectively applies operations.
2054
+
2055
+ Args:
2056
+ graph: The graph to analyze.
2057
+ problem: Natural-language problem statement.
2058
+ adapter: LLM adapter for the analysis call.
2059
+ model: Optional model override.
2060
+ temperature: Optional temperature (default 0).
2061
+ max_tokens: Optional max tokens.
2062
+ actor: Optional actor for guard-scoped describe.
2063
+
2064
+ Returns:
2065
+ A structured strategy plan.
2066
+
2067
+ Raises:
2068
+ ValueError: On invalid LLM output.
2069
+ """
2070
+ kwargs: dict[str, Any] = {}
2071
+ if actor is not None:
2072
+ kwargs["actor"] = actor
2073
+ described = graph.describe(**kwargs)
2074
+
2075
+ messages = [
2076
+ ChatMessage(role="system", content=_SUGGEST_STRATEGY_SYSTEM_PROMPT),
2077
+ ChatMessage(
2078
+ role="user",
2079
+ content=json.dumps({"graph": described, "problem": problem}),
2080
+ ),
2081
+ ]
2082
+
2083
+ raw_result = adapter.invoke(
2084
+ messages,
2085
+ LLMInvokeOptions(
2086
+ model=model,
2087
+ temperature=temperature if temperature is not None else 0.0,
2088
+ max_tokens=max_tokens,
2089
+ ),
2090
+ )
2091
+
2092
+ response = _resolve_node_input(raw_result)
2093
+ if not isinstance(response, LLMResponse):
2094
+ msg = f"suggest_strategy: expected LLMResponse, got {type(response).__name__}"
2095
+ raise ValueError(msg)
2096
+
2097
+ content = response.content.strip()
2098
+ if content.startswith("```"):
2099
+ content = _strip_fences(content)
2100
+
2101
+ try:
2102
+ parsed = json.loads(content)
2103
+ except json.JSONDecodeError as exc:
2104
+ msg = f"suggest_strategy: LLM response is not valid JSON: {content[:200]}"
2105
+ raise ValueError(msg) from exc
2106
+
2107
+ if not isinstance(parsed, dict):
2108
+ msg = "suggest_strategy: expected a JSON object"
2109
+ raise ValueError(msg)
2110
+
2111
+ summary = parsed.get("summary")
2112
+ if not isinstance(summary, str):
2113
+ msg = "suggest_strategy: missing 'summary' in response"
2114
+ raise ValueError(msg)
2115
+
2116
+ reasoning = parsed.get("reasoning")
2117
+ if not isinstance(reasoning, str):
2118
+ msg = "suggest_strategy: missing 'reasoning' in response"
2119
+ raise ValueError(msg)
2120
+
2121
+ ops_raw = parsed.get("operations")
2122
+ if not isinstance(ops_raw, list):
2123
+ msg = "suggest_strategy: missing 'operations' list in response"
2124
+ raise ValueError(msg)
2125
+
2126
+ operations = tuple(_parse_strategy_operation(op) for op in ops_raw if isinstance(op, dict))
2127
+
2128
+ return StrategyPlan(
2129
+ summary=summary,
2130
+ operations=operations,
2131
+ reasoning=reasoning,
2132
+ )