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 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"]