penguiflow 2.1.0__py3-none-any.whl → 2.2.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 +23 -2
- penguiflow/catalog.py +146 -0
- penguiflow/core.py +39 -0
- penguiflow/debug.py +30 -0
- penguiflow/metrics.py +9 -0
- penguiflow/middlewares.py +72 -1
- penguiflow/registry.py +21 -0
- penguiflow/testkit.py +107 -2
- {penguiflow-2.1.0.dist-info → penguiflow-2.2.0.dist-info}/METADATA +92 -3
- {penguiflow-2.1.0.dist-info → penguiflow-2.2.0.dist-info}/RECORD +14 -12
- {penguiflow-2.1.0.dist-info → penguiflow-2.2.0.dist-info}/WHEEL +0 -0
- {penguiflow-2.1.0.dist-info → penguiflow-2.2.0.dist-info}/entry_points.txt +0 -0
- {penguiflow-2.1.0.dist-info → penguiflow-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {penguiflow-2.1.0.dist-info → penguiflow-2.2.0.dist-info}/top_level.txt +0 -0
penguiflow/__init__.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from . import testkit
|
|
6
6
|
from .bus import BusEnvelope, MessageBus
|
|
7
|
+
from .catalog import NodeSpec, SideEffect, build_catalog, tool
|
|
7
8
|
from .core import (
|
|
8
9
|
DEFAULT_QUEUE_MAXSIZE,
|
|
9
10
|
Context,
|
|
@@ -12,11 +13,19 @@ from .core import (
|
|
|
12
13
|
call_playbook,
|
|
13
14
|
create,
|
|
14
15
|
)
|
|
16
|
+
from .debug import format_flow_event
|
|
15
17
|
from .errors import FlowError, FlowErrorCode
|
|
16
18
|
from .metrics import FlowEvent
|
|
17
|
-
from .middlewares import Middleware
|
|
19
|
+
from .middlewares import LatencyCallback, Middleware, log_flow_events
|
|
18
20
|
from .node import Node, NodePolicy
|
|
19
21
|
from .patterns import join_k, map_concurrent, predicate_router, union_router
|
|
22
|
+
from .planner import (
|
|
23
|
+
PlannerAction,
|
|
24
|
+
PlannerFinish,
|
|
25
|
+
ReactPlanner,
|
|
26
|
+
Trajectory,
|
|
27
|
+
TrajectoryStep,
|
|
28
|
+
)
|
|
20
29
|
from .policies import DictRoutingPolicy, RoutingPolicy, RoutingRequest
|
|
21
30
|
from .registry import ModelRegistry
|
|
22
31
|
from .remote import (
|
|
@@ -45,8 +54,15 @@ __all__ = [
|
|
|
45
54
|
"Node",
|
|
46
55
|
"NodePolicy",
|
|
47
56
|
"ModelRegistry",
|
|
57
|
+
"NodeSpec",
|
|
58
|
+
"SideEffect",
|
|
59
|
+
"build_catalog",
|
|
60
|
+
"tool",
|
|
48
61
|
"Middleware",
|
|
62
|
+
"log_flow_events",
|
|
63
|
+
"LatencyCallback",
|
|
49
64
|
"FlowEvent",
|
|
65
|
+
"format_flow_event",
|
|
50
66
|
"FlowError",
|
|
51
67
|
"FlowErrorCode",
|
|
52
68
|
"MessageBus",
|
|
@@ -82,6 +98,11 @@ __all__ = [
|
|
|
82
98
|
"RemoteCallResult",
|
|
83
99
|
"RemoteStreamEvent",
|
|
84
100
|
"RemoteNode",
|
|
101
|
+
"ReactPlanner",
|
|
102
|
+
"PlannerAction",
|
|
103
|
+
"PlannerFinish",
|
|
104
|
+
"Trajectory",
|
|
105
|
+
"TrajectoryStep",
|
|
85
106
|
]
|
|
86
107
|
|
|
87
|
-
__version__ = "2.
|
|
108
|
+
__version__ = "2.2.0"
|
penguiflow/catalog.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Tool catalog helpers for the planner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Literal, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .node import Node
|
|
13
|
+
from .registry import ModelRegistry
|
|
14
|
+
|
|
15
|
+
SideEffect = Literal["pure", "read", "write", "external", "stateful"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class NodeSpec:
|
|
20
|
+
"""Structured metadata describing a planner-discoverable node."""
|
|
21
|
+
|
|
22
|
+
node: Node
|
|
23
|
+
name: str
|
|
24
|
+
desc: str
|
|
25
|
+
args_model: type[BaseModel]
|
|
26
|
+
out_model: type[BaseModel]
|
|
27
|
+
side_effects: SideEffect = "pure"
|
|
28
|
+
tags: Sequence[str] = field(default_factory=tuple)
|
|
29
|
+
auth_scopes: Sequence[str] = field(default_factory=tuple)
|
|
30
|
+
cost_hint: str | None = None
|
|
31
|
+
latency_hint_ms: int | None = None
|
|
32
|
+
safety_notes: str | None = None
|
|
33
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def to_tool_record(self) -> dict[str, Any]:
|
|
36
|
+
"""Convert the spec to a serialisable record for prompting."""
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"name": self.name,
|
|
40
|
+
"desc": self.desc,
|
|
41
|
+
"side_effects": self.side_effects,
|
|
42
|
+
"tags": list(self.tags),
|
|
43
|
+
"auth_scopes": list(self.auth_scopes),
|
|
44
|
+
"cost_hint": self.cost_hint,
|
|
45
|
+
"latency_hint_ms": self.latency_hint_ms,
|
|
46
|
+
"safety_notes": self.safety_notes,
|
|
47
|
+
"args_schema": self.args_model.model_json_schema(),
|
|
48
|
+
"out_schema": self.out_model.model_json_schema(),
|
|
49
|
+
"extra": dict(self.extra),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalise_sequence(value: Sequence[str] | None) -> tuple[str, ...]:
|
|
54
|
+
if value is None:
|
|
55
|
+
return ()
|
|
56
|
+
return tuple(dict.fromkeys(value))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def tool(
|
|
60
|
+
*,
|
|
61
|
+
desc: str | None = None,
|
|
62
|
+
side_effects: SideEffect = "pure",
|
|
63
|
+
tags: Sequence[str] | None = None,
|
|
64
|
+
auth_scopes: Sequence[str] | None = None,
|
|
65
|
+
cost_hint: str | None = None,
|
|
66
|
+
latency_hint_ms: int | None = None,
|
|
67
|
+
safety_notes: str | None = None,
|
|
68
|
+
extra: Mapping[str, Any] | None = None,
|
|
69
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
70
|
+
"""Annotate a node function with catalog metadata."""
|
|
71
|
+
|
|
72
|
+
payload: dict[str, Any] = {
|
|
73
|
+
"desc": desc,
|
|
74
|
+
"side_effects": side_effects,
|
|
75
|
+
"tags": _normalise_sequence(tags),
|
|
76
|
+
"auth_scopes": _normalise_sequence(auth_scopes),
|
|
77
|
+
"cost_hint": cost_hint,
|
|
78
|
+
"latency_hint_ms": latency_hint_ms,
|
|
79
|
+
"safety_notes": safety_notes,
|
|
80
|
+
"extra": dict(extra) if extra else {},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
84
|
+
func_ref = cast(Any, func)
|
|
85
|
+
func_ref.__penguiflow_tool__ = payload
|
|
86
|
+
return func
|
|
87
|
+
|
|
88
|
+
return decorator
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_metadata(func: Callable[..., Any]) -> dict[str, Any]:
|
|
92
|
+
raw = getattr(func, "__penguiflow_tool__", None)
|
|
93
|
+
if not raw:
|
|
94
|
+
return {
|
|
95
|
+
"desc": inspect.getdoc(func) or func.__name__,
|
|
96
|
+
"side_effects": "pure",
|
|
97
|
+
"tags": (),
|
|
98
|
+
"auth_scopes": (),
|
|
99
|
+
"cost_hint": None,
|
|
100
|
+
"latency_hint_ms": None,
|
|
101
|
+
"safety_notes": None,
|
|
102
|
+
"extra": {},
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
"desc": raw.get("desc") or inspect.getdoc(func) or func.__name__,
|
|
106
|
+
"side_effects": raw.get("side_effects", "pure"),
|
|
107
|
+
"tags": tuple(raw.get("tags", ())),
|
|
108
|
+
"auth_scopes": tuple(raw.get("auth_scopes", ())),
|
|
109
|
+
"cost_hint": raw.get("cost_hint"),
|
|
110
|
+
"latency_hint_ms": raw.get("latency_hint_ms"),
|
|
111
|
+
"safety_notes": raw.get("safety_notes"),
|
|
112
|
+
"extra": dict(raw.get("extra", {})),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_catalog(
|
|
117
|
+
nodes: Sequence[Node],
|
|
118
|
+
registry: ModelRegistry,
|
|
119
|
+
) -> list[NodeSpec]:
|
|
120
|
+
"""Derive :class:`NodeSpec` objects from runtime nodes."""
|
|
121
|
+
|
|
122
|
+
specs: list[NodeSpec] = []
|
|
123
|
+
for node in nodes:
|
|
124
|
+
node_name = node.name or node.func.__name__
|
|
125
|
+
in_model, out_model = registry.models(node_name)
|
|
126
|
+
metadata = _load_metadata(node.func)
|
|
127
|
+
specs.append(
|
|
128
|
+
NodeSpec(
|
|
129
|
+
node=node,
|
|
130
|
+
name=node_name,
|
|
131
|
+
desc=metadata["desc"],
|
|
132
|
+
args_model=in_model,
|
|
133
|
+
out_model=out_model,
|
|
134
|
+
side_effects=metadata["side_effects"],
|
|
135
|
+
tags=metadata["tags"],
|
|
136
|
+
auth_scopes=metadata["auth_scopes"],
|
|
137
|
+
cost_hint=metadata["cost_hint"],
|
|
138
|
+
latency_hint_ms=metadata["latency_hint_ms"],
|
|
139
|
+
safety_notes=metadata["safety_notes"],
|
|
140
|
+
extra=metadata["extra"],
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return specs
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = ["NodeSpec", "SideEffect", "build_catalog", "tool"]
|
penguiflow/core.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import logging
|
|
11
11
|
import time
|
|
12
|
+
import warnings
|
|
12
13
|
from collections import deque
|
|
13
14
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
14
15
|
from contextlib import suppress
|
|
@@ -686,6 +687,21 @@ class PenguiFlow:
|
|
|
686
687
|
trace_id,
|
|
687
688
|
)
|
|
688
689
|
|
|
690
|
+
if (
|
|
691
|
+
result is not None
|
|
692
|
+
and self._expects_message_output(node)
|
|
693
|
+
and not isinstance(result, Message)
|
|
694
|
+
):
|
|
695
|
+
node_name = node.name or node.node_id
|
|
696
|
+
warning_msg = (
|
|
697
|
+
"Node "
|
|
698
|
+
f"'{node_name}' is registered for Message -> Message outputs "
|
|
699
|
+
f"but returned {type(result).__name__}. "
|
|
700
|
+
"Return a penguiflow.types.Message to preserve headers, "
|
|
701
|
+
"trace_id, and meta."
|
|
702
|
+
)
|
|
703
|
+
warnings.warn(warning_msg, RuntimeWarning, stacklevel=2)
|
|
704
|
+
|
|
689
705
|
if result is not None:
|
|
690
706
|
(
|
|
691
707
|
destination,
|
|
@@ -1210,6 +1226,29 @@ class PenguiFlow:
|
|
|
1210
1226
|
for waiter in waiters:
|
|
1211
1227
|
waiter.set()
|
|
1212
1228
|
|
|
1229
|
+
def _expects_message_output(self, node: Node) -> bool:
|
|
1230
|
+
registry = self._registry
|
|
1231
|
+
if registry is None:
|
|
1232
|
+
return False
|
|
1233
|
+
|
|
1234
|
+
models = getattr(registry, "models", None)
|
|
1235
|
+
if models is None:
|
|
1236
|
+
return False
|
|
1237
|
+
|
|
1238
|
+
node_name = node.name
|
|
1239
|
+
if not node_name:
|
|
1240
|
+
return False
|
|
1241
|
+
|
|
1242
|
+
try:
|
|
1243
|
+
_in_model, out_model = models(node_name)
|
|
1244
|
+
except Exception: # pragma: no cover - registry without entry
|
|
1245
|
+
return False
|
|
1246
|
+
|
|
1247
|
+
try:
|
|
1248
|
+
return issubclass(out_model, Message)
|
|
1249
|
+
except TypeError:
|
|
1250
|
+
return False
|
|
1251
|
+
|
|
1213
1252
|
def _controller_postprocess(
|
|
1214
1253
|
self,
|
|
1215
1254
|
node: Node,
|
penguiflow/debug.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Developer-facing debugging helpers for PenguiFlow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .metrics import FlowEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_flow_event(event: FlowEvent) -> dict[str, Any]:
|
|
12
|
+
"""Return a structured payload ready for logging.
|
|
13
|
+
|
|
14
|
+
The returned dictionary mirrors :meth:`FlowEvent.to_payload` and flattens any
|
|
15
|
+
embedded ``FlowError`` payload so that log aggregators can index the error
|
|
16
|
+
metadata (``flow_error_code``, ``flow_error_message``, ...).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
payload = dict(event.to_payload())
|
|
20
|
+
error_payload: Mapping[str, Any] | None = event.error_payload
|
|
21
|
+
if error_payload is not None:
|
|
22
|
+
# Preserve the original payload for downstream consumers.
|
|
23
|
+
payload["flow_error"] = dict(error_payload)
|
|
24
|
+
for key, value in error_payload.items():
|
|
25
|
+
payload[f"flow_error_{key}"] = value
|
|
26
|
+
return payload
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["format_flow_event"]
|
|
30
|
+
|
penguiflow/metrics.py
CHANGED
|
@@ -31,6 +31,15 @@ class FlowEvent:
|
|
|
31
31
|
def __post_init__(self) -> None:
|
|
32
32
|
object.__setattr__(self, "extra", MappingProxyType(dict(self.extra)))
|
|
33
33
|
|
|
34
|
+
@property
|
|
35
|
+
def error_payload(self) -> Mapping[str, Any] | None:
|
|
36
|
+
"""Return the structured ``FlowError`` payload if present."""
|
|
37
|
+
|
|
38
|
+
raw_payload = self.extra.get("flow_error")
|
|
39
|
+
if isinstance(raw_payload, Mapping):
|
|
40
|
+
return MappingProxyType(dict(raw_payload))
|
|
41
|
+
return None
|
|
42
|
+
|
|
34
43
|
@property
|
|
35
44
|
def queue_depth(self) -> int:
|
|
36
45
|
"""Return the combined depth of incoming and outgoing queues."""
|
penguiflow/middlewares.py
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
5
7
|
from typing import Protocol
|
|
6
8
|
|
|
7
9
|
from .metrics import FlowEvent
|
|
8
10
|
|
|
11
|
+
LatencyCallback = Callable[[str, float, FlowEvent], None]
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
class Middleware(Protocol):
|
|
11
15
|
"""Base middleware signature receiving :class:`FlowEvent` objects."""
|
|
@@ -13,4 +17,71 @@ class Middleware(Protocol):
|
|
|
13
17
|
async def __call__(self, event: FlowEvent) -> None: ...
|
|
14
18
|
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
def log_flow_events(
|
|
21
|
+
logger: logging.Logger | None = None,
|
|
22
|
+
*,
|
|
23
|
+
start_level: int = logging.INFO,
|
|
24
|
+
success_level: int = logging.INFO,
|
|
25
|
+
error_level: int = logging.ERROR,
|
|
26
|
+
latency_callback: LatencyCallback | None = None,
|
|
27
|
+
) -> Middleware:
|
|
28
|
+
"""Return middleware that emits structured node lifecycle logs.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
logger:
|
|
33
|
+
Optional :class:`logging.Logger` instance. When omitted a logger named
|
|
34
|
+
``"penguiflow.flow"`` is used.
|
|
35
|
+
start_level, success_level, error_level:
|
|
36
|
+
Logging levels for ``node_start``, ``node_success``, and
|
|
37
|
+
``node_error`` events respectively.
|
|
38
|
+
latency_callback:
|
|
39
|
+
Optional callable invoked with ``(event_type, latency_ms, event)`` for
|
|
40
|
+
``node_success`` and ``node_error`` events. Use this hook to connect the
|
|
41
|
+
middleware to histogram-based metrics backends without
|
|
42
|
+
re-implementing timing logic.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
log = logger or logging.getLogger("penguiflow.flow")
|
|
46
|
+
|
|
47
|
+
async def _middleware(event: FlowEvent) -> None:
|
|
48
|
+
if event.event_type not in {"node_start", "node_success", "node_error"}:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
payload = event.to_payload()
|
|
52
|
+
log_level = start_level
|
|
53
|
+
|
|
54
|
+
if event.event_type == "node_start":
|
|
55
|
+
log_level = start_level
|
|
56
|
+
elif event.event_type == "node_success":
|
|
57
|
+
log_level = success_level
|
|
58
|
+
else:
|
|
59
|
+
log_level = error_level
|
|
60
|
+
if event.error_payload is not None:
|
|
61
|
+
payload = dict(payload)
|
|
62
|
+
payload["error_payload"] = dict(event.error_payload)
|
|
63
|
+
|
|
64
|
+
log.log(log_level, event.event_type, extra=payload)
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
latency_callback is not None
|
|
68
|
+
and event.event_type in {"node_success", "node_error"}
|
|
69
|
+
and event.latency_ms is not None
|
|
70
|
+
):
|
|
71
|
+
try:
|
|
72
|
+
latency_callback(event.event_type, float(event.latency_ms), event)
|
|
73
|
+
except Exception:
|
|
74
|
+
log.exception(
|
|
75
|
+
"log_flow_events_latency_callback_error",
|
|
76
|
+
extra={
|
|
77
|
+
"event": "log_flow_events_latency_callback_error",
|
|
78
|
+
"node_name": event.node_name,
|
|
79
|
+
"node_id": event.node_id,
|
|
80
|
+
"trace_id": event.trace_id,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return _middleware
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["Middleware", "FlowEvent", "log_flow_events", "LatencyCallback"]
|
penguiflow/registry.py
CHANGED
|
@@ -15,6 +15,8 @@ ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
|
15
15
|
class RegistryEntry:
|
|
16
16
|
in_adapter: TypeAdapter[Any]
|
|
17
17
|
out_adapter: TypeAdapter[Any]
|
|
18
|
+
in_model: type[BaseModel]
|
|
19
|
+
out_model: type[BaseModel]
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class ModelRegistry:
|
|
@@ -36,6 +38,8 @@ class ModelRegistry:
|
|
|
36
38
|
self._entries[node_name] = RegistryEntry(
|
|
37
39
|
TypeAdapter(in_model),
|
|
38
40
|
TypeAdapter(out_model),
|
|
41
|
+
in_model,
|
|
42
|
+
out_model,
|
|
39
43
|
)
|
|
40
44
|
|
|
41
45
|
def adapters(self, node_name: str) -> tuple[TypeAdapter[Any], TypeAdapter[Any]]:
|
|
@@ -45,5 +49,22 @@ class ModelRegistry:
|
|
|
45
49
|
raise KeyError(f"Node '{node_name}' not registered") from exc
|
|
46
50
|
return entry.in_adapter, entry.out_adapter
|
|
47
51
|
|
|
52
|
+
def models(
|
|
53
|
+
self, node_name: str
|
|
54
|
+
) -> tuple[type[BaseModel], type[BaseModel]]:
|
|
55
|
+
"""Return the registered models for ``node_name``.
|
|
56
|
+
|
|
57
|
+
Raises
|
|
58
|
+
------
|
|
59
|
+
KeyError
|
|
60
|
+
If the node has not been registered.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
entry = self._entries[node_name]
|
|
65
|
+
except KeyError as exc:
|
|
66
|
+
raise KeyError(f"Node '{node_name}' not registered") from exc
|
|
67
|
+
return entry.in_model, entry.out_model
|
|
68
|
+
|
|
48
69
|
|
|
49
70
|
__all__ = ["ModelRegistry"]
|
penguiflow/testkit.py
CHANGED
|
@@ -21,9 +21,15 @@ from weakref import WeakKeyDictionary
|
|
|
21
21
|
from .core import PenguiFlow
|
|
22
22
|
from .errors import FlowErrorCode
|
|
23
23
|
from .metrics import FlowEvent
|
|
24
|
-
from .types import Message
|
|
24
|
+
from .types import Headers, Message
|
|
25
25
|
|
|
26
|
-
__all__ = [
|
|
26
|
+
__all__ = [
|
|
27
|
+
"run_one",
|
|
28
|
+
"assert_node_sequence",
|
|
29
|
+
"get_recorded_events",
|
|
30
|
+
"simulate_error",
|
|
31
|
+
"assert_preserves_message_envelope",
|
|
32
|
+
]
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
_MAX_TRACE_HISTORY = 64
|
|
@@ -102,6 +108,20 @@ class _Recorder:
|
|
|
102
108
|
await self._state.record(event)
|
|
103
109
|
|
|
104
110
|
|
|
111
|
+
class _StubContext:
|
|
112
|
+
async def emit(self, *_args: Any, **_kwargs: Any) -> None:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def emit_nowait(self, *_args: Any, **_kwargs: Any) -> None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
async def emit_chunk(self, *_args: Any, **_kwargs: Any) -> Any:
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
"FlowTestKit stub context does not support emit_chunk; provide a custom"
|
|
121
|
+
" context via the 'ctx' parameter"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
105
125
|
def _get_state(flow: PenguiFlow) -> _RecorderState:
|
|
106
126
|
state = _RECORDER_STATE.get(flow)
|
|
107
127
|
if state is None:
|
|
@@ -150,6 +170,78 @@ async def run_one(
|
|
|
150
170
|
return result
|
|
151
171
|
|
|
152
172
|
|
|
173
|
+
async def assert_preserves_message_envelope(
|
|
174
|
+
node: Callable[[Message, Any], Awaitable[Any]] | Any,
|
|
175
|
+
*,
|
|
176
|
+
message: Message | None = None,
|
|
177
|
+
ctx: Any | None = None,
|
|
178
|
+
) -> Message:
|
|
179
|
+
"""Execute ``node`` and assert it preserves the ``Message`` envelope.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
node:
|
|
184
|
+
Either a bare async callable or a :class:`penguiflow.node.Node` whose
|
|
185
|
+
first parameter is a :class:`~penguiflow.types.Message` instance.
|
|
186
|
+
message:
|
|
187
|
+
Optional sample message. When omitted, a minimal envelope is
|
|
188
|
+
synthesised.
|
|
189
|
+
ctx:
|
|
190
|
+
Optional context object passed to the node. By default a stub context
|
|
191
|
+
is used that simply no-ops ``emit``/``emit_nowait``.
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
Message
|
|
196
|
+
The resulting message from the node, allowing additional assertions.
|
|
197
|
+
|
|
198
|
+
Raises
|
|
199
|
+
------
|
|
200
|
+
AssertionError
|
|
201
|
+
If the node does not return a ``Message`` or mutates core envelope
|
|
202
|
+
fields (headers or trace_id).
|
|
203
|
+
TypeError
|
|
204
|
+
If ``node`` is not awaitable.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
from .node import Node # Local import to avoid circular dependency
|
|
208
|
+
|
|
209
|
+
if isinstance(node, Node):
|
|
210
|
+
func = node.func
|
|
211
|
+
node_name = node.name or node.func.__name__
|
|
212
|
+
else:
|
|
213
|
+
func = node
|
|
214
|
+
node_name = getattr(node, "__name__", "<anonymous>")
|
|
215
|
+
|
|
216
|
+
if not inspect.iscoroutinefunction(func):
|
|
217
|
+
raise TypeError("assert_preserves_message_envelope expects an async node")
|
|
218
|
+
|
|
219
|
+
sample = message or Message(payload={}, headers=Headers(tenant="test"))
|
|
220
|
+
context = ctx if ctx is not None else _StubContext()
|
|
221
|
+
|
|
222
|
+
result = await func(sample, context)
|
|
223
|
+
if not isinstance(result, Message):
|
|
224
|
+
produced = type(result).__name__
|
|
225
|
+
raise AssertionError(
|
|
226
|
+
"Node "
|
|
227
|
+
f"'{node_name}' must return a Message but produced {produced}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
mismatches: list[str] = []
|
|
231
|
+
if result.headers != sample.headers:
|
|
232
|
+
mismatches.append("headers")
|
|
233
|
+
if result.trace_id != sample.trace_id:
|
|
234
|
+
mismatches.append("trace_id")
|
|
235
|
+
|
|
236
|
+
if mismatches:
|
|
237
|
+
joined = ", ".join(mismatches)
|
|
238
|
+
raise AssertionError(
|
|
239
|
+
f"Node '{node_name}' altered Message {joined}; preserve the envelope"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
153
245
|
def assert_node_sequence(trace_id: str, expected: Sequence[str]) -> None:
|
|
154
246
|
"""Assert that ``expected`` matches the recorded node start order."""
|
|
155
247
|
|
|
@@ -175,6 +267,19 @@ def assert_node_sequence(trace_id: str, expected: Sequence[str]) -> None:
|
|
|
175
267
|
)
|
|
176
268
|
|
|
177
269
|
|
|
270
|
+
def get_recorded_events(trace_id: str) -> tuple[FlowEvent, ...]:
|
|
271
|
+
"""Return the recorded :class:`FlowEvent` history for ``trace_id``.
|
|
272
|
+
|
|
273
|
+
The FlowTestKit recorder maintains a bounded cache of trace histories.
|
|
274
|
+
This helper exposes the immutable snapshot so tests can assert on
|
|
275
|
+
diagnostics such as ``node_failed`` payloads or retry attempts without
|
|
276
|
+
touching the private cache directly.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
events = _TRACE_HISTORY.get(trace_id, [])
|
|
280
|
+
return tuple(events)
|
|
281
|
+
|
|
282
|
+
|
|
178
283
|
class _ErrorSimulation:
|
|
179
284
|
def __init__(
|
|
180
285
|
self,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: penguiflow
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Async agent orchestration primitives.
|
|
5
5
|
Author: PenguiFlow Team
|
|
6
6
|
License: MIT License
|
|
@@ -36,11 +36,14 @@ Requires-Dist: pytest>=7.4; extra == "dev"
|
|
|
36
36
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
37
37
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
38
38
|
Requires-Dist: coverage[toml]>=7.0; extra == "dev"
|
|
39
|
+
Requires-Dist: hypothesis>=6.103; extra == "dev"
|
|
39
40
|
Requires-Dist: ruff>=0.2; extra == "dev"
|
|
40
|
-
Requires-Dist: fastapi>=0.
|
|
41
|
+
Requires-Dist: fastapi>=0.118; extra == "dev"
|
|
41
42
|
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
42
43
|
Provides-Extra: a2a-server
|
|
43
|
-
Requires-Dist: fastapi>=0.
|
|
44
|
+
Requires-Dist: fastapi>=0.118; extra == "a2a-server"
|
|
45
|
+
Provides-Extra: planner
|
|
46
|
+
Requires-Dist: litellm>=1.77.3; extra == "planner"
|
|
44
47
|
Dynamic: license-file
|
|
45
48
|
|
|
46
49
|
# PenguiFlow 🐧❄️
|
|
@@ -56,6 +59,9 @@ Dynamic: license-file
|
|
|
56
59
|
<a href="https://github.com/penguiflow/penguiflow">
|
|
57
60
|
<img src="https://img.shields.io/badge/coverage-85%25-brightgreen" alt="Coverage">
|
|
58
61
|
</a>
|
|
62
|
+
<a href="https://nightly.link/penguiflow/penguiflow/workflows/benchmarks/main/benchmarks.json.zip">
|
|
63
|
+
<img src="https://img.shields.io/badge/benchmarks-latest-orange" alt="Benchmarks">
|
|
64
|
+
</a>
|
|
59
65
|
<a href="https://pypi.org/project/penguiflow/">
|
|
60
66
|
<img src="https://img.shields.io/pypi/v/penguiflow.svg" alt="PyPI version">
|
|
61
67
|
</a>
|
|
@@ -97,6 +103,23 @@ It provides:
|
|
|
97
103
|
Built on pure `asyncio` (no threads), PenguiFlow is small, predictable, and repo-agnostic.
|
|
98
104
|
Product repos only define **their models + node functions** — the core stays dependency-light.
|
|
99
105
|
|
|
106
|
+
## Gold Standard Scorecard
|
|
107
|
+
|
|
108
|
+
| Area | Metric | Target | Current |
|
|
109
|
+
| --- | --- | --- | --- |
|
|
110
|
+
| Hop overhead | µs per hop | ≤ 500 | 398 |
|
|
111
|
+
| Streaming order | gaps/dupes | 0 | 0 |
|
|
112
|
+
| Cancel leakage | orphan tasks | 0 | 0 |
|
|
113
|
+
| Coverage | lines | ≥85% | 87% |
|
|
114
|
+
| Deps | count | ≤2 | 2 |
|
|
115
|
+
| Import time | ms | ≤220 | 203 |
|
|
116
|
+
|
|
117
|
+
## 📑 Core Behavior Spec
|
|
118
|
+
|
|
119
|
+
* [Core Behavior Spec](docs/core_behavior_spec.md) — single-page rundown of ordering,
|
|
120
|
+
streaming, cancellation, deadline, and fan-in invariants with pointers to regression
|
|
121
|
+
tests.
|
|
122
|
+
|
|
100
123
|
---
|
|
101
124
|
|
|
102
125
|
## ✨ Why PenguiFlow?
|
|
@@ -346,6 +369,70 @@ The new `penguiflow.testkit` module keeps unit tests tiny:
|
|
|
346
369
|
The harness is covered by `tests/test_testkit.py` and demonstrated in
|
|
347
370
|
`examples/testkit_demo/`.
|
|
348
371
|
|
|
372
|
+
### JSON-only ReAct planner (Phase A)
|
|
373
|
+
|
|
374
|
+
Phase A introduces a lightweight planner loop that keeps PenguiFlow typed and
|
|
375
|
+
deterministic:
|
|
376
|
+
|
|
377
|
+
* `penguiflow.catalog.NodeSpec` + `build_catalog` turn registered nodes into
|
|
378
|
+
tool descriptors with JSON Schemas derived from your Pydantic models.
|
|
379
|
+
* `penguiflow.planner.ReactPlanner` drives a JSON-only ReAct loop over those
|
|
380
|
+
descriptors, validating every LLM action with Pydantic and replaying invalid
|
|
381
|
+
steps to request corrections.
|
|
382
|
+
* LiteLLM stays optional—install `penguiflow[planner]` or inject a custom
|
|
383
|
+
`llm_client` for deterministic/offline runs.
|
|
384
|
+
|
|
385
|
+
See `examples/react_minimal/` for a stubbed end-to-end run.
|
|
386
|
+
|
|
387
|
+
### Trajectory summarisation & pause/resume (Phase B)
|
|
388
|
+
|
|
389
|
+
Phase B adds the tools you need for longer-running, approval-driven flows:
|
|
390
|
+
|
|
391
|
+
* **Token-aware summaries** — `Trajectory.compress()` keeps a compact state and
|
|
392
|
+
the planner can route summaries through a cheaper `summarizer_llm` before
|
|
393
|
+
asking for the next action.
|
|
394
|
+
* **`PlannerPause` contract** — nodes can call `await ctx.pause(...)` to return a
|
|
395
|
+
typed pause payload. Resume the run later with `ReactPlanner.resume(token, user_input=...)`.
|
|
396
|
+
* **Developer hints** — pass `planning_hints={...}` to enforce disallowed tools,
|
|
397
|
+
preferred ordering, or parallelism ceilings.
|
|
398
|
+
|
|
399
|
+
All three features are exercised in `examples/react_pause_resume/`, which runs
|
|
400
|
+
entirely offline with stubbed LLM responses.
|
|
401
|
+
|
|
402
|
+
### Adaptive re-planning & budgets (Phase C)
|
|
403
|
+
|
|
404
|
+
Phase C closes the loop when things go sideways:
|
|
405
|
+
|
|
406
|
+
* **Structured failure feedback** — if a tool raises after exhausting its retries,
|
|
407
|
+
the planner records `{failure: {node, args, error_code, suggestion}}` and feeds
|
|
408
|
+
it back to the LLM, prompting a constrained re-plan instead of aborting.
|
|
409
|
+
* **Hard guardrails** — configure wall-clock deadlines and hop budgets directly
|
|
410
|
+
on `ReactPlanner`; attempts beyond the allotted hops surface deterministic
|
|
411
|
+
violations and ultimately finish with `reason="budget_exhausted"` alongside a
|
|
412
|
+
constraint snapshot.
|
|
413
|
+
* **Typed exit reasons** — runs now finish with one of
|
|
414
|
+
`answer_complete`, `no_path`, or `budget_exhausted`, keeping downstream code
|
|
415
|
+
simple and machine-checkable.
|
|
416
|
+
|
|
417
|
+
The new `examples/react_replan/` sample shows a retrieval timeout automatically
|
|
418
|
+
recover via a cached index without leaving the JSON-only contract.
|
|
419
|
+
|
|
420
|
+
### Parallel fan-out & joins (Phase D)
|
|
421
|
+
|
|
422
|
+
Phase D lets the planner propose sets of independent tool calls and join them
|
|
423
|
+
without leaving the typed surface area:
|
|
424
|
+
|
|
425
|
+
* **Parallel `plan` blocks** — the LLM can return `{"plan": [...]}` actions
|
|
426
|
+
where each branch is validated against the catalog and executed concurrently.
|
|
427
|
+
* **Typed joins** — provide a `{"join": {"node": ...}}` descriptor and the
|
|
428
|
+
planner will aggregate results, auto-populate fields like `expect`, `results`,
|
|
429
|
+
or `failures`, and feed branch metadata through `ctx.meta` for the join node.
|
|
430
|
+
* **Deterministic telemetry** — branch errors, pauses, and joins are recorded as
|
|
431
|
+
structured observations so follow-up actions can re-plan or finish cleanly.
|
|
432
|
+
|
|
433
|
+
See `examples/react_parallel/` for a shard fan-out that merges responses in one
|
|
434
|
+
round-trip.
|
|
435
|
+
|
|
349
436
|
|
|
350
437
|
## 🧭 Repo Structure
|
|
351
438
|
|
|
@@ -626,6 +713,8 @@ pytest -q
|
|
|
626
713
|
* `examples/streaming_llm/`: mock LLM emitting streaming chunks to an SSE sink.
|
|
627
714
|
* `examples/metadata_propagation/`: attaching and consuming `Message.meta` context.
|
|
628
715
|
* `examples/visualizer/`: exports Mermaid + DOT diagrams with loop/subflow annotations.
|
|
716
|
+
* `examples/react_minimal/`: JSON-only ReactPlanner loop with a stubbed LLM.
|
|
717
|
+
* `examples/react_pause_resume/`: Phase B planner features with pause/resume and developer hints.
|
|
629
718
|
|
|
630
719
|
---
|
|
631
720
|
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
penguiflow/__init__.py,sha256=
|
|
1
|
+
penguiflow/__init__.py,sha256=Hsoc0UtuilT77kVd0VKPlgnliaRlHgkA3XYd_PvQOYw,2435
|
|
2
2
|
penguiflow/admin.py,sha256=093xFkE4bM_2ZhLrzhrEUKtmKHi_yVfMPyaGfwi1rcA,5382
|
|
3
3
|
penguiflow/bus.py,sha256=mb29509_n97A6zwC-6EDpYorfAWFSpwqsMu_WeZhLE8,732
|
|
4
|
-
penguiflow/
|
|
4
|
+
penguiflow/catalog.py,sha256=z-Drf6PbEkvd65PcBvsVJZBBnM9GwT8ctcMdiIoQ5HY,4673
|
|
5
|
+
penguiflow/core.py,sha256=7w3fbyfQspt0aRt5nfDcr2kzPzX7Tf7O83v-U64DfHI,53960
|
|
6
|
+
penguiflow/debug.py,sha256=KPdpWbascsi1ghu-2HPqRORPM2iqkuV6qWyPc0mAalY,945
|
|
5
7
|
penguiflow/errors.py,sha256=mXpCqZ3zdvz7J7Dck_kcw2BGTIm9yrJAjxp_L8KMY7o,3419
|
|
6
|
-
penguiflow/metrics.py,sha256=
|
|
7
|
-
penguiflow/middlewares.py,sha256=
|
|
8
|
+
penguiflow/metrics.py,sha256=KsxH9tUqrYfs3EyccLcM0-haYySAByq7RMnK7q61eRA,3989
|
|
9
|
+
penguiflow/middlewares.py,sha256=cB4SrRciNcKHyLanaMVsfMElt3El0LNCj_3dyik07x4,2864
|
|
8
10
|
penguiflow/node.py,sha256=0NOs3rU6t1tHNNwwJopqzM2ufGcp82JpzhckynWBRqs,3563
|
|
9
11
|
penguiflow/patterns.py,sha256=qtzRSNRKxV5_qEPXhffd15PuCZs0YnoGF80nNUsrcxw,5512
|
|
10
12
|
penguiflow/policies.py,sha256=3w8ionnpTyuA0ZCc3jPpB011L7_i1qlbiO6escY024s,4385
|
|
11
|
-
penguiflow/registry.py,sha256=
|
|
13
|
+
penguiflow/registry.py,sha256=1nR3J1A6jzuevH8EMn83vCkSnnNKgE28CCO6fXMA3wE,2001
|
|
12
14
|
penguiflow/remote.py,sha256=0-2aW48P8OB8KLEC_7_F_RHtzVJk3huyAMBGdXjmWeA,16426
|
|
13
15
|
penguiflow/state.py,sha256=fBY5d_48hR4XHWVG08FraaQ7u4IVPJwooewfVLmzu1Q,1773
|
|
14
16
|
penguiflow/streaming.py,sha256=RKMm4VfaDA2ceEM_pB2Cuhmpwtdcjj7og-kjXQQDcbc,3863
|
|
15
|
-
penguiflow/testkit.py,sha256=
|
|
17
|
+
penguiflow/testkit.py,sha256=pIFYpu1RfJnW2mbGvUkPhMpL-xDAw0E959oTMxLkLh8,11806
|
|
16
18
|
penguiflow/types.py,sha256=Fl56-b7OwIEUbPMDD1CY09nbOG_tmBw3FUhioojeG5M,1503
|
|
17
19
|
penguiflow/viz.py,sha256=KbBb9kKoL223vj0NgJV_jo5ny-0RTc2gcSBACm0jG8w,5508
|
|
18
|
-
penguiflow-2.
|
|
20
|
+
penguiflow-2.2.0.dist-info/licenses/LICENSE,sha256=JSvodvLXxSct_kI9IBsZOBpVKoESQTB_AGbkClwZ7HI,1065
|
|
19
21
|
penguiflow_a2a/__init__.py,sha256=JuK_ov06yS2H97D2OVXhgX8LcgdOqE3EujUPaDKaduc,342
|
|
20
22
|
penguiflow_a2a/server.py,sha256=VMBO-oGjB6Z9mtRBU0z7ZFGprDUC_kihZJukh3budbs,25932
|
|
21
|
-
penguiflow-2.
|
|
22
|
-
penguiflow-2.
|
|
23
|
-
penguiflow-2.
|
|
24
|
-
penguiflow-2.
|
|
25
|
-
penguiflow-2.
|
|
23
|
+
penguiflow-2.2.0.dist-info/METADATA,sha256=Rp0MN4Yovee2HoJOKpio9PE8A2UBTNUsECAsXBt6lLk,28872
|
|
24
|
+
penguiflow-2.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
25
|
+
penguiflow-2.2.0.dist-info/entry_points.txt,sha256=F2KxANLEVGRbpWLmcHcvYrTVLWbKWdmk3VOe98a7t9I,59
|
|
26
|
+
penguiflow-2.2.0.dist-info/top_level.txt,sha256=K-fTwLA14n0u_LDxDBCV7FmeBnJffhTOtUbTtOymQns,26
|
|
27
|
+
penguiflow-2.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|