penguiflow 2.1.0__py3-none-any.whl → 2.2.1__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 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.1.0"
108
+ __version__ = "2.2.1"
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
- __all__ = ["Middleware", "FlowEvent"]
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__ = ["run_one", "assert_node_sequence", "simulate_error"]
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.1.0
3
+ Version: 2.2.1
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.110; extra == "dev"
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.110; extra == "a2a-server"
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 🐧❄️
@@ -50,18 +53,11 @@ Dynamic: license-file
50
53
  </p>
51
54
 
52
55
  <p align="center">
53
- <a href="https://github.com/penguiflow/penguiflow/actions/workflows/ci.yml">
54
- <img src="https://github.com/penguiflow/penguiflow/actions/workflows/ci.yml/badge.svg" alt="CI Status">
55
- </a>
56
- <a href="https://github.com/penguiflow/penguiflow">
57
- <img src="https://img.shields.io/badge/coverage-85%25-brightgreen" alt="Coverage">
58
- </a>
59
- <a href="https://pypi.org/project/penguiflow/">
60
- <img src="https://img.shields.io/pypi/v/penguiflow.svg" alt="PyPI version">
61
- </a>
62
- <a href="https://github.com/penguiflow/penguiflow/blob/main/LICENSE">
63
- <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
64
- </a>
56
+ <a href="https://github.com/hurtener/penguiflow/actions/workflows/ci.yml"><img src="https://github.com/hurtener/penguiflow/actions/workflows/ci.yml/badge.svg" alt="CI Status"></a>
57
+ <a href="https://github.com/hurtener/penguiflow"><img src="https://img.shields.io/badge/coverage-85%25-brightgreen" alt="Coverage"></a>
58
+ <a href="https://nightly.link/hurtener/penguiflow/workflows/benchmarks/main/benchmarks.json.zip"><img src="https://img.shields.io/badge/benchmarks-latest-orange" alt="Benchmarks"></a>
59
+ <a href="https://pypi.org/project/penguiflow/"><img src="https://img.shields.io/pypi/v/penguiflow.svg" alt="PyPI version"></a>
60
+ <a href="https://github.com/hurtener/penguiflow/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
65
61
  </p>
66
62
 
67
63
  **Async-first orchestration library for multi-agent and data pipelines**
@@ -75,6 +71,7 @@ It provides:
75
71
  * **Retries, timeouts, backpressure**
76
72
  * **Streaming chunks** (LLM-style token emission with `Context.emit_chunk`)
77
73
  * **Dynamic loops** (controller nodes)
74
+ * **LLM-driven orchestration** (`ReactPlanner` for autonomous multi-step workflows with tool selection, parallel execution, and pause/resume)
78
75
  * **Runtime playbooks** (callable subflows with shared metadata)
79
76
  * **Per-trace cancellation** (`PenguiFlow.cancel` with `TraceCancelled` surfacing in nodes)
80
77
  * **Deadlines & budgets** (`Message.deadline_s`, `WM.budget_hops`, and `WM.budget_tokens` guardrails that you can leave unset/`None`)
@@ -97,6 +94,23 @@ It provides:
97
94
  Built on pure `asyncio` (no threads), PenguiFlow is small, predictable, and repo-agnostic.
98
95
  Product repos only define **their models + node functions** — the core stays dependency-light.
99
96
 
97
+ ## Gold Standard Scorecard
98
+
99
+ | Area | Metric | Target | Current |
100
+ | --- | --- | --- | --- |
101
+ | Hop overhead | µs per hop | ≤ 500 | 398 |
102
+ | Streaming order | gaps/dupes | 0 | 0 |
103
+ | Cancel leakage | orphan tasks | 0 | 0 |
104
+ | Coverage | lines | ≥85% | 87% |
105
+ | Deps | count | ≤2 | 2 |
106
+ | Import time | ms | ≤220 | 203 |
107
+
108
+ ## 📑 Core Behavior Spec
109
+
110
+ * [Core Behavior Spec](docs/core_behavior_spec.md) — single-page rundown of ordering,
111
+ streaming, cancellation, deadline, and fan-in invariants with pointers to regression
112
+ tests.
113
+
100
114
  ---
101
115
 
102
116
  ## ✨ Why PenguiFlow?
@@ -346,6 +360,53 @@ The new `penguiflow.testkit` module keeps unit tests tiny:
346
360
  The harness is covered by `tests/test_testkit.py` and demonstrated in
347
361
  `examples/testkit_demo/`.
348
362
 
