penguiflow 2.1.0__tar.gz → 2.2.1__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.1}/PKG-INFO +78 -15
- {penguiflow-2.1.0 → penguiflow-2.2.1}/README.md +72 -12
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/__init__.py +23 -2
- penguiflow-2.2.1/penguiflow/catalog.py +146 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/core.py +39 -0
- penguiflow-2.2.1/penguiflow/debug.py +30 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/metrics.py +9 -0
- penguiflow-2.2.1/penguiflow/middlewares.py +87 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/registry.py +21 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/testkit.py +107 -2
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow.egg-info/PKG-INFO +78 -15
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow.egg-info/SOURCES.txt +6 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow.egg-info/requires.txt +6 -2
- {penguiflow-2.1.0 → penguiflow-2.2.1}/pyproject.toml +21 -3
- penguiflow-2.2.1/tests/test_catalog.py +61 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_core.py +52 -0
- penguiflow-2.2.1/tests/test_metrics.py +133 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_middlewares.py +103 -1
- penguiflow-2.2.1/tests/test_planner_prompts.py +55 -0
- penguiflow-2.2.1/tests/test_property_based.py +172 -0
- penguiflow-2.2.1/tests/test_react_planner.py +845 -0
- penguiflow-2.2.1/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.1}/LICENSE +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/admin.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/bus.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/errors.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/node.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/patterns.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/policies.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/remote.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/state.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/streaming.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/types.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow/viz.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow.egg-info/dependency_links.txt +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow.egg-info/entry_points.txt +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow.egg-info/top_level.txt +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow_a2a/__init__.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/penguiflow_a2a/server.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/setup.cfg +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_a2a_server.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_budgets.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_cancel.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_controller.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_distribution_hooks.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_errors.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_metadata.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_node.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_patterns.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_registry.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_remote.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_routing_policy.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_streaming.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_types.py +0 -0
- {penguiflow-2.1.0 → penguiflow-2.2.1}/tests/test_viz.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: penguiflow
|
|
3
|
-
Version: 2.1
|
|
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.
|
|
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 🐧❄️
|
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
<a href="https://
|
|
57
|
-
|
|
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
|
|
|
@@ -5,18 +5,11 @@
|
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<a href="https://github.com/penguiflow/penguiflow/actions/workflows/ci.yml">
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<a href="https://
|
|
12
|
-
|
|
13
|
-
</a>
|
|
14
|
-
<a href="https://pypi.org/project/penguiflow/">
|
|
15
|
-
<img src="https://img.shields.io/pypi/v/penguiflow.svg" alt="PyPI version">
|
|
16
|
-
</a>
|
|
17
|
-
<a href="https://github.com/penguiflow/penguiflow/blob/main/LICENSE">
|
|
18
|
-
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
|
|
19
|
-
</a>
|
|
8
|
+
<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>
|
|
9
|
+
<a href="https://github.com/hurtener/penguiflow"><img src="https://img.shields.io/badge/coverage-85%25-brightgreen" alt="Coverage"></a>
|
|
10
|
+
<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>
|
|
11
|
+
<a href="https://pypi.org/project/penguiflow/"><img src="https://img.shields.io/pypi/v/penguiflow.svg" alt="PyPI version"></a>
|
|
12
|
+
<a href="https://github.com/hurtener/penguiflow/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
|
20
13
|
</p>
|
|
21
14
|
|
|
22
15
|
**Async-first orchestration library for multi-agent and data pipelines**
|
|
@@ -30,6 +23,7 @@ It provides:
|
|
|
30
23
|
* **Retries, timeouts, backpressure**
|
|
31
24
|
* **Streaming chunks** (LLM-style token emission with `Context.emit_chunk`)
|
|
32
25
|
* **Dynamic loops** (controller nodes)
|
|
26
|
+
* **LLM-driven orchestration** (`ReactPlanner` for autonomous multi-step workflows with tool selection, parallel execution, and pause/resume)
|
|
33
27
|
* **Runtime playbooks** (callable subflows with shared metadata)
|
|
34
28
|
* **Per-trace cancellation** (`PenguiFlow.cancel` with `TraceCancelled` surfacing in nodes)
|
|
35
29
|
* **Deadlines & budgets** (`Message.deadline_s`, `WM.budget_hops`, and `WM.budget_tokens` guardrails that you can leave unset/`None`)
|
|
@@ -52,6 +46,23 @@ It provides:
|
|
|
52
46
|
Built on pure `asyncio` (no threads), PenguiFlow is small, predictable, and repo-agnostic.
|
|
53
47
|
Product repos only define **their models + node functions** — the core stays dependency-light.
|
|
54
48
|
|
|
49
|
+
## Gold Standard Scorecard
|
|
50
|
+
|
|
51
|
+
| Area | Metric | Target | Current |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| Hop overhead | µs per hop | ≤ 500 | 398 |
|
|
54
|
+
| Streaming order | gaps/dupes | 0 | 0 |
|
|
55
|
+
| Cancel leakage | orphan tasks | 0 | 0 |
|
|
56
|
+
| Coverage | lines | ≥85% | 87% |
|
|
57
|
+
| Deps | count | ≤2 | 2 |
|
|
58
|
+
| Import time | ms | ≤220 | 203 |
|
|
59
|
+
|
|
60
|
+
## 📑 Core Behavior Spec
|
|
61
|
+
|
|
62
|
+
* [Core Behavior Spec](docs/core_behavior_spec.md) — single-page rundown of ordering,
|
|
63
|
+
streaming, cancellation, deadline, and fan-in invariants with pointers to regression
|
|
64
|
+
tests.
|
|
65
|
+
|
|
55
66
|
---
|
|
56
67
|
|
|
57
68
|
## ✨ Why PenguiFlow?
|
|
@@ -301,6 +312,53 @@ The new `penguiflow.testkit` module keeps unit tests tiny:
|
|
|
301
312
|
The harness is covered by `tests/test_testkit.py` and demonstrated in
|
|
302
313
|
`examples/testkit_demo/`.
|
|
303
314
|
|
|
315
|
+
### React Planner - LLM-Driven Orchestration
|
|
316
|
+
|
|
317
|
+
Build autonomous agents that select and execute tools dynamically using the ReAct (Reasoning + Acting) pattern:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
from penguiflow import ReactPlanner, tool, build_catalog
|
|
321
|
+
|
|
322
|
+
@tool(desc="Search documentation")
|
|
323
|
+
async def search_docs(args: Query, ctx) -> Documents:
|
|
324
|
+
return Documents(results=await search(args.text))
|
|
325
|
+
|
|
326
|
+
@tool(desc="Summarize results")
|
|
327
|
+
async def summarize(args: Documents, ctx) -> Summary:
|
|
328
|
+
return Summary(text=await llm_summarize(args.results))
|
|
329
|
+
|
|
330
|
+
planner = ReactPlanner(
|
|
331
|
+
llm="gpt-4",
|
|
332
|
+
catalog=build_catalog([search_docs, summarize], registry),
|
|
333
|
+
max_iters=10
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
result = await planner.run("Explain PenguiFlow routing")
|
|
337
|
+
print(result.payload) # LLM orchestrated search → summarize automatically
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Key capabilities:**
|
|
341
|
+
|
|
342
|
+
* **Autonomous tool selection** — LLM decides which tools to call and in what order based on your query
|
|
343
|
+
* **Type-safe execution** — All tool inputs/outputs validated with Pydantic, JSON schemas auto-generated from models
|
|
344
|
+
* **Parallel execution** — LLM can fan out to multiple tools concurrently with automatic result joining
|
|
345
|
+
* **Pause/resume workflows** — Add approval gates with `await ctx.pause()`, resume later with user input
|
|
346
|
+
* **Adaptive replanning** — Tool failures feed structured error suggestions back to LLM for recovery
|
|
347
|
+
* **Constraint enforcement** — Set hop budgets, deadlines, and token limits to prevent runaway execution
|
|
348
|
+
* **Planning hints** — Guide LLM behavior with ordering preferences, parallel groups, and tool filters
|
|
349
|
+
|
|
350
|
+
**Model support:**
|
|
351
|
+
* Install `penguiflow[planner]` for LiteLLM integration (100+ models: OpenAI, Anthropic, Azure, etc.)
|
|
352
|
+
* Or inject a custom `llm_client` for deterministic/offline testing
|
|
353
|
+
|
|
354
|
+
**Examples:**
|
|
355
|
+
* `examples/react_minimal/` — Basic sequential flow with stub LLM
|
|
356
|
+
* `examples/react_parallel/` — Parallel shard fan-out with join node
|
|
357
|
+
* `examples/react_pause_resume/` — Approval workflow with planning hints
|
|
358
|
+
* `examples/react_replan/` — Adaptive recovery from tool failures
|
|
359
|
+
|
|
360
|
+
See **manual.md Section 19** for complete documentation.
|
|
361
|
+
|
|
304
362
|
|
|
305
363
|
## 🧭 Repo Structure
|
|
306
364
|
|
|
@@ -581,6 +639,8 @@ pytest -q
|
|
|
581
639
|
* `examples/streaming_llm/`: mock LLM emitting streaming chunks to an SSE sink.
|
|
582
640
|
* `examples/metadata_propagation/`: attaching and consuming `Message.meta` context.
|
|
583
641
|
* `examples/visualizer/`: exports Mermaid + DOT diagrams with loop/subflow annotations.
|
|
642
|
+
* `examples/react_minimal/`: JSON-only ReactPlanner loop with a stubbed LLM.
|
|
643
|
+
* `examples/react_pause_resume/`: Phase B planner features with pause/resume and developer hints.
|
|
584
644
|
|
|
585
645
|
---
|
|
586
646
|
|
|
@@ -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
|
|
108
|
+
__version__ = "2.2.1"
|
|
@@ -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"]
|