penguiflow 2.2.3__tar.gz → 2.2.5__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.

Files changed (98) hide show
  1. {penguiflow-2.2.3 → penguiflow-2.2.5}/PKG-INFO +4 -1
  2. {penguiflow-2.2.3 → penguiflow-2.2.5}/README.md +3 -0
  3. penguiflow-2.2.5/examples/__init__.py +0 -0
  4. penguiflow-2.2.5/examples/controller_multihop/__init__.py +0 -0
  5. penguiflow-2.2.5/examples/controller_multihop/flow.py +54 -0
  6. penguiflow-2.2.5/examples/fanout_join/__init__.py +0 -0
  7. penguiflow-2.2.5/examples/fanout_join/flow.py +54 -0
  8. penguiflow-2.2.5/examples/map_concurrent/__init__.py +0 -0
  9. penguiflow-2.2.5/examples/map_concurrent/flow.py +56 -0
  10. penguiflow-2.2.5/examples/metadata_propagation/flow.py +61 -0
  11. penguiflow-2.2.5/examples/mlflow_metrics/__init__.py +1 -0
  12. penguiflow-2.2.5/examples/mlflow_metrics/flow.py +120 -0
  13. penguiflow-2.2.5/examples/playbook_retrieval/__init__.py +0 -0
  14. penguiflow-2.2.5/examples/playbook_retrieval/flow.py +61 -0
  15. penguiflow-2.2.5/examples/quickstart/__init__.py +0 -0
  16. penguiflow-2.2.5/examples/quickstart/flow.py +71 -0
  17. penguiflow-2.2.5/examples/react_minimal/main.py +109 -0
  18. penguiflow-2.2.5/examples/react_parallel/main.py +121 -0
  19. penguiflow-2.2.5/examples/react_pause_resume/main.py +157 -0
  20. penguiflow-2.2.5/examples/react_replan/main.py +133 -0
  21. penguiflow-2.2.5/examples/reliability_middleware/__init__.py +0 -0
  22. penguiflow-2.2.5/examples/reliability_middleware/flow.py +67 -0
  23. penguiflow-2.2.5/examples/roadmap_status_updates/__init__.py +0 -0
  24. penguiflow-2.2.5/examples/roadmap_status_updates/flow.py +640 -0
  25. penguiflow-2.2.5/examples/roadmap_status_updates_subflows/__init__.py +0 -0
  26. penguiflow-2.2.5/examples/roadmap_status_updates_subflows/flow.py +814 -0
  27. penguiflow-2.2.5/examples/routing_policy/__init__.py +0 -0
  28. penguiflow-2.2.5/examples/routing_policy/flow.py +89 -0
  29. penguiflow-2.2.5/examples/routing_predicate/__init__.py +0 -0
  30. penguiflow-2.2.5/examples/routing_predicate/flow.py +51 -0
  31. penguiflow-2.2.5/examples/routing_union/__init__.py +0 -0
  32. penguiflow-2.2.5/examples/routing_union/flow.py +56 -0
  33. penguiflow-2.2.5/examples/status_roadmap_flow/__init__.py +0 -0
  34. penguiflow-2.2.5/examples/status_roadmap_flow/flow.py +458 -0
  35. penguiflow-2.2.5/examples/streaming_llm/__init__.py +3 -0
  36. penguiflow-2.2.5/examples/streaming_llm/flow.py +77 -0
  37. penguiflow-2.2.5/examples/testkit_demo/flow.py +34 -0
  38. penguiflow-2.2.5/examples/trace_cancel/flow.py +79 -0
  39. penguiflow-2.2.5/examples/traceable_errors/flow.py +51 -0
  40. penguiflow-2.2.5/examples/visualizer/flow.py +49 -0
  41. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/__init__.py +1 -1
  42. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow.egg-info/PKG-INFO +4 -1
  43. penguiflow-2.2.5/penguiflow.egg-info/SOURCES.txt +95 -0
  44. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow.egg-info/top_level.txt +1 -0
  45. {penguiflow-2.2.3 → penguiflow-2.2.5}/pyproject.toml +2 -2
  46. penguiflow-2.2.5/tests/test_examples_roadmap.py +110 -0
  47. penguiflow-2.2.3/penguiflow.egg-info/SOURCES.txt +0 -56
  48. {penguiflow-2.2.3 → penguiflow-2.2.5}/LICENSE +0 -0
  49. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/admin.py +0 -0
  50. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/bus.py +0 -0
  51. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/catalog.py +0 -0
  52. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/core.py +0 -0
  53. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/debug.py +0 -0
  54. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/errors.py +0 -0
  55. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/metrics.py +0 -0
  56. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/middlewares.py +0 -0
  57. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/node.py +0 -0
  58. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/patterns.py +0 -0
  59. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/planner/__init__.py +0 -0
  60. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/planner/prompts.py +0 -0
  61. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/planner/react.py +0 -0
  62. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/policies.py +0 -0
  63. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/registry.py +0 -0
  64. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/remote.py +0 -0
  65. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/state.py +0 -0
  66. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/streaming.py +0 -0
  67. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/testkit.py +0 -0
  68. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/types.py +0 -0
  69. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow/viz.py +0 -0
  70. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow.egg-info/dependency_links.txt +0 -0
  71. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow.egg-info/entry_points.txt +0 -0
  72. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow.egg-info/requires.txt +0 -0
  73. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow_a2a/__init__.py +0 -0
  74. {penguiflow-2.2.3 → penguiflow-2.2.5}/penguiflow_a2a/server.py +0 -0
  75. {penguiflow-2.2.3 → penguiflow-2.2.5}/setup.cfg +0 -0
  76. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_a2a_server.py +0 -0
  77. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_budgets.py +0 -0
  78. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_cancel.py +0 -0
  79. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_catalog.py +0 -0
  80. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_controller.py +0 -0
  81. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_core.py +0 -0
  82. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_distribution_hooks.py +0 -0
  83. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_errors.py +0 -0
  84. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_metadata.py +0 -0
  85. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_metrics.py +0 -0
  86. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_middlewares.py +0 -0
  87. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_node.py +0 -0
  88. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_patterns.py +0 -0
  89. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_planner_prompts.py +0 -0
  90. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_property_based.py +0 -0
  91. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_react_planner.py +0 -0
  92. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_registry.py +0 -0
  93. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_remote.py +0 -0
  94. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_routing_policy.py +0 -0
  95. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_streaming.py +0 -0
  96. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_testkit.py +0 -0
  97. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_types.py +0 -0
  98. {penguiflow-2.2.3 → penguiflow-2.2.5}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: penguiflow