363
+ ### React Planner - LLM-Driven Orchestration
364
+
365
+ Build autonomous agents that select and execute tools dynamically using the ReAct (Reasoning + Acting) pattern:
366
+
367
+ ```python
368
+ from penguiflow import ReactPlanner, tool, build_catalog
369
+
370
+ @tool(desc="Search documentation")
371
+ async def search_docs(args: Query, ctx) -> Documents:
372
+ return Documents(results=await search(args.text))
373
+
374
+ @tool(desc="Summarize results")
375
+ async def summarize(args: Documents, ctx) -> Summary:
376
+ return Summary(text=await llm_summarize(args.results))
377
+
378
+ planner = ReactPlanner(
379
+ llm="gpt-4",
380
+ catalog=build_catalog([search_docs, summarize], registry),
381
+ max_iters=10
382
+ )
383
+
384
+ result = await planner.run("Explain PenguiFlow routing")
385
+ print(result.payload) # LLM orchestrated search → summarize automatically
386
+ ```
387
+
388
+ **Key capabilities:**
389
+
390
+ * **Autonomous tool selection** — LLM decides which tools to call and in what order based on your query
391
+ * **Type-safe execution** — All tool inputs/outputs validated with Pydantic, JSON schemas auto-generated from models
392
+ * **Parallel execution** — LLM can fan out to multiple tools concurrently with automatic result joining
393
+ * **Pause/resume workflows** — Add approval gates with `await ctx.pause()`, resume later with user input
394
+ * **Adaptive replanning** — Tool failures feed structured error suggestions back to LLM for recovery
395
+ * **Constraint enforcement** — Set hop budgets, deadlines, and token limits to prevent runaway execution
396
+ * **Planning hints** — Guide LLM behavior with ordering preferences, parallel groups, and tool filters
397
+
398
+ **Model support:**
399
+ * Install `penguiflow[planner]` for LiteLLM integration (100+ models: OpenAI, Anthropic, Azure, etc.)
400
+ * Or inject a custom `llm_client` for deterministic/offline testing
401
+
402
+ **Examples:**
403
+ * `examples/react_minimal/` — Basic sequential flow with stub LLM
404
+ * `examples/react_parallel/` — Parallel shard fan-out with join node
405
+ * `examples/react_pause_resume/` — Approval workflow with planning hints
406
+ * `examples/react_replan/` — Adaptive recovery from tool failures
407
+
408
+ See **manual.md Section 19** for complete documentation.
409
+
349
410
 
350
411
  ## 🧭 Repo Structure
351
412
 
@@ -626,6 +687,8 @@ pytest -q
626
687
  * `examples/streaming_llm/`: mock LLM emitting streaming chunks to an SSE sink.
627
688
  * `examples/metadata_propagation/`: attaching and consuming `Message.meta` context.
628
689
  * `examples/visualizer/`: exports Mermaid + DOT diagrams with loop/subflow annotations.
690
+ * `examples/react_minimal/`: JSON-only ReactPlanner loop with a stubbed LLM.
691
+ * `examples/react_pause_resume/`: Phase B planner features with pause/resume and developer hints.
629
692
 
630
693
  ---
631
694
 
@@ -1,25 +1,27 @@
1
- penguiflow/__init__.py,sha256=Ik7scO1qMwAI0iTL2ASII_z2jl7kpkUAhsQ2yAuUstI,1944
1
+ penguiflow/__init__.py,sha256=kc4GWt5h5HFqOWMoL8Holk_QSxhOr8KHKsLSLJEeuz0,2435
2
2
  penguiflow/admin.py,sha256=093xFkE4bM_2ZhLrzhrEUKtmKHi_yVfMPyaGfwi1rcA,5382
3
3
  penguiflow/bus.py,sha256=mb29509_n97A6zwC-6EDpYorfAWFSpwqsMu_WeZhLE8,732
4
- penguiflow/core.py,sha256=LRE2TUkAXhiVEO6trD1T1NP_7PRwTFy4MLvzVW0pN24,52631
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=RW76sYDebzix6Hf7mDemOj-4SwsnILu5YVEoUHNUKuA,3675
7
- penguiflow/middlewares.py,sha256=4mQgkPowTs6II-_UvIFGyMUTvzhxwDYUicvJMpNNlPU,341
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=4lrGDMFjM7c8pfZFc_YG0YHg-F80JyF4c-j0UbAf150,1419
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=wNPqLldHjoq6q7RItSEdKLveSHqXkNj1HAS_FTZDxxs,8581
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.1.0.dist-info/licenses/LICENSE,sha256=JSvodvLXxSct_kI9IBsZOBpVKoESQTB_AGbkClwZ7HI,1065
20
+ penguiflow-2.2.1.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.1.0.dist-info/METADATA,sha256=ep-_5zifZ9G-25TSfCzctVMsiom1QdrZXx_b0tZAaog,24706
22
- penguiflow-2.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
23
- penguiflow-2.1.0.dist-info/entry_points.txt,sha256=F2KxANLEVGRbpWLmcHcvYrTVLWbKWdmk3VOe98a7t9I,59
24
- penguiflow-2.1.0.dist-info/top_level.txt,sha256=K-fTwLA14n0u_LDxDBCV7FmeBnJffhTOtUbTtOymQns,26
25
- penguiflow-2.1.0.dist-info/RECORD,,
23
+ penguiflow-2.2.1.dist-info/METADATA,sha256=ve8PUCCS-W-ws6LYxSaiudqmLeArnH-tYqINRBJS48w,27930
24
+ penguiflow-2.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
25
+ penguiflow-2.2.1.dist-info/entry_points.txt,sha256=F2KxANLEVGRbpWLmcHcvYrTVLWbKWdmk3VOe98a7t9I,59
26
+ penguiflow-2.2.1.dist-info/top_level.txt,sha256=K-fTwLA14n0u_LDxDBCV7FmeBnJffhTOtUbTtOymQns,26
27
+ penguiflow-2.2.1.dist-info/RECORD,,