penguiflow 2.1.0__tar.gz → 2.2.0__tar.gz
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-2.1.0 → penguiflow-2.2.0}/PKG-INFO +92 -3
- {penguiflow-2.1.0 → penguiflow-2.2.0}/README.md +86 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/__init__.py +23 -2
- penguiflow-2.2.0/penguiflow/catalog.py +146 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/core.py +39 -0
- penguiflow-2.2.0/penguiflow/debug.py +30 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/metrics.py +9 -0
- penguiflow-2.2.0/penguiflow/middlewares.py +87 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/registry.py +21 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/testkit.py +107 -2
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow.egg-info/PKG-INFO +92 -3
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow.egg-info/SOURCES.txt +6 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow.egg-info/requires.txt +6 -2
- {penguiflow-2.1.0 → penguiflow-2.2.0}/pyproject.toml +21 -3
- penguiflow-2.2.0/tests/test_catalog.py +61 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_core.py +52 -0
- penguiflow-2.2.0/tests/test_metrics.py +133 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_middlewares.py +103 -1
- penguiflow-2.2.0/tests/test_planner_prompts.py +55 -0
- penguiflow-2.2.0/tests/test_property_based.py +172 -0
- penguiflow-2.2.0/tests/test_react_planner.py +845 -0
- penguiflow-2.2.0/tests/test_testkit.py +203 -0
- penguiflow-2.1.0/penguiflow/middlewares.py +0 -16
- penguiflow-2.1.0/tests/test_metrics.py +0 -41
- penguiflow-2.1.0/tests/test_testkit.py +0 -92
- {penguiflow-2.1.0 → penguiflow-2.2.0}/LICENSE +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/admin.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/bus.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/errors.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/node.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/patterns.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/policies.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/remote.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/state.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/streaming.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/types.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow/viz.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow.egg-info/dependency_links.txt +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow.egg-info/entry_points.txt +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow.egg-info/top_level.txt +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow_a2a/__init__.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/penguiflow_a2a/server.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/setup.cfg +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_a2a_server.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_budgets.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_cancel.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_controller.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_distribution_hooks.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_errors.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_metadata.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_node.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_patterns.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_registry.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_remote.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_routing_policy.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_streaming.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_types.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.0}/tests/test_viz.py +0 -0
|
@@ -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
|
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
<a href="https://github.com/penguiflow/penguiflow">
|
|
12
12
|
<img src="https://img.shields.io/badge/coverage-85%25-brightgreen" alt="Coverage">
|
|
13
13
|
</a>
|
|
14
|
+
<a href="https://nightly.link/penguiflow/penguiflow/workflows/benchmarks/main/benchmarks.json.zip">
|
|
15
|
+
<img src="https://img.shields.io/badge/benchmarks-latest-orange" alt="Benchmarks">
|
|
16
|
+
</a>
|
|
14
17
|
<a href="https://pypi.org/project/penguiflow/">
|
|
15
18
|
<img src="https://img.shields.io/pypi/v/penguiflow.svg" alt="PyPI version">
|
|
16
19
|
</a>
|
|
@@ -52,6 +55,23 @@ It provides:
|
|
|
52
55
|
Built on pure `asyncio` (no threads), PenguiFlow is small, predictable, and repo-agnostic.
|
|
53
56
|
Product repos only define **their models + node functions** — the core stays dependency-light.
|
|
54
57
|
|
|
58
|
+
## Gold Standard Scorecard
|
|
59
|
+
|
|
60
|
+
| Area | Metric | Target | Current |
|
|
61
|
+
| --- | --- | --- | --- |
|
|
62
|
+
| Hop overhead | µs per hop | ≤ 500 | 398 |
|
|
63
|
+
| Streaming order | gaps/dupes | 0 | 0 |
|
|
64
|
+
| Cancel leakage | orphan tasks | 0 | 0 |
|
|
65
|
+
| Coverage | lines | ≥85% | 87% |
|
|
66
|
+
| Deps | count | ≤2 | 2 |
|
|
67
|
+
| Import time | ms | ≤220 | 203 |
|
|
68
|
+
|
|
69
|
+
## 📑 Core Behavior Spec
|
|
70
|
+
|
|
71
|
+
* [Core Behavior Spec](docs/core_behavior_spec.md) — single-page rundown of ordering,
|
|
72
|
+
streaming, cancellation, deadline, and fan-in invariants with pointers to regression
|
|
73
|
+
tests.
|
|
74
|
+
|
|
55
75
|
---
|
|
56
76
|
|
|
57
77
|
## ✨ Why PenguiFlow?
|
|
@@ -301,6 +321,70 @@ The new `penguiflow.testkit` module keeps unit tests tiny:
|
|
|
301
321
|
The harness is covered by `tests/test_testkit.py` and demonstrated in
|
|
302
322
|
`examples/testkit_demo/`.
|
|
303
323
|
|
|
324
|
+
### JSON-only ReAct planner (Phase A)
|
|
325
|
+
|
|
326
|
+
Phase A introduces a lightweight planner loop that keeps PenguiFlow typed and
|
|
327
|
+
deterministic:
|
|
328
|
+
|
|
329
|
+
* `penguiflow.catalog.NodeSpec` + `build_catalog` turn registered nodes into
|
|
330
|
+
tool descriptors with JSON Schemas derived from your Pydantic models.
|
|
331
|
+
* `penguiflow.planner.ReactPlanner` drives a JSON-only ReAct loop over those
|
|
332
|
+
descriptors, validating every LLM action with Pydantic and replaying invalid
|
|
333
|
+
steps to request corrections.
|
|
334
|
+
* LiteLLM stays optional—install `penguiflow[planner]` or inject a custom
|
|
335
|
+
`llm_client` for deterministic/offline runs.
|
|
336
|
+
|
|
337
|
+
See `examples/react_minimal/` for a stubbed end-to-end run.
|
|
338
|
+
|
|
339
|
+
### Trajectory summarisation & pause/resume (Phase B)
|
|
340
|
+
|
|
341
|
+
Phase B adds the tools you need for longer-running, approval-driven flows:
|
|
342
|
+
|
|
343
|
+
* **Token-aware summaries** — `Trajectory.compress()` keeps a compact state and
|
|
344
|
+
the planner can route summaries through a cheaper `summarizer_llm` before
|
|
345
|
+
asking for the next action.
|
|
346
|
+
* **`PlannerPause` contract** — nodes can call `await ctx.pause(...)` to return a
|
|
347
|
+
typed pause payload. Resume the run later with `ReactPlanner.resume(token, user_input=...)`.
|
|
348
|
+
* **Developer hints** — pass `planning_hints={...}` to enforce disallowed tools,
|
|
349
|
+
preferred ordering, or parallelism ceilings.
|
|
350
|
+
|
|
351
|
+
All three features are exercised in `examples/react_pause_resume/`, which runs
|
|
352
|
+
entirely offline with stubbed LLM responses.
|
|
353
|
+
|
|
354
|
+
### Adaptive re-planning & budgets (Phase C)
|
|
355
|
+
|
|
356
|
+
Phase C closes the loop when things go sideways:
|
|
357
|
+
|
|
358
|
+
* **Structured failure feedback** — if a tool raises after exhausting its retries,
|
|
359
|
+
the planner records `{failure: {node, args, error_code, suggestion}}` and feeds
|
|
360
|
+
it back to the LLM, prompting a constrained re-plan instead of aborting.
|
|
361
|
+
* **Hard guardrails** — configure wall-clock deadlines and hop budgets directly
|
|
362
|
+
on `ReactPlanner`; attempts beyond the allotted hops surface deterministic
|
|
363
|
+
violations and ultimately finish with `reason="budget_exhausted"` alongside a
|
|
364
|
+
constraint snapshot.
|
|
365
|
+
* **Typed exit reasons** — runs now finish with one of
|
|
366
|
+
`answer_complete`, `no_path`, or `budget_exhausted`, keeping downstream code
|
|
367
|
+
simple and machine-checkable.
|
|
368
|
+
|
|
369
|
+
The new `examples/react_replan/` sample shows a retrieval timeout automatically
|
|
370
|
+
recover via a cached index without leaving the JSON-only contract.
|
|
371
|
+
|
|
372
|
+
### Parallel fan-out & joins (Phase D)
|
|
373
|
+
|
|
374
|
+
Phase D lets the planner propose sets of independent tool calls and join them
|
|
375
|
+
without leaving the typed surface area:
|
|
376
|
+
|
|
377
|
+
* **Parallel `plan` blocks** — the LLM can return `{"plan": [...]}` actions
|
|
378
|
+
where each branch is validated against the catalog and executed concurrently.
|
|
379
|
+
* **Typed joins** — provide a `{"join": {"node": ...}}` descriptor and the
|
|
380
|
+
planner will aggregate results, auto-populate fields like `expect`, `results`,
|
|
381
|
+
or `failures`, and feed branch metadata through `ctx.meta` for the join node.
|
|
382
|
+
* **Deterministic telemetry** — branch errors, pauses, and joins are recorded as
|
|
383
|
+
structured observations so follow-up actions can re-plan or finish cleanly.
|
|
384
|
+
|
|
385
|
+
See `examples/react_parallel/` for a shard fan-out that merges responses in one
|
|
386
|
+
round-trip.
|
|
387
|
+
|
|
304
388
|
|
|
305
389
|
## 🧭 Repo Structure
|
|
306
390
|
|
|
@@ -581,6 +665,8 @@ pytest -q
|
|
|
581
665
|
* `examples/streaming_llm/`: mock LLM emitting streaming chunks to an SSE sink.
|
|
582
666
|
* `examples/metadata_propagation/`: attaching and consuming `Message.meta` context.
|
|
583
667
|
* `examples/visualizer/`: exports Mermaid + DOT diagrams with loop/subflow annotations.
|
|
668
|
+
* `examples/react_minimal/`: JSON-only ReactPlanner loop with a stubbed LLM.
|
|
669
|
+
* `examples/react_pause_resume/`: Phase B planner features with pause/resume and developer hints.
|
|
584
670
|
|
|
585
671
|
---
|
|
586
672
|
|
|
@@ -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"
|
|
@@ -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"]
|
|
@@ -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,
|
|
@@ -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
|
+
|
|
@@ -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."""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Middleware hooks for PenguiFlow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from .metrics import FlowEvent
|
|
10
|
+
|
|
11
|
+
LatencyCallback = Callable[[str, float, FlowEvent], None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Middleware(Protocol):
|
|
15
|
+
"""Base middleware signature receiving :class:`FlowEvent` objects."""
|
|
16
|
+
|
|
17
|
+
async def __call__(self, event: FlowEvent) -> None: ...
|
|
18
|
+
|
|
19
|
+
|
|
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"]
|
|
@@ -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"]
|