3
- Version: 2.2.3
3
+ Version: 2.2.5
4
4
  Summary: Async agent orchestration primitives.
5
5
  Author: PenguiFlow Team
6
6
  License: MIT License
@@ -687,9 +687,12 @@ pytest -q
687
687
  * `examples/streaming_llm/`: mock LLM emitting streaming chunks to an SSE sink.
688
688
  * `examples/metadata_propagation/`: attaching and consuming `Message.meta` context.
689
689
  * `examples/visualizer/`: exports Mermaid + DOT diagrams with loop/subflow annotations.
690
+ * `examples/roadmap_status_updates/`: roadmap-aware agent scaffold that streams status updates and final chunks.
691
+ * `examples/status_roadmap_flow/`: roadmap-driven websocket status updates with FlowResponse scaffolding.
690
692
  * `examples/react_minimal/`: JSON-only ReactPlanner loop with a stubbed LLM.
691
693
  * `examples/react_pause_resume/`: Phase B planner features with pause/resume and developer hints.
692
694
 
695
+
693
696
  ---
694
697
 
695
698
  ## 🤝 Contributing
@@ -639,9 +639,12 @@ pytest -q
639
639
  * `examples/streaming_llm/`: mock LLM emitting streaming chunks to an SSE sink.
640
640
  * `examples/metadata_propagation/`: attaching and consuming `Message.meta` context.
641
641
  * `examples/visualizer/`: exports Mermaid + DOT diagrams with loop/subflow annotations.
642
+ * `examples/roadmap_status_updates/`: roadmap-aware agent scaffold that streams status updates and final chunks.
643
+ * `examples/status_roadmap_flow/`: roadmap-driven websocket status updates with FlowResponse scaffolding.
642
644
  * `examples/react_minimal/`: JSON-only ReactPlanner loop with a stubbed LLM.
