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 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.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
- __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.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.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 🐧❄️
@@ -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=Ik7scO1qMwAI0iTL2ASII_z2jl7kpkUAhsQ2yAuUstI,1944
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/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.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.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.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,,