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.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|