643
645
  * `examples/react_pause_resume/`: Phase B planner features with pause/resume and developer hints.
644
646
 
647
+
645
648
  ---
646
649
 
647
650
  ## 🤝 Contributing
File without changes
@@ -0,0 +1,54 @@
1
+ """Controller loop example."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+
8
+ from penguiflow import WM, FinalAnswer, Headers, Message, Node, NodePolicy, create
9
+
10
+
11
+ async def controller(msg: Message, ctx) -> Message:
12
+ wm = msg.payload
13
+ assert isinstance(wm, WM)
14
+
15
+ if wm.hops >= 3:
16
+ final = FinalAnswer(text=f"answer after {wm.hops} hops: {wm.facts[-1]}")
17
+ return msg.model_copy(update={"payload": final})
18
+
19
+ token_cost = 5
20
+ updated_wm = wm.model_copy(
21
+ update={
22
+ "facts": wm.facts + [f"fact-{wm.hops}"],
23
+ "tokens_used": wm.tokens_used + token_cost,
24
+ }
25
+ )
26
+ return msg.model_copy(update={"payload": updated_wm})
27
+
28
+
29
+ async def main() -> None:
30
+ controller_node = Node(
31
+ controller,
32
+ name="controller",
33
+ allow_cycle=True,
34
+ policy=NodePolicy(validate="none"),
35
+ )
36
+ flow = create(controller_node.to(controller_node))
37
+ flow.run()
38
+
39
+ wm = WM(query="latest metrics", budget_hops=5, budget_tokens=12)
40
+ message = Message(
41
+ payload=wm,
42
+ headers=Headers(tenant="acme"),
43
+ deadline_s=time.time() + 5,
44
+ )
45
+
46
+ await flow.emit(message)
47
+ final_msg = await flow.fetch()
48
+ print(final_msg.payload.text)
49
+
50
+ await flow.stop()
51
+
52
+
53
+ if __name__ == "__main__": # pragma: no cover
54
+ asyncio.run(main())
File without changes
@@ -0,0 +1,54 @@
1
+ """Fan-out and join example with join_k."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from penguiflow import Headers, Message, Node, NodePolicy, create, join_k
8
+
9
+
10
+ async def fan(msg: Message, ctx) -> Message:
11
+ return msg
12
+
13
+
14
+ async def work_a(msg: Message, ctx) -> Message:
15
+ return msg.model_copy(update={"payload": msg.payload + "::A"})
16
+
17
+
18
+ async def work_b(msg: Message, ctx) -> Message:
19
+ return msg.model_copy(update={"payload": msg.payload + "::B"})
20
+
21
+
22
+ async def summarize(batch: Message, ctx) -> str:
23
+ return ",".join(batch.payload)
24
+
25
+
26
+ async def main() -> None:
27
+ fan_node = Node(fan, name="fan", policy=NodePolicy(validate="none"))
28
+ worker_a = Node(work_a, name="work_a", policy=NodePolicy(validate="none"))
29
+ worker_b = Node(work_b, name="work_b", policy=NodePolicy(validate="none"))
30
+ join_node = join_k("join", 2)
31
+ summarize_node = Node(
32
+ summarize,
33
+ name="summarize",
34
+ policy=NodePolicy(validate="none"),
35
+ )
36
+
37
+ flow = create(
38
+ fan_node.to(worker_a, worker_b),
39
+ worker_a.to(join_node),
40
+ worker_b.to(join_node),
41
+ join_node.to(summarize_node),
42
+ summarize_node.to(),
43
+ )
44
+ flow.run()
45
+
46
+ message = Message(payload="task", headers=Headers(tenant="acme"))
47
+ await flow.emit(message)
48
+ print(await flow.fetch()) # task::A,task::B
49
+
50
+ await flow.stop()
51
+
52
+
53
+ if __name__ == "__main__": # pragma: no cover
54
+ asyncio.run(main())
File without changes
@@ -0,0 +1,56 @@
1
+ """Demonstrate the map_concurrent helper inside a node."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from penguiflow import Headers, Message, Node, NodePolicy, create, map_concurrent
9
+
10
+
11
+ async def seed(msg: Message, ctx) -> Message:
12
+ """Attach a batch of document ids to the message payload."""
13
+
14
+ docs = [f"doc-{i}" for i in range(1, 6)]
15
+ return msg.model_copy(update={"payload": {"query": msg.payload, "docs": docs}})
16
+
17
+
18
+ async def score(msg: Message, ctx) -> Message:
19
+ """Score each document concurrently with a bounded semaphore."""
20
+
21
+ docs = msg.payload["docs"]
22
+
23
+ async def worker(doc_id: str) -> dict[str, Any]:
24
+ await asyncio.sleep(0.05)
25
+ return {"doc_id": doc_id, "score": len(doc_id) / 10}
26
+
27
+ scored = await map_concurrent(docs, worker, max_concurrency=2)
28
+ return msg.model_copy(update={"payload": {**msg.payload, "scores": scored}})
29
+
30
+
31
+ async def summarize(msg: Message, ctx) -> str:
32
+ top = max(msg.payload["scores"], key=lambda item: item["score"])
33
+ return f"top doc: {top['doc_id']} score={top['score']:.2f}"
34
+
35
+
36
+ async def main() -> None:
37
+ seed_node = Node(seed, name="seed", policy=NodePolicy(validate="none"))
38
+ score_node = Node(score, name="score", policy=NodePolicy(validate="none"))
39
+ summary_node = Node(summarize, name="summary", policy=NodePolicy(validate="none"))
40
+
41
+ flow = create(
42
+ seed_node.to(score_node),
43
+ score_node.to(summary_node),
44
+ summary_node.to(),
45
+ )
46
+ flow.run()
47
+
48
+ message = Message(payload="antarctic krill", headers=Headers(tenant="acme"))
49
+ await flow.emit(message)
50
+ print(await flow.fetch())
51
+
52
+ await flow.stop()
53
+
54
+
55
+ if __name__ == "__main__": # pragma: no cover
56
+ asyncio.run(main())
@@ -0,0 +1,61 @@
1
+ """Metadata propagation demo for PenguiFlow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from penguiflow import Headers, Message, Node, create
8
+
9
+
10
+ async def annotate_cost(message: Message, _ctx) -> Message:
11
+ """Add retrieval cost metadata without mutating payload."""
12
+
13
+ message.meta["retrieval_cost_ms"] = 87
14
+ return message
15
+
16
+
17
+ async def summarize(message: Message, _ctx) -> Message:
18
+ """Read metadata and add summarizer details."""
19
+
20
+ cost = message.meta.get("retrieval_cost_ms", 0)
21
+ new_meta = dict(message.meta)
22
+ new_meta["summary_model"] = "penguin-x1"
23
+ new_meta["summary_tokens"] = 128
24
+ return message.model_copy(
25
+ update={
26
+ "payload": f"Summary(cost={cost}ms): {message.payload}",
27
+ "meta": new_meta,
28
+ }
29
+ )
30
+
31
+
32
+ async def sink(message: Message, _ctx) -> Message:
33
+ """Forward the final message to Rookery."""
34
+
35
+ return message
36
+
37
+
38
+ async def main() -> None:
39
+ annotate_node = Node(annotate_cost, name="annotate_cost")
40
+ summarize_node = Node(summarize, name="summarize")
41
+ sink_node = Node(sink, name="sink")
42
+
43
+ flow = create(
44
+ annotate_node.to(summarize_node),
45
+ summarize_node.to(sink_node),
46
+ )
47
+ flow.run()
48
+
49
+ headers = Headers(tenant="acme", topic="metadata-demo")
50
+ message = Message(payload="documents about penguins", headers=headers)
51
+
52
+ await flow.emit(message)
53
+ result = await flow.fetch()
54
+ await flow.stop()
55
+
56
+ print("Payload:", result.payload)
57
+ print("Metadata:", result.meta)
58
+
59
+
60
+ if __name__ == "__main__":
61
+ asyncio.run(main())
@@ -0,0 +1 @@
1
+ """MLflow observability example."""
@@ -0,0 +1,120 @@
1
+ """Demo flow that records node events to MLflow (or stdout fallback)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ from penguiflow import (
9
+ FinalAnswer,
10
+ Headers,
11
+ Message,
12
+ Node,
13
+ NodePolicy,
14
+ PenguiFlow,
15
+ create,
16
+ )
17
+ from penguiflow.metrics import FlowEvent
18
+
19
+
20
+ class MlflowMiddleware:
21
+ """Forward runtime events to MLflow if available, otherwise print them."""
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ run_name: str = "penguiflow-demo",
27
+ tracking_dir: str | None = None,
28
+ ) -> None:
29
+ self.run_name = run_name
30
+ self.events: list[FlowEvent] = []
31
+ self._warned = False
32
+ self._tracking_dir = tracking_dir
33
+ try: # pragma: no cover - optional dependency
34
+ import mlflow
35
+ except ModuleNotFoundError: # pragma: no cover - optional dependency
36
+ self._mlflow = None
37
+ else: # pragma: no cover - optional dependency
38
+ self._mlflow = mlflow
39
+ if tracking_dir is not None:
40
+ tracking_path = Path(tracking_dir).resolve()
41
+ tracking_path.mkdir(parents=True, exist_ok=True)
42
+ mlflow.set_tracking_uri(f"file:{tracking_path}")
43
+ self._active_run = mlflow.start_run(run_name=run_name)
44
+
45
+ async def __call__(self, event: FlowEvent) -> None:
46
+ self.events.append(event)
47
+
48
+ if self._mlflow is None:
49
+ if not self._warned:
50
+ print("MLflow not installed; capturing events locally only.")
51
+ self._warned = True
52
+ metrics = event.metric_samples()
53
+ print(
54
+ f"{event.event_type} | node={event.node_name} | metrics={metrics}"
55
+ )
56
+ return
57
+
58
+ assert self._mlflow is not None # for type-checkers
59
+ self._mlflow.set_tags(event.tag_values())
60
+ metrics = event.metric_samples()
61
+ for key, value in metrics.items():
62
+ self._mlflow.log_metric(key, value, step=int(event.ts * 1000))
63
+
64
+ def close(self) -> None:
65
+ if getattr(self, "_mlflow", None) is not None:
66
+ self._mlflow.end_run()
67
+
68
+
69
+ def build_flow(middleware: MlflowMiddleware) -> tuple[PenguiFlow, Node]:
70
+ """Assemble the demo flow and attach the provided middleware."""
71
+
72
+ async def prepare(message: Message, _ctx) -> Message:
73
+ meta = dict(message.meta)
74
+ meta["prepared"] = True
75
+ cleaned = str(message.payload).strip()
76
+ return message.model_copy(update={"payload": cleaned, "meta": meta})
77
+
78
+ async def score(message: Message, _ctx) -> Message:
79
+ await asyncio.sleep(0.05)
80
+ meta = dict(message.meta)
81
+ meta["score"] = len(str(message.payload))
82
+ enriched = str(message.payload).upper()
83
+ return message.model_copy(update={"payload": enriched, "meta": meta})
84
+
85
+ async def respond(message: Message, _ctx) -> Message:
86
+ final = FinalAnswer(text=f"Tracked: {message.payload}", citations=["mlflow"])
87
+ return message.model_copy(update={"payload": final})
88
+
89
+ prepare_node = Node(prepare, name="prepare", policy=NodePolicy(validate="none"))
90
+ score_node = Node(score, name="score", policy=NodePolicy(validate="none"))
91
+ respond_node = Node(respond, name="respond", policy=NodePolicy(validate="none"))
92
+
93
+ flow = create(prepare_node.to(score_node), score_node.to(respond_node))
94
+ flow.add_middleware(middleware)
95
+ return flow, respond_node
96
+
97
+
98
+ async def main() -> None:
99
+ middleware = MlflowMiddleware(tracking_dir=Path(__file__).parent / "mlruns")
100
+ flow, _ = build_flow(middleware)
101
+ flow.run()
102
+
103
+ headers = Headers(tenant="demo", topic="metrics")
104
+ message = Message(payload="observe penguiflow", headers=headers)
105
+
106
+ await flow.emit(message)
107
+ result = await flow.fetch()
108
+ await flow.stop()
109
+ middleware.close()
110
+
111
+ if isinstance(result, Message) and isinstance(result.payload, FinalAnswer):
112
+ print(f"Final answer: {result.payload.text}")
113
+ else:
114
+ print(f"Result: {result}")
115
+
116
+ print(f"Captured {len(middleware.events)} events.")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ asyncio.run(main())
@@ -0,0 +1,61 @@
1
+ """Playbook-driven retrieval and compression example."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from penguiflow import Headers, Message, Node, NodePolicy, create
9
+
10
+
11
+ def build_retrieval_playbook() -> tuple[Any, Any]:
12
+ async def retrieve(msg: Message, ctx) -> Message:
13
+ query = msg.payload["query"]
14
+ docs = [f"{query}-doc-{i}" for i in range(1, 4)]
15
+ return msg.model_copy(update={"payload": docs})
16
+
17
+ async def rerank(msg: Message, ctx) -> Message:
18
+ reranked = sorted(msg.payload, key=len)
19
+ return msg.model_copy(update={"payload": reranked})
20
+
21
+ async def compress(msg: Message, ctx) -> Message:
22
+ summary = f"{msg.payload[0]} :: compressed"
23
+ return msg.model_copy(update={"payload": summary})
24
+
25
+ retrieve_node = Node(retrieve, name="retrieve", policy=NodePolicy(validate="none"))
26
+ rerank_node = Node(rerank, name="rerank", policy=NodePolicy(validate="none"))
27
+ compress_node = Node(compress, name="compress", policy=NodePolicy(validate="none"))
28
+
29
+ flow = create(
30
+ retrieve_node.to(rerank_node),
31
+ rerank_node.to(compress_node),
32
+ compress_node.to(),
33
+ )
34
+ return flow, None
35
+
36
+
37
+ async def controller(msg: Message, ctx) -> Message:
38
+ request = msg.model_copy(update={"payload": {"query": msg.payload}})
39
+ summary = await ctx.call_playbook(build_retrieval_playbook, request)
40
+ return msg.model_copy(update={"payload": summary})
41
+
42
+
43
+ async def main() -> None:
44
+ controller_node = Node(
45
+ controller,
46
+ name="controller",
47
+ policy=NodePolicy(validate="none"),
48
+ )
49
+ flow = create(controller_node.to())
50
+ flow.run()
51
+
52
+ message = Message(payload="antarctic krill", headers=Headers(tenant="acme"))
53
+ await flow.emit(message)
54
+ final = await flow.fetch()
55
+ print(final.payload)
56
+
57
+ await flow.stop()
58
+
59
+
60
+ if __name__ == "__main__": # pragma: no cover
61
+ asyncio.run(main())
File without changes
@@ -0,0 +1,71 @@
1
+ """Typed quickstart example for PenguiFlow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from penguiflow import ModelRegistry, Node, NodePolicy, create
10
+
11
+
12
+ class TriageIn(BaseModel):
13
+ text: str
14
+
15
+
16
+ class TriageOut(BaseModel):
17
+ text: str
18
+ topic: str
19
+
20
+
21
+ class RetrieveOut(BaseModel):
22
+ topic: str
23
+ docs: list[str]
24
+
25
+
26
+ class PackOut(BaseModel):
27
+ prompt: str
28
+
29
+
30
+ async def triage(msg: TriageIn, ctx) -> TriageOut:
31
+ topic = "metrics" if "metric" in msg.text else "general"
32
+ return TriageOut(text=msg.text, topic=topic)
33
+
34
+
35
+ async def retrieve(msg: TriageOut, ctx) -> RetrieveOut:
36
+ docs = [f"doc_{i}_{msg.topic}" for i in range(2)]
37
+ return RetrieveOut(topic=msg.topic, docs=docs)
38
+
39
+
40
+ async def pack(msg: RetrieveOut, ctx) -> PackOut:
41
+ prompt = f"[{msg.topic}] summarize {len(msg.docs)} docs"
42
+ return PackOut(prompt=prompt)
43
+
44
+
45
+ async def main() -> None:
46
+ triage_node = Node(triage, name="triage", policy=NodePolicy(validate="both"))
47
+ retrieve_node = Node(retrieve, name="retrieve", policy=NodePolicy(validate="both"))
48
+ pack_node = Node(pack, name="pack", policy=NodePolicy(validate="both"))
49
+
50
+ registry = ModelRegistry()
51
+ registry.register("triage", TriageIn, TriageOut)
52
+ registry.register("retrieve", TriageOut, RetrieveOut)
53
+ registry.register("pack", RetrieveOut, PackOut)
54
+
55
+ flow = create(
56
+ triage_node.to(retrieve_node),
57
+ retrieve_node.to(pack_node),
58
+ )
59
+ flow.run(registry=registry)
60
+
61
+ payload = TriageIn(text="show marketing metrics")
62
+
63
+ await flow.emit(payload)
64
+ result = await flow.fetch()
65
+ print(result.prompt)
66
+
67
+ await flow.stop()
68
+
69
+
70
+ if __name__ == "__main__": # pragma: no cover - example entrypoint
71
+ asyncio.run(main())
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from penguiflow.catalog import build_catalog, tool
11
+ from penguiflow.node import Node
12
+ from penguiflow.planner import ReactPlanner
13
+ from penguiflow.registry import ModelRegistry
14
+
15
+
16
+ class Question(BaseModel):
17
+ text: str
18
+
19
+
20
+ class Intent(BaseModel):
21
+ intent: str
22
+
23
+
24
+ class Documents(BaseModel):
25
+ documents: list[str]
26
+
27
+
28
+ class FinalAnswer(BaseModel):
29
+ answer: str
30
+
31
+
32
+ @tool(desc="Detect the caller intent", tags=["planner"])
33
+ async def triage(args: Question, ctx: object) -> Intent:
34
+ return Intent(intent="docs")
35
+
36
+
37
+ @tool(desc="Retrieve supporting documents", side_effects="read")
38
+ async def retrieve(args: Intent, ctx: object) -> Documents:
39
+ return Documents(documents=[f"PenguiFlow remains lightweight for {args.intent}"])
40
+
41
+
42
+ @tool(desc="Summarise retrieved documents")
43
+ async def summarise(args: FinalAnswer, ctx: object) -> FinalAnswer:
44
+ return args
45
+
46
+
47
+ class SequenceLLM:
48
+ """Deterministic stub that returns pre-authored planner actions."""
49
+
50
+ def __init__(self, responses: list[Mapping[str, Any]]) -> None:
51
+ self._responses = [json.dumps(item) for item in responses]
52
+
53
+ async def complete(
54
+ self,
55
+ *,
56
+ messages: list[Mapping[str, str]],
57
+ response_format: Mapping[str, Any] | None = None,
58
+ ) -> str:
59
+ del messages, response_format
60
+ if not self._responses:
61
+ raise RuntimeError("SequenceLLM has no responses left")
62
+ return self._responses.pop(0)
63
+
64
+
65
+ async def main() -> None:
66
+ registry = ModelRegistry()
67
+ registry.register("triage", Question, Intent)
68
+ registry.register("retrieve", Intent, Documents)
69
+ registry.register("summarise", FinalAnswer, FinalAnswer)
70
+
71
+ nodes = [
72
+ Node(triage, name="triage"),
73
+ Node(retrieve, name="retrieve"),
74
+ Node(summarise, name="summarise"),
75
+ ]
76
+
77
+ client = SequenceLLM(
78
+ [
79
+ {
80
+ "thought": "triage",
81
+ "next_node": "triage",
82
+ "args": {"text": "How does PenguiFlow stay lightweight?"},
83
+ },
84
+ {
85
+ "thought": "retrieve",
86
+ "next_node": "retrieve",
87
+ "args": {"intent": "docs"},
88
+ },
89
+ {
90
+ "thought": "wrap up",
91
+ "next_node": None,
92
+ "args": {
93
+ "answer": "PenguiFlow uses async orchestration and minimal deps."
94
+ },
95
+ },
96
+ ]
97
+ )
98
+
99
+ planner = ReactPlanner(
100
+ llm_client=client,
101
+ catalog=build_catalog(nodes, registry),
102
+ )
103
+
104
+ result = await planner.run("Explain PenguiFlow's lightweight design")
105
+ print(json.dumps(result.model_dump(), indent=2, ensure_ascii=False))
106
+
107
+
108
+ if __name__ == "__main__":
109
+ asyncio.run(main())