penguiflow 2.2.3__tar.gz → 2.2.4__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.2.3 → penguiflow-2.2.4}/PKG-INFO +4 -1
- {penguiflow-2.2.3 → penguiflow-2.2.4}/README.md +3 -0
- penguiflow-2.2.4/examples/__init__.py +0 -0
- penguiflow-2.2.4/examples/controller_multihop/__init__.py +0 -0
- penguiflow-2.2.4/examples/controller_multihop/flow.py +54 -0
- penguiflow-2.2.4/examples/fanout_join/__init__.py +0 -0
- penguiflow-2.2.4/examples/fanout_join/flow.py +54 -0
- penguiflow-2.2.4/examples/map_concurrent/__init__.py +0 -0
- penguiflow-2.2.4/examples/map_concurrent/flow.py +56 -0
- penguiflow-2.2.4/examples/metadata_propagation/flow.py +61 -0
- penguiflow-2.2.4/examples/mlflow_metrics/__init__.py +1 -0
- penguiflow-2.2.4/examples/mlflow_metrics/flow.py +120 -0
- penguiflow-2.2.4/examples/playbook_retrieval/__init__.py +0 -0
- penguiflow-2.2.4/examples/playbook_retrieval/flow.py +61 -0
- penguiflow-2.2.4/examples/quickstart/__init__.py +0 -0
- penguiflow-2.2.4/examples/quickstart/flow.py +74 -0
- penguiflow-2.2.4/examples/react_minimal/main.py +109 -0
- penguiflow-2.2.4/examples/react_parallel/main.py +121 -0
- penguiflow-2.2.4/examples/react_pause_resume/main.py +157 -0
- penguiflow-2.2.4/examples/react_replan/main.py +133 -0
- penguiflow-2.2.4/examples/reliability_middleware/__init__.py +0 -0
- penguiflow-2.2.4/examples/reliability_middleware/flow.py +67 -0
- penguiflow-2.2.4/examples/roadmap_status_updates/__init__.py +0 -0
- penguiflow-2.2.4/examples/roadmap_status_updates/flow.py +640 -0
- penguiflow-2.2.4/examples/roadmap_status_updates_subflows/__init__.py +0 -0
- penguiflow-2.2.4/examples/roadmap_status_updates_subflows/flow.py +814 -0
- penguiflow-2.2.4/examples/routing_policy/__init__.py +0 -0
- penguiflow-2.2.4/examples/routing_policy/flow.py +89 -0
- penguiflow-2.2.4/examples/routing_predicate/__init__.py +0 -0
- penguiflow-2.2.4/examples/routing_predicate/flow.py +51 -0
- penguiflow-2.2.4/examples/routing_union/__init__.py +0 -0
- penguiflow-2.2.4/examples/routing_union/flow.py +56 -0
- penguiflow-2.2.4/examples/status_roadmap_flow/__init__.py +0 -0
- penguiflow-2.2.4/examples/status_roadmap_flow/flow.py +458 -0
- penguiflow-2.2.4/examples/streaming_llm/__init__.py +3 -0
- penguiflow-2.2.4/examples/streaming_llm/flow.py +77 -0
- penguiflow-2.2.4/examples/testkit_demo/flow.py +34 -0
- penguiflow-2.2.4/examples/trace_cancel/flow.py +78 -0
- penguiflow-2.2.4/examples/traceable_errors/flow.py +51 -0
- penguiflow-2.2.4/examples/visualizer/flow.py +49 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/__init__.py +1 -1
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow.egg-info/PKG-INFO +4 -1
- penguiflow-2.2.4/penguiflow.egg-info/SOURCES.txt +95 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow.egg-info/top_level.txt +1 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/pyproject.toml +2 -2
- penguiflow-2.2.4/tests/test_examples_roadmap.py +110 -0
- penguiflow-2.2.3/penguiflow.egg-info/SOURCES.txt +0 -56
- {penguiflow-2.2.3 → penguiflow-2.2.4}/LICENSE +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/admin.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/bus.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/catalog.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/core.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/debug.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/errors.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/metrics.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/middlewares.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/node.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/patterns.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/planner/__init__.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/planner/prompts.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/planner/react.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/policies.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/registry.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/remote.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/state.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/streaming.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/testkit.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/types.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow/viz.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow.egg-info/dependency_links.txt +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow.egg-info/entry_points.txt +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow.egg-info/requires.txt +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow_a2a/__init__.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/penguiflow_a2a/server.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/setup.cfg +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_a2a_server.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_budgets.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_cancel.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_catalog.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_controller.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_core.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_distribution_hooks.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_errors.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_metadata.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_metrics.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_middlewares.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_node.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_patterns.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_planner_prompts.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_property_based.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_react_planner.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_registry.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_remote.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_routing_policy.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_streaming.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_testkit.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/tests/test_types.py +0 -0
- {penguiflow-2.2.3 → penguiflow-2.2.4}/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
|
+
Version: 2.2.4
|
|
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
|
|
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())
|
|
File without changes
|
|
@@ -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,74 @@
|
|
|
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 Headers, Message, 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
|
+
message = Message(
|
|
62
|
+
payload=TriageIn(text="show marketing metrics"),
|
|
63
|
+
headers=Headers(tenant="acme"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
await flow.emit(message)
|
|
67
|
+
result = await flow.fetch()
|
|
68
|
+
print(result.prompt)
|
|
69
|
+
|
|
70
|
+
await flow.stop()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__": # pragma: no cover - example entrypoint
|
|
74
|
+
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())
|