penguiflow 1.0.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.
Potentially problematic release.
This version of penguiflow might be problematic. Click here for more details.
- penguiflow/__init__.py +43 -0
- penguiflow/core.py +587 -0
- penguiflow/middlewares.py +17 -0
- penguiflow/node.py +117 -0
- penguiflow/patterns.py +142 -0
- penguiflow/registry.py +49 -0
- penguiflow/types.py +58 -0
- penguiflow/viz.py +5 -0
- penguiflow-1.0.0.dist-info/METADATA +392 -0
- penguiflow-1.0.0.dist-info/RECORD +13 -0
- penguiflow-1.0.0.dist-info/WHEEL +5 -0
- penguiflow-1.0.0.dist-info/licenses/LICENSE +21 -0
- penguiflow-1.0.0.dist-info/top_level.txt +1 -0
penguiflow/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Public package surface for PenguiFlow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .core import (
|
|
6
|
+
DEFAULT_QUEUE_MAXSIZE,
|
|
7
|
+
Context,
|
|
8
|
+
CycleError,
|
|
9
|
+
PenguiFlow,
|
|
10
|
+
call_playbook,
|
|
11
|
+
create,
|
|
12
|
+
)
|
|
13
|
+
from .middlewares import Middleware
|
|
14
|
+
from .node import Node, NodePolicy
|
|
15
|
+
from .patterns import join_k, map_concurrent, predicate_router, union_router
|
|
16
|
+
from .registry import ModelRegistry
|
|
17
|
+
from .types import WM, FinalAnswer, Headers, Message, PlanStep, Thought
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"Context",
|
|
22
|
+
"CycleError",
|
|
23
|
+
"PenguiFlow",
|
|
24
|
+
"DEFAULT_QUEUE_MAXSIZE",
|
|
25
|
+
"Node",
|
|
26
|
+
"NodePolicy",
|
|
27
|
+
"ModelRegistry",
|
|
28
|
+
"Middleware",
|
|
29
|
+
"call_playbook",
|
|
30
|
+
"Headers",
|
|
31
|
+
"Message",
|
|
32
|
+
"PlanStep",
|
|
33
|
+
"Thought",
|
|
34
|
+
"WM",
|
|
35
|
+
"FinalAnswer",
|
|
36
|
+
"map_concurrent",
|
|
37
|
+
"join_k",
|
|
38
|
+
"predicate_router",
|
|
39
|
+
"union_router",
|
|
40
|
+
"create",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
__version__ = "1.0.0"
|
penguiflow/core.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Core runtime primitives for PenguiFlow (Phase 1).
|
|
2
|
+
|
|
3
|
+
Implements Context, Floe, and PenguiFlow runtime with backpressure-aware
|
|
4
|
+
queues, cycle detection, and graceful shutdown semantics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .middlewares import Middleware
|
|
18
|
+
from .node import Node, NodePolicy
|
|
19
|
+
from .registry import ModelRegistry
|
|
20
|
+
from .types import WM, FinalAnswer, Message
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("penguiflow.core")
|
|
23
|
+
|
|
24
|
+
BUDGET_EXCEEDED_TEXT = "Hop budget exhausted"
|
|
25
|
+
DEADLINE_EXCEEDED_TEXT = "Deadline exceeded"
|
|
26
|
+
|
|
27
|
+
DEFAULT_QUEUE_MAXSIZE = 64
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CycleError(RuntimeError):
|
|
31
|
+
"""Raised when a cycle is detected in the flow graph."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Endpoint:
|
|
36
|
+
"""Synthetic endpoints for PenguiFlow."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
OPEN_SEA = Endpoint("OpenSea")
|
|
42
|
+
ROOKERY = Endpoint("Rookery")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Floe:
|
|
46
|
+
"""Queue-backed edge between nodes."""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("source", "target", "queue")
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
source: Node | Endpoint | None,
|
|
53
|
+
target: Node | Endpoint | None,
|
|
54
|
+
*,
|
|
55
|
+
maxsize: int,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.source = source
|
|
58
|
+
self.target = target
|
|
59
|
+
self.queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=maxsize)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Context:
|
|
63
|
+
"""Provides fetch/emit helpers for a node within a flow."""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("_owner", "_incoming", "_outgoing", "_buffer")
|
|
66
|
+
|
|
67
|
+
def __init__(self, owner: Node | Endpoint) -> None:
|
|
68
|
+
self._owner = owner
|
|
69
|
+
self._incoming: dict[Node | Endpoint, Floe] = {}
|
|
70
|
+
self._outgoing: dict[Node | Endpoint, Floe] = {}
|
|
71
|
+
self._buffer: deque[Any] = deque()
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def owner(self) -> Node | Endpoint:
|
|
75
|
+
return self._owner
|
|
76
|
+
|
|
77
|
+
def add_incoming_floe(self, floe: Floe) -> None:
|
|
78
|
+
if floe.source is None:
|
|
79
|
+
return
|
|
80
|
+
self._incoming[floe.source] = floe
|
|
81
|
+
|
|
82
|
+
def add_outgoing_floe(self, floe: Floe) -> None:
|
|
83
|
+
if floe.target is None:
|
|
84
|
+
return
|
|
85
|
+
self._outgoing[floe.target] = floe
|
|
86
|
+
|
|
87
|
+
def _resolve_targets(
|
|
88
|
+
self,
|
|
89
|
+
targets: Node | Endpoint | Sequence[Node | Endpoint] | None,
|
|
90
|
+
mapping: dict[Node | Endpoint, Floe],
|
|
91
|
+
) -> list[Floe]:
|
|
92
|
+
if not mapping:
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
if targets is None:
|
|
96
|
+
return list(mapping.values())
|
|
97
|
+
|
|
98
|
+
if isinstance(targets, Node | Endpoint):
|
|
99
|
+
targets = [targets]
|
|
100
|
+
|
|
101
|
+
resolved: list[Floe] = []
|
|
102
|
+
for node in targets:
|
|
103
|
+
floe = mapping.get(node)
|
|
104
|
+
if floe is None:
|
|
105
|
+
owner = getattr(self._owner, "name", self._owner)
|
|
106
|
+
target_name = getattr(node, "name", node)
|
|
107
|
+
raise KeyError(f"Unknown target {target_name} for {owner}")
|
|
108
|
+
resolved.append(floe)
|
|
109
|
+
return resolved
|
|
110
|
+
|
|
111
|
+
async def emit(
|
|
112
|
+
self, msg: Any, to: Node | Endpoint | Sequence[Node | Endpoint] | None = None
|
|
113
|
+
) -> None:
|
|
114
|
+
for floe in self._resolve_targets(to, self._outgoing):
|
|
115
|
+
await floe.queue.put(msg)
|
|
116
|
+
|
|
117
|
+
def emit_nowait(
|
|
118
|
+
self, msg: Any, to: Node | Endpoint | Sequence[Node | Endpoint] | None = None
|
|
119
|
+
) -> None:
|
|
120
|
+
for floe in self._resolve_targets(to, self._outgoing):
|
|
121
|
+
floe.queue.put_nowait(msg)
|
|
122
|
+
|
|
123
|
+
def fetch_nowait(
|
|
124
|
+
self, from_: Node | Endpoint | Sequence[Node | Endpoint] | None = None
|
|
125
|
+
) -> Any:
|
|
126
|
+
if self._buffer:
|
|
127
|
+
return self._buffer.popleft()
|
|
128
|
+
for floe in self._resolve_targets(from_, self._incoming):
|
|
129
|
+
try:
|
|
130
|
+
return floe.queue.get_nowait()
|
|
131
|
+
except asyncio.QueueEmpty:
|
|
132
|
+
continue
|
|
133
|
+
raise asyncio.QueueEmpty("no messages available")
|
|
134
|
+
|
|
135
|
+
async def fetch(
|
|
136
|
+
self, from_: Node | Endpoint | Sequence[Node | Endpoint] | None = None
|
|
137
|
+
) -> Any:
|
|
138
|
+
if self._buffer:
|
|
139
|
+
return self._buffer.popleft()
|
|
140
|
+
|
|
141
|
+
floes = self._resolve_targets(from_, self._incoming)
|
|
142
|
+
if not floes:
|
|
143
|
+
raise RuntimeError("context has no incoming floes to fetch from")
|
|
144
|
+
if len(floes) == 1:
|
|
145
|
+
return await floes[0].queue.get()
|
|
146
|
+
return await self.fetch_any(from_)
|
|
147
|
+
|
|
148
|
+
async def fetch_any(
|
|
149
|
+
self, from_: Node | Endpoint | Sequence[Node | Endpoint] | None = None
|
|
150
|
+
) -> Any:
|
|
151
|
+
if self._buffer:
|
|
152
|
+
return self._buffer.popleft()
|
|
153
|
+
|
|
154
|
+
floes = self._resolve_targets(from_, self._incoming)
|
|
155
|
+
if not floes:
|
|
156
|
+
raise RuntimeError("context has no incoming floes to fetch from")
|
|
157
|
+
|
|
158
|
+
tasks = [asyncio.create_task(floe.queue.get()) for floe in floes]
|
|
159
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
done_results = [task.result() for task in done]
|
|
163
|
+
result = done_results[0]
|
|
164
|
+
for extra in done_results[1:]:
|
|
165
|
+
self._buffer.append(extra)
|
|
166
|
+
finally:
|
|
167
|
+
for task in pending:
|
|
168
|
+
task.cancel()
|
|
169
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def outgoing_count(self) -> int:
|
|
173
|
+
return len(self._outgoing)
|
|
174
|
+
|
|
175
|
+
def queue_depth_in(self) -> int:
|
|
176
|
+
return sum(floe.queue.qsize() for floe in self._incoming.values())
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class PenguiFlow:
|
|
180
|
+
"""Coordinates node execution and message routing."""
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
*adjacencies: tuple[Node, Sequence[Node]],
|
|
185
|
+
queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE,
|
|
186
|
+
allow_cycles: bool = False,
|
|
187
|
+
middlewares: Sequence[Middleware] | None = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
self._queue_maxsize = queue_maxsize
|
|
190
|
+
self._allow_cycles = allow_cycles
|
|
191
|
+
self._nodes: set[Node] = set()
|
|
192
|
+
self._adjacency: dict[Node, set[Node]] = {}
|
|
193
|
+
self._contexts: dict[Node | Endpoint, Context] = {}
|
|
194
|
+
self._floes: set[Floe] = set()
|
|
195
|
+
self._tasks: list[asyncio.Task[Any]] = []
|
|
196
|
+
self._running = False
|
|
197
|
+
self._registry: Any | None = None
|
|
198
|
+
self._middlewares: list[Middleware] = list(middlewares or [])
|
|
199
|
+
|
|
200
|
+
self._build_graph(adjacencies)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def registry(self) -> Any | None:
|
|
204
|
+
return self._registry
|
|
205
|
+
|
|
206
|
+
def add_middleware(self, middleware: Middleware) -> None:
|
|
207
|
+
self._middlewares.append(middleware)
|
|
208
|
+
|
|
209
|
+
def _build_graph(self, adjacencies: Sequence[tuple[Node, Sequence[Node]]]) -> None:
|
|
210
|
+
for start, successors in adjacencies:
|
|
211
|
+
self._nodes.add(start)
|
|
212
|
+
self._adjacency.setdefault(start, set())
|
|
213
|
+
for succ in successors:
|
|
214
|
+
self._nodes.add(succ)
|
|
215
|
+
self._adjacency.setdefault(succ, set())
|
|
216
|
+
self._adjacency[start].add(succ)
|
|
217
|
+
|
|
218
|
+
self._detect_cycles()
|
|
219
|
+
|
|
220
|
+
# create contexts for nodes and endpoints
|
|
221
|
+
for node in self._nodes:
|
|
222
|
+
self._contexts[node] = Context(node)
|
|
223
|
+
self._contexts[OPEN_SEA] = Context(OPEN_SEA)
|
|
224
|
+
self._contexts[ROOKERY] = Context(ROOKERY)
|
|
225
|
+
|
|
226
|
+
incoming: dict[Node, set[Node | Endpoint]] = {
|
|
227
|
+
node: set() for node in self._nodes
|
|
228
|
+
}
|
|
229
|
+
for parent, children in self._adjacency.items():
|
|
230
|
+
for child in children:
|
|
231
|
+
if not (parent is child and parent.allow_cycle):
|
|
232
|
+
incoming[child].add(parent)
|
|
233
|
+
floe = Floe(parent, child, maxsize=self._queue_maxsize)
|
|
234
|
+
self._floes.add(floe)
|
|
235
|
+
self._contexts[parent].add_outgoing_floe(floe)
|
|
236
|
+
self._contexts[child].add_incoming_floe(floe)
|
|
237
|
+
|
|
238
|
+
# Link OpenSea to ingress nodes (no incoming parents)
|
|
239
|
+
for node, parents in incoming.items():
|
|
240
|
+
if not parents:
|
|
241
|
+
ingress_floe = Floe(OPEN_SEA, node, maxsize=self._queue_maxsize)
|
|
242
|
+
self._floes.add(ingress_floe)
|
|
243
|
+
self._contexts[OPEN_SEA].add_outgoing_floe(ingress_floe)
|
|
244
|
+
self._contexts[node].add_incoming_floe(ingress_floe)
|
|
245
|
+
|
|
246
|
+
# Link egress nodes (no outgoing successors) to Rookery
|
|
247
|
+
for node in self._nodes:
|
|
248
|
+
successors_set = self._adjacency.get(node, set())
|
|
249
|
+
if not successors_set or successors_set == {node}:
|
|
250
|
+
egress_floe = Floe(node, ROOKERY, maxsize=self._queue_maxsize)
|
|
251
|
+
self._floes.add(egress_floe)
|
|
252
|
+
self._contexts[node].add_outgoing_floe(egress_floe)
|
|
253
|
+
self._contexts[ROOKERY].add_incoming_floe(egress_floe)
|
|
254
|
+
|
|
255
|
+
def _detect_cycles(self) -> None:
|
|
256
|
+
if self._allow_cycles:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
adjacency: dict[Node, set[Node]] = {
|
|
260
|
+
node: set(children) for node, children in self._adjacency.items()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for node, children in adjacency.items():
|
|
264
|
+
if node.allow_cycle:
|
|
265
|
+
children.discard(node)
|
|
266
|
+
|
|
267
|
+
indegree: dict[Node, int] = {node: 0 for node in self._nodes}
|
|
268
|
+
for _parent, children in adjacency.items():
|
|
269
|
+
for child in children:
|
|
270
|
+
indegree[child] += 1
|
|
271
|
+
|
|
272
|
+
queue = [node for node, deg in indegree.items() if deg == 0]
|
|
273
|
+
visited = 0
|
|
274
|
+
|
|
275
|
+
while queue:
|
|
276
|
+
node = queue.pop()
|
|
277
|
+
visited += 1
|
|
278
|
+
for succ in adjacency.get(node, set()):
|
|
279
|
+
indegree[succ] -= 1
|
|
280
|
+
if indegree[succ] == 0:
|
|
281
|
+
queue.append(succ)
|
|
282
|
+
|
|
283
|
+
if visited != len(self._nodes):
|
|
284
|
+
raise CycleError("Flow contains a cycle; enable allow_cycles to bypass")
|
|
285
|
+
|
|
286
|
+
def run(self, *, registry: Any | None = None) -> None:
|
|
287
|
+
if self._running:
|
|
288
|
+
raise RuntimeError("PenguiFlow already running")
|
|
289
|
+
self._running = True
|
|
290
|
+
self._registry = registry
|
|
291
|
+
loop = asyncio.get_running_loop()
|
|
292
|
+
|
|
293
|
+
for node in self._nodes:
|
|
294
|
+
context = self._contexts[node]
|
|
295
|
+
task = loop.create_task(
|
|
296
|
+
self._node_worker(node, context), name=f"penguiflow:{node.name}"
|
|
297
|
+
)
|
|
298
|
+
self._tasks.append(task)
|
|
299
|
+
|
|
300
|
+
async def _node_worker(self, node: Node, context: Context) -> None:
|
|
301
|
+
while True:
|
|
302
|
+
try:
|
|
303
|
+
message = await context.fetch()
|
|
304
|
+
await self._execute_with_reliability(node, context, message)
|
|
305
|
+
except asyncio.CancelledError:
|
|
306
|
+
await self._emit_event(
|
|
307
|
+
event="node_cancelled",
|
|
308
|
+
node=node,
|
|
309
|
+
context=context,
|
|
310
|
+
trace_id=None,
|
|
311
|
+
attempt=0,
|
|
312
|
+
latency_ms=None,
|
|
313
|
+
level=logging.DEBUG,
|
|
314
|
+
)
|
|
315
|
+
raise
|
|
316
|
+
|
|
317
|
+
async def stop(self) -> None:
|
|
318
|
+
if not self._running:
|
|
319
|
+
return
|
|
320
|
+
for task in self._tasks:
|
|
321
|
+
task.cancel()
|
|
322
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
323
|
+
self._tasks.clear()
|
|
324
|
+
self._running = False
|
|
325
|
+
|
|
326
|
+
async def emit(self, msg: Any, to: Node | Sequence[Node] | None = None) -> None:
|
|
327
|
+
await self._contexts[OPEN_SEA].emit(msg, to)
|
|
328
|
+
|
|
329
|
+
def emit_nowait(self, msg: Any, to: Node | Sequence[Node] | None = None) -> None:
|
|
330
|
+
self._contexts[OPEN_SEA].emit_nowait(msg, to)
|
|
331
|
+
|
|
332
|
+
async def fetch(self, from_: Node | Sequence[Node] | None = None) -> Any:
|
|
333
|
+
return await self._contexts[ROOKERY].fetch(from_)
|
|
334
|
+
|
|
335
|
+
async def fetch_any(self, from_: Node | Sequence[Node] | None = None) -> Any:
|
|
336
|
+
return await self._contexts[ROOKERY].fetch_any(from_)
|
|
337
|
+
|
|
338
|
+
async def _execute_with_reliability(
|
|
339
|
+
self,
|
|
340
|
+
node: Node,
|
|
341
|
+
context: Context,
|
|
342
|
+
message: Any,
|
|
343
|
+
) -> None:
|
|
344
|
+
trace_id = getattr(message, "trace_id", None)
|
|
345
|
+
attempt = 0
|
|
346
|
+
|
|
347
|
+
while True:
|
|
348
|
+
start = time.perf_counter()
|
|
349
|
+
await self._emit_event(
|
|
350
|
+
event="node_start",
|
|
351
|
+
node=node,
|
|
352
|
+
context=context,
|
|
353
|
+
trace_id=trace_id,
|
|
354
|
+
attempt=attempt,
|
|
355
|
+
latency_ms=0.0,
|
|
356
|
+
level=logging.DEBUG,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
invocation = node.invoke(message, context, registry=self._registry)
|
|
361
|
+
if node.policy.timeout_s is not None:
|
|
362
|
+
result = await asyncio.wait_for(invocation, node.policy.timeout_s)
|
|
363
|
+
else:
|
|
364
|
+
result = await invocation
|
|
365
|
+
|
|
366
|
+
if result is not None:
|
|
367
|
+
destination, prepared, targets = self._controller_postprocess(
|
|
368
|
+
node, context, message, result
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if destination == "skip":
|
|
372
|
+
continue
|
|
373
|
+
if destination == "rookery":
|
|
374
|
+
await context.emit(prepared, to=[ROOKERY])
|
|
375
|
+
continue
|
|
376
|
+
await context.emit(prepared, to=targets)
|
|
377
|
+
|
|
378
|
+
latency = (time.perf_counter() - start) * 1000
|
|
379
|
+
await self._emit_event(
|
|
380
|
+
event="node_success",
|
|
381
|
+
node=node,
|
|
382
|
+
context=context,
|
|
383
|
+
trace_id=trace_id,
|
|
384
|
+
attempt=attempt,
|
|
385
|
+
latency_ms=latency,
|
|
386
|
+
level=logging.INFO,
|
|
387
|
+
)
|
|
388
|
+
return
|
|
389
|
+
except asyncio.CancelledError:
|
|
390
|
+
raise
|
|
391
|
+
except TimeoutError as exc:
|
|
392
|
+
latency = (time.perf_counter() - start) * 1000
|
|
393
|
+
await self._emit_event(
|
|
394
|
+
event="node_timeout",
|
|
395
|
+
node=node,
|
|
396
|
+
context=context,
|
|
397
|
+
trace_id=trace_id,
|
|
398
|
+
attempt=attempt,
|
|
399
|
+
latency_ms=latency,
|
|
400
|
+
level=logging.WARNING,
|
|
401
|
+
extra={"exception": repr(exc)},
|
|
402
|
+
)
|
|
403
|
+
if attempt >= node.policy.max_retries:
|
|
404
|
+
await self._emit_event(
|
|
405
|
+
event="node_failed",
|
|
406
|
+
node=node,
|
|
407
|
+
context=context,
|
|
408
|
+
trace_id=trace_id,
|
|
409
|
+
attempt=attempt,
|
|
410
|
+
latency_ms=latency,
|
|
411
|
+
level=logging.ERROR,
|
|
412
|
+
extra={"exception": repr(exc)},
|
|
413
|
+
)
|
|
414
|
+
return
|
|
415
|
+
attempt += 1
|
|
416
|
+
delay = self._backoff_delay(node.policy, attempt)
|
|
417
|
+
await self._emit_event(
|
|
418
|
+
event="node_retry",
|
|
419
|
+
node=node,
|
|
420
|
+
context=context,
|
|
421
|
+
trace_id=trace_id,
|
|
422
|
+
attempt=attempt,
|
|
423
|
+
latency_ms=None,
|
|
424
|
+
level=logging.INFO,
|
|
425
|
+
extra={"sleep_s": delay, "exception": repr(exc)},
|
|
426
|
+
)
|
|
427
|
+
await asyncio.sleep(delay)
|
|
428
|
+
continue
|
|
429
|
+
except Exception as exc: # noqa: BLE001
|
|
430
|
+
latency = (time.perf_counter() - start) * 1000
|
|
431
|
+
await self._emit_event(
|
|
432
|
+
event="node_error",
|
|
433
|
+
node=node,
|
|
434
|
+
context=context,
|
|
435
|
+
trace_id=trace_id,
|
|
436
|
+
attempt=attempt,
|
|
437
|
+
latency_ms=latency,
|
|
438
|
+
level=logging.ERROR,
|
|
439
|
+
extra={"exception": repr(exc)},
|
|
440
|
+
)
|
|
441
|
+
if attempt >= node.policy.max_retries:
|
|
442
|
+
await self._emit_event(
|
|
443
|
+
event="node_failed",
|
|
444
|
+
node=node,
|
|
445
|
+
context=context,
|
|
446
|
+
trace_id=trace_id,
|
|
447
|
+
attempt=attempt,
|
|
448
|
+
latency_ms=latency,
|
|
449
|
+
level=logging.ERROR,
|
|
450
|
+
extra={"exception": repr(exc)},
|
|
451
|
+
)
|
|
452
|
+
return
|
|
453
|
+
attempt += 1
|
|
454
|
+
delay = self._backoff_delay(node.policy, attempt)
|
|
455
|
+
await self._emit_event(
|
|
456
|
+
event="node_retry",
|
|
457
|
+
node=node,
|
|
458
|
+
context=context,
|
|
459
|
+
trace_id=trace_id,
|
|
460
|
+
attempt=attempt,
|
|
461
|
+
latency_ms=None,
|
|
462
|
+
level=logging.INFO,
|
|
463
|
+
extra={"sleep_s": delay, "exception": repr(exc)},
|
|
464
|
+
)
|
|
465
|
+
await asyncio.sleep(delay)
|
|
466
|
+
|
|
467
|
+
def _backoff_delay(self, policy: NodePolicy, attempt: int) -> float:
|
|
468
|
+
exponent = max(attempt - 1, 0)
|
|
469
|
+
delay = policy.backoff_base * (policy.backoff_mult ** exponent)
|
|
470
|
+
if policy.max_backoff is not None:
|
|
471
|
+
delay = min(delay, policy.max_backoff)
|
|
472
|
+
return delay
|
|
473
|
+
|
|
474
|
+
def _controller_postprocess(
|
|
475
|
+
self,
|
|
476
|
+
node: Node,
|
|
477
|
+
context: Context,
|
|
478
|
+
incoming: Any,
|
|
479
|
+
result: Any,
|
|
480
|
+
) -> tuple[str, Any, list[Node] | None]:
|
|
481
|
+
if isinstance(result, Message):
|
|
482
|
+
payload = result.payload
|
|
483
|
+
if isinstance(payload, WM):
|
|
484
|
+
now = time.time()
|
|
485
|
+
if result.deadline_s is not None and now > result.deadline_s:
|
|
486
|
+
final = FinalAnswer(text=DEADLINE_EXCEEDED_TEXT)
|
|
487
|
+
final_msg = result.model_copy(update={"payload": final})
|
|
488
|
+
return "rookery", final_msg, None
|
|
489
|
+
|
|
490
|
+
if payload.hops + 1 >= payload.budget_hops:
|
|
491
|
+
final = FinalAnswer(text=BUDGET_EXCEEDED_TEXT)
|
|
492
|
+
final_msg = result.model_copy(update={"payload": final})
|
|
493
|
+
return "rookery", final_msg, None
|
|
494
|
+
|
|
495
|
+
updated_payload = payload.model_copy(update={"hops": payload.hops + 1})
|
|
496
|
+
updated_msg = result.model_copy(update={"payload": updated_payload})
|
|
497
|
+
return "context", updated_msg, [node]
|
|
498
|
+
|
|
499
|
+
if isinstance(payload, FinalAnswer):
|
|
500
|
+
return "rookery", result, None
|
|
501
|
+
|
|
502
|
+
return "context", result, None
|
|
503
|
+
|
|
504
|
+
async def _emit_event(
|
|
505
|
+
self,
|
|
506
|
+
*,
|
|
507
|
+
event: str,
|
|
508
|
+
node: Node,
|
|
509
|
+
context: Context,
|
|
510
|
+
trace_id: str | None,
|
|
511
|
+
attempt: int,
|
|
512
|
+
latency_ms: float | None,
|
|
513
|
+
level: int,
|
|
514
|
+
extra: dict[str, Any] | None = None,
|
|
515
|
+
) -> None:
|
|
516
|
+
payload: dict[str, Any] = {
|
|
517
|
+
"ts": time.time(),
|
|
518
|
+
"event": event,
|
|
519
|
+
"node_name": node.name,
|
|
520
|
+
"node_id": node.node_id,
|
|
521
|
+
"trace_id": trace_id,
|
|
522
|
+
"latency_ms": latency_ms,
|
|
523
|
+
"q_depth_in": context.queue_depth_in(),
|
|
524
|
+
"attempt": attempt,
|
|
525
|
+
}
|
|
526
|
+
if extra:
|
|
527
|
+
payload.update(extra)
|
|
528
|
+
|
|
529
|
+
logger.log(level, event, extra=payload)
|
|
530
|
+
|
|
531
|
+
for middleware in list(self._middlewares):
|
|
532
|
+
try:
|
|
533
|
+
await middleware(event, payload)
|
|
534
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
535
|
+
logger.exception(
|
|
536
|
+
"middleware_error",
|
|
537
|
+
extra={
|
|
538
|
+
"event": "middleware_error",
|
|
539
|
+
"node_name": node.name,
|
|
540
|
+
"node_id": node.node_id,
|
|
541
|
+
"exception": exc,
|
|
542
|
+
},
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
PlaybookFactory = Callable[[], tuple["PenguiFlow", ModelRegistry | None]]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
async def call_playbook(
|
|
550
|
+
playbook: PlaybookFactory,
|
|
551
|
+
parent_msg: Message,
|
|
552
|
+
timeout: float | None = None,
|
|
553
|
+
) -> Any:
|
|
554
|
+
"""Execute a subflow playbook and return the first Rookery payload."""
|
|
555
|
+
|
|
556
|
+
flow, registry = playbook()
|
|
557
|
+
flow.run(registry=registry)
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
await flow.emit(parent_msg)
|
|
561
|
+
fetch_coro = flow.fetch()
|
|
562
|
+
if timeout is not None:
|
|
563
|
+
result_msg = await asyncio.wait_for(fetch_coro, timeout)
|
|
564
|
+
else:
|
|
565
|
+
result_msg = await fetch_coro
|
|
566
|
+
if isinstance(result_msg, Message):
|
|
567
|
+
return result_msg.payload
|
|
568
|
+
return result_msg
|
|
569
|
+
finally:
|
|
570
|
+
await asyncio.shield(flow.stop())
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def create(*adjacencies: tuple[Node, Sequence[Node]], **kwargs: Any) -> PenguiFlow:
|
|
574
|
+
"""Convenience helper to instantiate a PenguiFlow."""
|
|
575
|
+
|
|
576
|
+
return PenguiFlow(*adjacencies, **kwargs)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
__all__ = [
|
|
580
|
+
"Context",
|
|
581
|
+
"Floe",
|
|
582
|
+
"PenguiFlow",
|
|
583
|
+
"CycleError",
|
|
584
|
+
"call_playbook",
|
|
585
|
+
"create",
|
|
586
|
+
"DEFAULT_QUEUE_MAXSIZE",
|
|
587
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Middleware hooks for PenguiFlow.
|
|
2
|
+
|
|
3
|
+
Instrumentation arrives in Phase 3.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Middleware(Protocol):
|
|
12
|
+
"""Base middleware signature."""
|
|
13
|
+
|
|
14
|
+
async def __call__(self, event: str, payload: dict[str, object]) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["Middleware"]
|