flowscript-agents 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.
- flowscript_agents/__init__.py +21 -0
- flowscript_agents/crewai.py +409 -0
- flowscript_agents/google_adk.py +258 -0
- flowscript_agents/langgraph.py +280 -0
- flowscript_agents/memory.py +504 -0
- flowscript_agents/openai_agents.py +170 -0
- flowscript_agents-0.1.0.dist-info/METADATA +285 -0
- flowscript_agents-0.1.0.dist-info/RECORD +9 -0
- flowscript_agents-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FlowScript Memory — Python implementation.
|
|
3
|
+
|
|
4
|
+
Programmatic builder for FlowScript IR graphs. The Python equivalent of
|
|
5
|
+
flowscript-core's Memory class, built on top of flowscript-ldp's IR types
|
|
6
|
+
and query engine.
|
|
7
|
+
|
|
8
|
+
Design:
|
|
9
|
+
- IR is the internal representation (same schema as TypeScript)
|
|
10
|
+
- Content-hash deduplication drives frequency tracking
|
|
11
|
+
- Query engine refreshes when IR changes
|
|
12
|
+
- JSON is canonical persistence
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from flowscript_ldp.ir import (
|
|
24
|
+
IR,
|
|
25
|
+
GraphInvariants,
|
|
26
|
+
IRMetadata,
|
|
27
|
+
Node,
|
|
28
|
+
NodeModifier,
|
|
29
|
+
NodeType,
|
|
30
|
+
Provenance,
|
|
31
|
+
Relationship,
|
|
32
|
+
RelationType,
|
|
33
|
+
State,
|
|
34
|
+
StateFields,
|
|
35
|
+
StateType,
|
|
36
|
+
)
|
|
37
|
+
from flowscript_ldp.query import QueryEngine
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hash_content(content: str, node_type: str) -> str:
|
|
41
|
+
"""Generate SHA-256 content hash matching TypeScript hashContent."""
|
|
42
|
+
raw = f"{node_type}:{content}"
|
|
43
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _now_iso() -> str:
|
|
47
|
+
return datetime.now(timezone.utc).isoformat()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _make_provenance(source: str = "memory-api") -> Provenance:
|
|
51
|
+
return Provenance(
|
|
52
|
+
source_file=source,
|
|
53
|
+
line_number=1,
|
|
54
|
+
timestamp=_now_iso(),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NodeRef:
|
|
59
|
+
"""Fluent reference handle for a node in the memory graph."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, memory: Memory, node: Node) -> None:
|
|
62
|
+
self._memory = memory
|
|
63
|
+
self._node = node
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def id(self) -> str:
|
|
67
|
+
return self._node.id
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def node(self) -> Node:
|
|
71
|
+
return self._node
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def type(self) -> NodeType:
|
|
75
|
+
return self._node.type
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def content(self) -> str:
|
|
79
|
+
return self._node.content
|
|
80
|
+
|
|
81
|
+
# -- Relationship builders (return self for chaining) --
|
|
82
|
+
|
|
83
|
+
def causes(self, target: NodeRef | str) -> NodeRef:
|
|
84
|
+
"""This node causes target."""
|
|
85
|
+
t = self._memory._resolve_ref(target)
|
|
86
|
+
self._memory._add_relationship(self.id, t.id, RelationType.CAUSES)
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def then(self, target: NodeRef | str) -> NodeRef:
|
|
90
|
+
"""Temporal: this node leads to target."""
|
|
91
|
+
t = self._memory._resolve_ref(target)
|
|
92
|
+
self._memory._add_relationship(self.id, t.id, RelationType.TEMPORAL)
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def derives_from(self, source: NodeRef | str) -> NodeRef:
|
|
96
|
+
"""This node derives from source."""
|
|
97
|
+
s = self._memory._resolve_ref(source)
|
|
98
|
+
self._memory._add_relationship(s.id, self.id, RelationType.DERIVES_FROM)
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def tension_with(self, target: NodeRef | str, axis: str) -> NodeRef:
|
|
102
|
+
"""Tension between this and target on named axis."""
|
|
103
|
+
t = self._memory._resolve_ref(target)
|
|
104
|
+
self._memory._add_relationship(
|
|
105
|
+
self.id, t.id, RelationType.TENSION, axis_label=axis
|
|
106
|
+
)
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def bidirectional(self, target: NodeRef | str) -> NodeRef:
|
|
110
|
+
"""Bidirectional relationship."""
|
|
111
|
+
t = self._memory._resolve_ref(target)
|
|
112
|
+
self._memory._add_relationship(self.id, t.id, RelationType.BIDIRECTIONAL)
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
# -- State builders (return self for chaining) --
|
|
116
|
+
|
|
117
|
+
def decide(self, rationale: str, on: Optional[str] = None) -> NodeRef:
|
|
118
|
+
"""Mark as decided."""
|
|
119
|
+
fields = StateFields(rationale=rationale, on=on or _now_iso())
|
|
120
|
+
self._memory._add_state(self.id, StateType.DECIDED, fields)
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def block(self, reason: str, since: Optional[str] = None) -> NodeRef:
|
|
124
|
+
"""Mark as blocked."""
|
|
125
|
+
fields = StateFields(reason=reason, since=since or _now_iso())
|
|
126
|
+
self._memory._add_state(self.id, StateType.BLOCKED, fields)
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def park(self, why: str, until: Optional[str] = None) -> NodeRef:
|
|
130
|
+
"""Mark as parked."""
|
|
131
|
+
fields = StateFields(why=why, until=until)
|
|
132
|
+
self._memory._add_state(self.id, StateType.PARKING, fields)
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def explore(self) -> NodeRef:
|
|
136
|
+
"""Mark as exploring."""
|
|
137
|
+
self._memory._add_state(self.id, StateType.EXPLORING)
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def unblock(self) -> NodeRef:
|
|
141
|
+
"""Remove blocked states."""
|
|
142
|
+
self._memory._remove_states(self.id, StateType.BLOCKED)
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def __repr__(self) -> str:
|
|
146
|
+
return f"NodeRef({self.type.value}: {self.content!r})"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _QueryProxy:
|
|
150
|
+
"""Lazy query engine that refreshes when IR changes."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, memory: Memory) -> None:
|
|
153
|
+
self._memory = memory
|
|
154
|
+
self._engine: Optional[QueryEngine] = None
|
|
155
|
+
self._dirty = True
|
|
156
|
+
|
|
157
|
+
def _mark_dirty(self) -> None:
|
|
158
|
+
self._dirty = True
|
|
159
|
+
|
|
160
|
+
def _get_engine(self) -> QueryEngine:
|
|
161
|
+
if self._dirty or self._engine is None:
|
|
162
|
+
self._engine = QueryEngine(self._memory.to_ir())
|
|
163
|
+
self._dirty = False
|
|
164
|
+
return self._engine
|
|
165
|
+
|
|
166
|
+
def why(self, node_id: str, **kwargs: Any) -> Any:
|
|
167
|
+
return self._get_engine().why(node_id, **kwargs)
|
|
168
|
+
|
|
169
|
+
def what_if(self, node_id: str, **kwargs: Any) -> Any:
|
|
170
|
+
return self._get_engine().what_if(node_id, **kwargs)
|
|
171
|
+
|
|
172
|
+
def tensions(self, **kwargs: Any) -> Any:
|
|
173
|
+
return self._get_engine().tensions(**kwargs)
|
|
174
|
+
|
|
175
|
+
def blocked(self, **kwargs: Any) -> Any:
|
|
176
|
+
return self._get_engine().blocked(**kwargs)
|
|
177
|
+
|
|
178
|
+
def alternatives(self, question_id: str, **kwargs: Any) -> Any:
|
|
179
|
+
return self._get_engine().alternatives(question_id, **kwargs)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Memory:
|
|
183
|
+
"""
|
|
184
|
+
Programmatic builder for FlowScript reasoning graphs.
|
|
185
|
+
|
|
186
|
+
Mirrors the TypeScript Memory class from flowscript-core.
|
|
187
|
+
Built on flowscript-ldp's IR types and query engine.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self, source_file: str = "memory-api") -> None:
|
|
191
|
+
self._nodes: dict[str, Node] = {}
|
|
192
|
+
self._relationships: list[Relationship] = []
|
|
193
|
+
self._states: list[State] = []
|
|
194
|
+
self._source_file = source_file
|
|
195
|
+
self._file_path: Optional[str] = None
|
|
196
|
+
self._query = _QueryProxy(self)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def query(self) -> _QueryProxy:
|
|
200
|
+
return self._query
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def size(self) -> int:
|
|
204
|
+
return len(self._nodes)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def nodes(self) -> list[NodeRef]:
|
|
208
|
+
return [NodeRef(self, n) for n in self._nodes.values()]
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def file_path(self) -> Optional[str]:
|
|
212
|
+
return self._file_path
|
|
213
|
+
|
|
214
|
+
# -- Static constructors --
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def from_ir(ir: IR) -> Memory:
|
|
218
|
+
"""Create Memory from an existing IR graph."""
|
|
219
|
+
mem = Memory()
|
|
220
|
+
for node in ir.nodes:
|
|
221
|
+
mem._nodes[node.id] = node
|
|
222
|
+
mem._relationships = list(ir.relationships)
|
|
223
|
+
mem._states = list(ir.states)
|
|
224
|
+
mem._query._mark_dirty()
|
|
225
|
+
return mem
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def load(file_path: str) -> Memory:
|
|
229
|
+
"""Load Memory from a JSON file."""
|
|
230
|
+
path = Path(file_path)
|
|
231
|
+
data = json.loads(path.read_text("utf-8"))
|
|
232
|
+
ir = IR.model_validate(data)
|
|
233
|
+
mem = Memory.from_ir(ir)
|
|
234
|
+
mem._file_path = str(path.resolve())
|
|
235
|
+
return mem
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def load_or_create(file_path: str) -> Memory:
|
|
239
|
+
"""Load existing memory or create empty. Zero-friction entry point."""
|
|
240
|
+
path = Path(file_path)
|
|
241
|
+
if path.exists():
|
|
242
|
+
return Memory.load(str(path))
|
|
243
|
+
mem = Memory()
|
|
244
|
+
mem._file_path = str(path.resolve())
|
|
245
|
+
return mem
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def from_json(data: dict[str, Any] | str) -> Memory:
|
|
249
|
+
"""Create Memory from JSON dict or string."""
|
|
250
|
+
if isinstance(data, str):
|
|
251
|
+
data = json.loads(data)
|
|
252
|
+
ir = IR.model_validate(data)
|
|
253
|
+
return Memory.from_ir(ir)
|
|
254
|
+
|
|
255
|
+
# -- Node creation --
|
|
256
|
+
|
|
257
|
+
def _add_node(self, content: str, node_type: NodeType) -> NodeRef:
|
|
258
|
+
node_id = _hash_content(content, node_type.value)
|
|
259
|
+
if node_id in self._nodes:
|
|
260
|
+
return NodeRef(self, self._nodes[node_id])
|
|
261
|
+
|
|
262
|
+
node = Node(
|
|
263
|
+
id=node_id,
|
|
264
|
+
type=node_type,
|
|
265
|
+
content=content,
|
|
266
|
+
provenance=_make_provenance(self._source_file),
|
|
267
|
+
)
|
|
268
|
+
self._nodes[node_id] = node
|
|
269
|
+
self._query._mark_dirty()
|
|
270
|
+
return NodeRef(self, node)
|
|
271
|
+
|
|
272
|
+
def thought(self, content: str) -> NodeRef:
|
|
273
|
+
return self._add_node(content, NodeType.THOUGHT)
|
|
274
|
+
|
|
275
|
+
def statement(self, content: str) -> NodeRef:
|
|
276
|
+
return self._add_node(content, NodeType.STATEMENT)
|
|
277
|
+
|
|
278
|
+
def question(self, content: str) -> NodeRef:
|
|
279
|
+
return self._add_node(content, NodeType.QUESTION)
|
|
280
|
+
|
|
281
|
+
def action(self, content: str) -> NodeRef:
|
|
282
|
+
return self._add_node(content, NodeType.ACTION)
|
|
283
|
+
|
|
284
|
+
def insight(self, content: str) -> NodeRef:
|
|
285
|
+
return self._add_node(content, NodeType.INSIGHT)
|
|
286
|
+
|
|
287
|
+
def completion(self, content: str) -> NodeRef:
|
|
288
|
+
return self._add_node(content, NodeType.COMPLETION)
|
|
289
|
+
|
|
290
|
+
def alternative(self, question: NodeRef | str, content: str) -> NodeRef:
|
|
291
|
+
"""Create an alternative linked to a question node."""
|
|
292
|
+
q = self._resolve_ref(question)
|
|
293
|
+
alt = self._add_node(content, NodeType.ALTERNATIVE)
|
|
294
|
+
self._add_relationship(q.id, alt.id, RelationType.ALTERNATIVE)
|
|
295
|
+
return alt
|
|
296
|
+
|
|
297
|
+
# -- Relationship creation --
|
|
298
|
+
|
|
299
|
+
def _add_relationship(
|
|
300
|
+
self,
|
|
301
|
+
source_id: str,
|
|
302
|
+
target_id: str,
|
|
303
|
+
rel_type: RelationType,
|
|
304
|
+
axis_label: Optional[str] = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
raw = f"{rel_type.value}:{source_id}:{target_id}"
|
|
307
|
+
if axis_label:
|
|
308
|
+
raw += f":{axis_label}"
|
|
309
|
+
rel_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
310
|
+
|
|
311
|
+
# Deduplicate
|
|
312
|
+
for existing in self._relationships:
|
|
313
|
+
if existing.id == rel_id:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
rel = Relationship(
|
|
317
|
+
id=rel_id,
|
|
318
|
+
type=rel_type,
|
|
319
|
+
source=source_id,
|
|
320
|
+
target=target_id,
|
|
321
|
+
axis_label=axis_label,
|
|
322
|
+
provenance=_make_provenance(self._source_file),
|
|
323
|
+
)
|
|
324
|
+
self._relationships.append(rel)
|
|
325
|
+
self._query._mark_dirty()
|
|
326
|
+
|
|
327
|
+
def tension(
|
|
328
|
+
self, a: NodeRef | str, b: NodeRef | str, axis: str
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Create a tension between two nodes on a named axis."""
|
|
331
|
+
a_ref = self._resolve_ref(a)
|
|
332
|
+
b_ref = self._resolve_ref(b)
|
|
333
|
+
self._add_relationship(a_ref.id, b_ref.id, RelationType.TENSION, axis_label=axis)
|
|
334
|
+
|
|
335
|
+
def relate(
|
|
336
|
+
self,
|
|
337
|
+
source: NodeRef | str,
|
|
338
|
+
target: NodeRef | str,
|
|
339
|
+
rel_type: RelationType,
|
|
340
|
+
axis_label: Optional[str] = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Create an arbitrary relationship."""
|
|
343
|
+
s = self._resolve_ref(source)
|
|
344
|
+
t = self._resolve_ref(target)
|
|
345
|
+
self._add_relationship(s.id, t.id, rel_type, axis_label=axis_label)
|
|
346
|
+
|
|
347
|
+
# -- State management --
|
|
348
|
+
|
|
349
|
+
def _add_state(
|
|
350
|
+
self,
|
|
351
|
+
node_id: str,
|
|
352
|
+
state_type: StateType,
|
|
353
|
+
fields: Optional[StateFields] = None,
|
|
354
|
+
) -> None:
|
|
355
|
+
raw = f"{state_type.value}:{node_id}"
|
|
356
|
+
state_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
357
|
+
|
|
358
|
+
# Remove existing state of same type on same node
|
|
359
|
+
self._states = [
|
|
360
|
+
s for s in self._states
|
|
361
|
+
if not (s.node_id == node_id and s.type == state_type)
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
state = State(
|
|
365
|
+
id=state_id,
|
|
366
|
+
type=state_type,
|
|
367
|
+
node_id=node_id,
|
|
368
|
+
fields=fields,
|
|
369
|
+
provenance=_make_provenance(self._source_file),
|
|
370
|
+
)
|
|
371
|
+
self._states.append(state)
|
|
372
|
+
self._query._mark_dirty()
|
|
373
|
+
|
|
374
|
+
def _remove_states(
|
|
375
|
+
self, node_id: str, state_type: Optional[StateType] = None
|
|
376
|
+
) -> int:
|
|
377
|
+
before = len(self._states)
|
|
378
|
+
if state_type:
|
|
379
|
+
self._states = [
|
|
380
|
+
s for s in self._states
|
|
381
|
+
if not (s.node_id == node_id and s.type == state_type)
|
|
382
|
+
]
|
|
383
|
+
else:
|
|
384
|
+
self._states = [s for s in self._states if s.node_id != node_id]
|
|
385
|
+
removed = before - len(self._states)
|
|
386
|
+
if removed > 0:
|
|
387
|
+
self._query._mark_dirty()
|
|
388
|
+
return removed
|
|
389
|
+
|
|
390
|
+
# -- Lookup --
|
|
391
|
+
|
|
392
|
+
def get_node(self, node_id: str) -> Optional[Node]:
|
|
393
|
+
return self._nodes.get(node_id)
|
|
394
|
+
|
|
395
|
+
def ref(self, node_id: str) -> NodeRef:
|
|
396
|
+
"""Get a NodeRef by ID. Raises KeyError if not found."""
|
|
397
|
+
node = self._nodes.get(node_id)
|
|
398
|
+
if node is None:
|
|
399
|
+
raise KeyError(f"No node with id {node_id!r}")
|
|
400
|
+
return NodeRef(self, node)
|
|
401
|
+
|
|
402
|
+
def find_nodes(self, content_match: str) -> list[NodeRef]:
|
|
403
|
+
"""Find nodes whose content contains the search string."""
|
|
404
|
+
lower = content_match.lower()
|
|
405
|
+
return [
|
|
406
|
+
NodeRef(self, n)
|
|
407
|
+
for n in self._nodes.values()
|
|
408
|
+
if lower in n.content.lower()
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
# -- Serialization --
|
|
412
|
+
|
|
413
|
+
def to_ir(self) -> IR:
|
|
414
|
+
"""Export as FlowScript IR."""
|
|
415
|
+
return IR(
|
|
416
|
+
version="1.0.0",
|
|
417
|
+
nodes=list(self._nodes.values()),
|
|
418
|
+
relationships=list(self._relationships),
|
|
419
|
+
states=list(self._states),
|
|
420
|
+
invariants=GraphInvariants(),
|
|
421
|
+
metadata=IRMetadata(
|
|
422
|
+
source_files=[self._source_file],
|
|
423
|
+
parsed_at=_now_iso(),
|
|
424
|
+
parser="flowscript-agents",
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def to_json(self) -> dict[str, Any]:
|
|
429
|
+
"""Export as JSON-serializable dict."""
|
|
430
|
+
return self.to_ir().model_dump(mode="json", exclude_none=True)
|
|
431
|
+
|
|
432
|
+
def to_json_string(self, indent: int = 2) -> str:
|
|
433
|
+
"""Export as JSON string."""
|
|
434
|
+
return json.dumps(self.to_json(), indent=indent)
|
|
435
|
+
|
|
436
|
+
def save(self, file_path: Optional[str] = None) -> None:
|
|
437
|
+
"""Save memory to JSON file. Uses atomic write (temp + rename)."""
|
|
438
|
+
import tempfile
|
|
439
|
+
import os
|
|
440
|
+
|
|
441
|
+
target = file_path or self._file_path
|
|
442
|
+
if target is None:
|
|
443
|
+
raise ValueError("No file path provided and no stored path")
|
|
444
|
+
path = Path(target)
|
|
445
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
|
|
447
|
+
# Atomic write: write to temp file, then rename
|
|
448
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
449
|
+
dir=str(path.parent), suffix=".tmp", prefix=".flowscript-"
|
|
450
|
+
)
|
|
451
|
+
try:
|
|
452
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
453
|
+
f.write(self.to_json_string())
|
|
454
|
+
os.replace(tmp_path, str(path))
|
|
455
|
+
except Exception:
|
|
456
|
+
# Clean up temp file on failure
|
|
457
|
+
try:
|
|
458
|
+
os.unlink(tmp_path)
|
|
459
|
+
except OSError:
|
|
460
|
+
pass
|
|
461
|
+
raise
|
|
462
|
+
|
|
463
|
+
self._file_path = str(path.resolve())
|
|
464
|
+
|
|
465
|
+
# -- Internal helpers --
|
|
466
|
+
|
|
467
|
+
def remove_node(self, node_id: str) -> bool:
|
|
468
|
+
"""Remove a node and its associated relationships and states.
|
|
469
|
+
|
|
470
|
+
Returns True if the node was found and removed, False otherwise.
|
|
471
|
+
"""
|
|
472
|
+
if node_id not in self._nodes:
|
|
473
|
+
return False
|
|
474
|
+
del self._nodes[node_id]
|
|
475
|
+
self._relationships = [
|
|
476
|
+
r for r in self._relationships
|
|
477
|
+
if r.source != node_id and r.target != node_id
|
|
478
|
+
]
|
|
479
|
+
self._states = [s for s in self._states if s.node_id != node_id]
|
|
480
|
+
self._query._mark_dirty()
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
def _resolve_ref(self, ref: NodeRef | str) -> NodeRef:
|
|
484
|
+
"""Resolve a NodeRef or content string to a NodeRef.
|
|
485
|
+
|
|
486
|
+
Resolution order:
|
|
487
|
+
1. If NodeRef, return as-is
|
|
488
|
+
2. If string matching an existing node ID, return that node
|
|
489
|
+
3. Otherwise, create a new thought node with the string as content
|
|
490
|
+
"""
|
|
491
|
+
if isinstance(ref, NodeRef):
|
|
492
|
+
return ref
|
|
493
|
+
# Try as node ID first
|
|
494
|
+
if ref in self._nodes:
|
|
495
|
+
return NodeRef(self, self._nodes[ref])
|
|
496
|
+
# Create thought from content string
|
|
497
|
+
return self.thought(ref)
|
|
498
|
+
|
|
499
|
+
def __repr__(self) -> str:
|
|
500
|
+
return (
|
|
501
|
+
f"Memory(nodes={len(self._nodes)}, "
|
|
502
|
+
f"relationships={len(self._relationships)}, "
|
|
503
|
+
f"states={len(self._states)})"
|
|
504
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FlowScript OpenAI Agents SDK Integration.
|
|
3
|
+
|
|
4
|
+
Implements the OpenAI Agents SDK Session protocol, making FlowScript memory
|
|
5
|
+
available as a session backend for OpenAI agents.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from flowscript_agents.openai_agents import FlowScriptSession
|
|
9
|
+
|
|
10
|
+
session = FlowScriptSession("conversation_123", "./agent-memory.json")
|
|
11
|
+
# Use with Runner:
|
|
12
|
+
# result = await Runner.run(agent, "Hello", session=session)
|
|
13
|
+
|
|
14
|
+
Note: Requires openai-agents package: pip install flowscript-agents[openai-agents]
|
|
15
|
+
The Session protocol is for conversation history. For richer FlowScript
|
|
16
|
+
capabilities (compression, semantic queries, temporal tiers), access
|
|
17
|
+
session.memory directly or expose as agent tools.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
from .memory import Memory
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FlowScriptSession:
|
|
30
|
+
"""OpenAI Agents SDK Session backed by FlowScript reasoning memory.
|
|
31
|
+
|
|
32
|
+
Implements the 4-method Session protocol:
|
|
33
|
+
- get_items(limit?) → conversation history
|
|
34
|
+
- add_items(items) → store new conversation items
|
|
35
|
+
- pop_item() → remove and return last item
|
|
36
|
+
- clear_session() → clear all items
|
|
37
|
+
|
|
38
|
+
Access FlowScript queries via .memory property:
|
|
39
|
+
session.memory.query.tensions()
|
|
40
|
+
session.memory.query.blocked()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
session_id: str,
|
|
46
|
+
file_path: str | None = None,
|
|
47
|
+
session_settings: Any = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.session_id = session_id
|
|
50
|
+
self.session_settings = session_settings
|
|
51
|
+
|
|
52
|
+
if file_path:
|
|
53
|
+
self._memory = Memory.load_or_create(file_path)
|
|
54
|
+
else:
|
|
55
|
+
self._memory = Memory()
|
|
56
|
+
self._file_path = file_path
|
|
57
|
+
|
|
58
|
+
# Ordered list of conversation items for this session
|
|
59
|
+
self._items: list[dict[str, Any]] = []
|
|
60
|
+
self._rebuild_items()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def memory(self) -> Memory:
|
|
64
|
+
return self._memory
|
|
65
|
+
|
|
66
|
+
def _rebuild_items(self) -> None:
|
|
67
|
+
"""Rebuild item list from loaded memory nodes."""
|
|
68
|
+
items_with_order: list[tuple[int, dict[str, Any]]] = []
|
|
69
|
+
for ref in self._memory.nodes:
|
|
70
|
+
node = ref.node
|
|
71
|
+
ext = node.ext or {}
|
|
72
|
+
if ext.get("oai_session_id") == self.session_id:
|
|
73
|
+
order = ext.get("oai_order", 0)
|
|
74
|
+
item = ext.get("oai_item", {"role": "user", "content": node.content})
|
|
75
|
+
items_with_order.append((order, item))
|
|
76
|
+
|
|
77
|
+
items_with_order.sort(key=lambda x: x[0])
|
|
78
|
+
self._items = [item for _, item in items_with_order]
|
|
79
|
+
|
|
80
|
+
async def get_items(self, limit: int | None = None) -> list[dict[str, Any]]:
|
|
81
|
+
"""Get conversation items, optionally limited."""
|
|
82
|
+
if limit is not None:
|
|
83
|
+
return list(self._items[-limit:])
|
|
84
|
+
return list(self._items)
|
|
85
|
+
|
|
86
|
+
async def add_items(self, items: list[dict[str, Any]]) -> None:
|
|
87
|
+
"""Add conversation items to the session."""
|
|
88
|
+
base_order = len(self._items)
|
|
89
|
+
for i, item in enumerate(items):
|
|
90
|
+
content = _extract_item_content(item)
|
|
91
|
+
if content:
|
|
92
|
+
ref = self._memory.thought(f"[{self.session_id}] {content}")
|
|
93
|
+
node = ref.node
|
|
94
|
+
node.ext = node.ext or {}
|
|
95
|
+
node.ext.update({
|
|
96
|
+
"oai_session_id": self.session_id,
|
|
97
|
+
"oai_order": base_order + i,
|
|
98
|
+
"oai_item": item,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
self._items.append(item)
|
|
102
|
+
|
|
103
|
+
if self._file_path:
|
|
104
|
+
self._memory.save()
|
|
105
|
+
|
|
106
|
+
async def pop_item(self) -> dict[str, Any] | None:
|
|
107
|
+
"""Remove and return the most recent item."""
|
|
108
|
+
if not self._items:
|
|
109
|
+
return None
|
|
110
|
+
item = self._items.pop()
|
|
111
|
+
|
|
112
|
+
# Remove the corresponding FlowScript node
|
|
113
|
+
content = _extract_item_content(item)
|
|
114
|
+
if content:
|
|
115
|
+
node_content = f"[{self.session_id}] {content}"
|
|
116
|
+
matches = self._memory.find_nodes(node_content)
|
|
117
|
+
for ref in matches:
|
|
118
|
+
if ref.content == node_content:
|
|
119
|
+
self._memory.remove_node(ref.id)
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
if self._file_path:
|
|
123
|
+
self._memory.save()
|
|
124
|
+
return item
|
|
125
|
+
|
|
126
|
+
async def clear_session(self) -> None:
|
|
127
|
+
"""Clear all items for this session. Removes nodes from graph."""
|
|
128
|
+
self._items.clear()
|
|
129
|
+
# Remove session-specific nodes from the FlowScript graph
|
|
130
|
+
to_remove = []
|
|
131
|
+
for ref in self._memory.nodes:
|
|
132
|
+
ext = ref.node.ext or {}
|
|
133
|
+
if ext.get("oai_session_id") == self.session_id:
|
|
134
|
+
to_remove.append(ref.id)
|
|
135
|
+
for node_id in to_remove:
|
|
136
|
+
self._memory.remove_node(node_id)
|
|
137
|
+
|
|
138
|
+
if self._file_path:
|
|
139
|
+
self._memory.save()
|
|
140
|
+
|
|
141
|
+
def save(self) -> None:
|
|
142
|
+
"""Persist to disk."""
|
|
143
|
+
self._memory.save()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _extract_item_content(item: dict[str, Any]) -> str | None:
|
|
147
|
+
"""Extract text content from an OpenAI response input item."""
|
|
148
|
+
# Standard message format
|
|
149
|
+
content = item.get("content")
|
|
150
|
+
if isinstance(content, str):
|
|
151
|
+
return content
|
|
152
|
+
|
|
153
|
+
# Content array format
|
|
154
|
+
if isinstance(content, list):
|
|
155
|
+
texts = []
|
|
156
|
+
for part in content:
|
|
157
|
+
if isinstance(part, dict) and part.get("type") == "input_text":
|
|
158
|
+
texts.append(part.get("text", ""))
|
|
159
|
+
elif isinstance(part, dict) and part.get("type") == "text":
|
|
160
|
+
texts.append(part.get("text", ""))
|
|
161
|
+
return " ".join(texts) if texts else None
|
|
162
|
+
|
|
163
|
+
# Tool output format
|
|
164
|
+
output = item.get("output")
|
|
165
|
+
if isinstance(output, str):
|
|
166
|
+
return output
|
|
167
|
+
|
|
168
|
+
# Fallback
|
|
169
|
+
role = item.get("role", "")
|
|
170
|
+
return f"[{role} message]" if role else None
|