penguiflow 2.2.4__py3-none-any.whl → 2.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of penguiflow might be problematic. Click here for more details.

@@ -0,0 +1,882 @@
1
+ """Planner-compatible nodes with comprehensive type safety and observability.
2
+
3
+ All nodes follow PenguiFlow's production patterns:
4
+ * Typed Pydantic contracts for planner compatibility
5
+ * Status updates emitted through the shared status publisher
6
+ * FlowError semantics for deterministic error reporting
7
+ * Subflow wrappers that preserve envelopes and telemetry hooks
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections.abc import Callable, MutableMapping, Sequence
15
+ from types import SimpleNamespace
16
+ from typing import Any, Literal
17
+ from uuid import uuid4
18
+
19
+ from pydantic import BaseModel, Field
20
+
21
+ from examples.planner_enterprise_agent.telemetry import AgentTelemetry
22
+ from penguiflow import (
23
+ Headers,
24
+ Message,
25
+ ModelRegistry,
26
+ Node,
27
+ PenguiFlow,
28
+ call_playbook,
29
+ create,
30
+ log_flow_events,
31
+ )
32
+ from penguiflow.catalog import tool
33
+ from penguiflow.errors import FlowError
34
+
35
+ logger = logging.getLogger("penguiflow.examples.planner_enterprise")
36
+
37
+
38
+ # ============================================================================
39
+ # Pydantic Models (Shared Contracts)
40
+ # ============================================================================
41
+
42
+
43
+ class UserQuery(BaseModel):
44
+ """User's input question or task."""
45
+
46
+ text: str
47
+ tenant_id: str = "default"
48
+
49
+
50
+ class RouteDecision(BaseModel):
51
+ """Router's classification of query intent."""
52
+
53
+ query: UserQuery
54
+ route: Literal["documents", "bug", "general"]
55
+ confidence: float = Field(ge=0.0, le=1.0)
56
+ reason: str
57
+
58
+
59
+ class RoadmapStep(BaseModel):
60
+ """UI progress indicator for multi-step workflows."""
61
+
62
+ id: int
63
+ name: str
64
+ description: str
65
+ status: Literal["pending", "running", "ok", "error"] = "pending"
66
+
67
+
68
+ class DocumentState(BaseModel):
69
+ """Accumulated state for document analysis workflow."""
70
+
71
+ query: UserQuery
72
+ route: Literal["documents"] = "documents"
73
+ roadmap: list[RoadmapStep]
74
+ sources: list[str] = Field(default_factory=list)
75
+ metadata: list[dict[str, Any]] = Field(default_factory=list)
76
+ summary: str | None = None
77
+
78
+
79
+ class BugState(BaseModel):
80
+ """Accumulated state for bug triage workflow."""
81
+
82
+ query: UserQuery
83
+ route: Literal["bug"] = "bug"
84
+ roadmap: list[RoadmapStep]
85
+ logs: list[str] = Field(default_factory=list)
86
+ diagnostics: dict[str, str] = Field(default_factory=dict)
87
+ recommendation: str | None = None
88
+
89
+
90
+ class FinalAnswer(BaseModel):
91
+ """Unified final response across all routes."""
92
+
93
+ text: str
94
+ route: str
95
+ artifacts: dict[str, Any] = Field(default_factory=dict)
96
+ metadata: dict[str, Any] = Field(default_factory=dict)
97
+
98
+
99
+ class StatusUpdate(BaseModel):
100
+ """Structured status update for frontend websocket."""
101
+
102
+ status: Literal["thinking", "ok", "error"]
103
+ message: str | None = None
104
+ roadmap_step_id: int | None = None
105
+ roadmap_step_status: Literal["running", "ok", "error"] | None = None
106
+ roadmap: list[RoadmapStep] | None = None
107
+
108
+
109
+ # Roadmap templates
110
+ DOCUMENT_ROADMAP = [
111
+ RoadmapStep(id=1, name="Parse files", description="Enumerate candidate documents"),
112
+ RoadmapStep(id=2, name="Extract metadata", description="Analyze files in parallel"),
113
+ RoadmapStep(id=3, name="Generate summary", description="Produce analysis summary"),
114
+ RoadmapStep(id=4, name="Render report", description="Assemble structured output"),
115
+ ]
116
+
117
+ BUG_ROADMAP = [
118
+ RoadmapStep(id=10, name="Collect logs", description="Gather error context"),
119
+ RoadmapStep(id=11, name="Run diagnostics", description="Execute validation checks"),
120
+ RoadmapStep(id=12, name="Recommend fix", description="Propose remediation steps"),
121
+ ]
122
+
123
+
124
+ StatusPublisher = Callable[[StatusUpdate], None]
125
+
126
+
127
+ # ============================================================================
128
+ # Helper utilities
129
+ # ============================================================================
130
+
131
+
132
+ def _resolve_meta(source: Any) -> MutableMapping[str, Any] | None:
133
+ """Return mutable metadata mapping from planner or flow context."""
134
+
135
+ if isinstance(source, MutableMapping):
136
+ return source
137
+
138
+ meta = getattr(source, "meta", None)
139
+ if isinstance(meta, MutableMapping):
140
+ return meta
141
+ return None
142
+
143
+
144
+ def _trace_id_from_ctx(source: Any) -> str | None:
145
+ meta = _resolve_meta(source)
146
+ if not meta:
147
+ return None
148
+ trace_id = meta.get("trace_id")
149
+ if isinstance(trace_id, str):
150
+ return trace_id
151
+ return None
152
+
153
+
154
+ def _status_logger(meta: MutableMapping[str, Any] | None) -> logging.Logger:
155
+ if meta is None:
156
+ return logger
157
+ override = meta.get("status_logger")
158
+ if isinstance(override, logging.Logger):
159
+ return override
160
+ return logger
161
+
162
+
163
+ def _publish_status(
164
+ ctx_or_meta: Any,
165
+ *,
166
+ status: Literal["thinking", "ok", "error"],
167
+ message: str | None = None,
168
+ roadmap_step_id: int | None = None,
169
+ roadmap_step_status: Literal["running", "ok", "error"] | None = None,
170
+ roadmap: Sequence[RoadmapStep] | None = None,
171
+ ) -> None:
172
+ meta = _resolve_meta(ctx_or_meta)
173
+ if meta is None:
174
+ return
175
+
176
+ publisher = meta.get("status_publisher")
177
+ if not callable(publisher):
178
+ return
179
+
180
+ update = StatusUpdate(
181
+ status=status,
182
+ message=message,
183
+ roadmap_step_id=roadmap_step_id,
184
+ roadmap_step_status=roadmap_step_status,
185
+ roadmap=list(roadmap) if roadmap is not None else None,
186
+ )
187
+ publisher(update)
188
+
189
+ status_log = _status_logger(meta)
190
+ status_log.debug(
191
+ "status_update",
192
+ extra={
193
+ "trace_id": meta.get("trace_id"),
194
+ "status": update.status,
195
+ "message": update.message,
196
+ "step_id": update.roadmap_step_id,
197
+ "step_status": update.roadmap_step_status,
198
+ },
199
+ )
200
+
201
+
202
+ def _clone_roadmap(template: Sequence[RoadmapStep]) -> list[RoadmapStep]:
203
+ return [step.model_copy() for step in template]
204
+
205
+
206
+ def _mark_step_status(
207
+ roadmap: list[RoadmapStep],
208
+ *,
209
+ step_id: int,
210
+ status: Literal["pending", "running", "ok", "error"],
211
+ ) -> RoadmapStep | None:
212
+ for idx, step in enumerate(roadmap):
213
+ if step.id == step_id:
214
+ updated = step.model_copy(update={"status": status})
215
+ roadmap[idx] = updated
216
+ return updated
217
+ return None
218
+
219
+
220
+ def _flow_ctx(meta: MutableMapping[str, Any]) -> SimpleNamespace:
221
+ """Create a lightweight context wrapper for subflow nodes."""
222
+
223
+ return SimpleNamespace(meta=meta, logger=_status_logger(meta))
224
+
225
+
226
+ def _ensure_message(message: Any) -> Message:
227
+ if isinstance(message, Message):
228
+ return message
229
+ return Message.model_validate(message)
230
+
231
+
232
+ # ============================================================================
233
+ # Planner-Discoverable Nodes
234
+ # ============================================================================
235
+
236
+
237
+ @tool(
238
+ desc="Classify user intent and route to appropriate workflow",
239
+ tags=["planner", "routing"],
240
+ side_effects="read",
241
+ )
242
+ async def triage_query(args: UserQuery, ctx: Any) -> RouteDecision:
243
+ """Intelligent routing based on query content analysis."""
244
+ _publish_status(
245
+ ctx,
246
+ status="thinking",
247
+ message="Classifying query intent",
248
+ )
249
+
250
+ text_lower = args.text.lower()
251
+
252
+ # Pattern-based routing (in production: use LLM classifier)
253
+ if any(kw in text_lower for kw in ["bug", "error", "crash", "traceback"]):
254
+ route: Literal["documents", "bug", "general"] = "bug"
255
+ confidence = 0.95
256
+ reason = "Detected incident keywords (bug, error, crash)"
257
+ elif any(kw in text_lower for kw in ["document", "file", "report", "analyze"]):
258
+ route = "documents"
259
+ confidence = 0.90
260
+ reason = "Detected document analysis keywords"
261
+ else:
262
+ route = "general"
263
+ confidence = 0.75
264
+ reason = "General query - no specific workflow match"
265
+
266
+ _publish_status(
267
+ ctx,
268
+ status="thinking",
269
+ message=f"Routed query to {route} workflow",
270
+ )
271
+
272
+ return RouteDecision(query=args, route=route, confidence=confidence, reason=reason)
273
+
274
+
275
+ @tool(
276
+ desc="Initialize document analysis workflow with roadmap",
277
+ tags=["planner", "documents"],
278
+ side_effects="stateful",
279
+ )
280
+ async def initialize_document_workflow(args: RouteDecision, ctx: Any) -> DocumentState:
281
+ """Set up document analysis pipeline."""
282
+ if args.route != "documents":
283
+ raise FlowError(
284
+ trace_id=_trace_id_from_ctx(ctx),
285
+ node_name="init_documents",
286
+ code="INVALID_ROUTE",
287
+ message=f"Expected documents route, got {args.route}",
288
+ )
289
+
290
+ roadmap = _clone_roadmap(DOCUMENT_ROADMAP)
291
+ current = _mark_step_status(
292
+ roadmap, step_id=DOCUMENT_ROADMAP[0].id, status="running"
293
+ )
294
+ _publish_status(
295
+ ctx,
296
+ status="thinking",
297
+ message="Document workflow initialised",
298
+ roadmap_step_id=current.id if current else None,
299
+ roadmap_step_status="running",
300
+ roadmap=roadmap,
301
+ )
302
+
303
+ return DocumentState(query=args.query, roadmap=roadmap)
304
+
305
+
306
+ @tool(
307
+ desc="Parse and enumerate document sources from query context",
308
+ tags=["planner", "documents"],
309
+ side_effects="read",
310
+ )
311
+ async def parse_documents(args: DocumentState, ctx: Any) -> DocumentState:
312
+ """Extract document references from query."""
313
+ roadmap = list(args.roadmap)
314
+ current = _mark_step_status(
315
+ roadmap, step_id=DOCUMENT_ROADMAP[0].id, status="running"
316
+ )
317
+ _publish_status(
318
+ ctx,
319
+ status="thinking",
320
+ message="Parsing candidate document sources",
321
+ roadmap_step_id=current.id if current else None,
322
+ roadmap_step_status="running",
323
+ roadmap=roadmap,
324
+ )
325
+
326
+ await asyncio.sleep(0.05) # Simulate parsing
327
+
328
+ sources = [
329
+ "README.md",
330
+ "CHANGELOG.md",
331
+ "docs/architecture.md",
332
+ "docs/deployment.md",
333
+ ]
334
+
335
+ current = _mark_step_status(roadmap, step_id=DOCUMENT_ROADMAP[0].id, status="ok")
336
+ _publish_status(
337
+ ctx,
338
+ status="ok",
339
+ message="Document sources identified",
340
+ roadmap_step_id=current.id if current else None,
341
+ roadmap_step_status="ok",
342
+ roadmap=roadmap,
343
+ )
344
+
345
+ return args.model_copy(update={"sources": sources, "roadmap": roadmap})
346
+
347
+
348
+ @tool(
349
+ desc="Extract structured metadata from documents in parallel",
350
+ tags=["planner", "documents"],
351
+ side_effects="read",
352
+ latency_hint_ms=1000, # High latency,
353
+ )
354
+ async def extract_metadata(args: DocumentState, ctx: Any) -> DocumentState:
355
+ """Concurrent metadata extraction from document sources."""
356
+
357
+ async def analyze_file(source: str) -> dict[str, Any]:
358
+ await asyncio.sleep(0.02)
359
+ return {
360
+ "source": source,
361
+ "size_kb": len(source) * 100,
362
+ "last_modified": "2025-10-22",
363
+ "checksum": hash(source) % 10000,
364
+ }
365
+
366
+ roadmap = list(args.roadmap)
367
+ current = _mark_step_status(
368
+ roadmap, step_id=DOCUMENT_ROADMAP[1].id, status="running"
369
+ )
370
+ _publish_status(
371
+ ctx,
372
+ status="thinking",
373
+ message="Extracting document metadata",
374
+ roadmap_step_id=current.id if current else None,
375
+ roadmap_step_status="running",
376
+ roadmap=roadmap,
377
+ )
378
+
379
+ metadata = []
380
+ for source in args.sources:
381
+ meta = await analyze_file(source)
382
+ metadata.append(meta)
383
+
384
+ current = _mark_step_status(roadmap, step_id=DOCUMENT_ROADMAP[1].id, status="ok")
385
+ _publish_status(
386
+ ctx,
387
+ status="ok",
388
+ message="Metadata extraction complete",
389
+ roadmap_step_id=current.id if current else None,
390
+ roadmap_step_status="ok",
391
+ roadmap=roadmap,
392
+ )
393
+
394
+ return args.model_copy(update={"metadata": metadata, "roadmap": roadmap})
395
+
396
+
397
+ @tool(
398
+ desc="Generate summary from extracted document metadata",
399
+ tags=["planner", "documents"],
400
+ side_effects="pure",
401
+ )
402
+ async def generate_document_summary(args: DocumentState, ctx: Any) -> DocumentState:
403
+ """Synthesize findings into natural language summary."""
404
+ roadmap = list(args.roadmap)
405
+ current = _mark_step_status(
406
+ roadmap, step_id=DOCUMENT_ROADMAP[2].id, status="running"
407
+ )
408
+ _publish_status(
409
+ ctx,
410
+ status="thinking",
411
+ message="Generating document summary",
412
+ roadmap_step_id=current.id if current else None,
413
+ roadmap_step_status="running",
414
+ roadmap=roadmap,
415
+ )
416
+
417
+ summary = (
418
+ f"Analyzed {len(args.sources)} documents. "
419
+ f"Total size: {sum(m.get('size_kb', 0) for m in args.metadata)}KB. "
420
+ f"Key files: {', '.join(args.sources[:3])}."
421
+ )
422
+
423
+ current = _mark_step_status(roadmap, step_id=DOCUMENT_ROADMAP[2].id, status="ok")
424
+ _publish_status(
425
+ ctx,
426
+ status="ok",
427
+ message="Document summary generated",
428
+ roadmap_step_id=current.id if current else None,
429
+ roadmap_step_status="ok",
430
+ roadmap=roadmap,
431
+ )
432
+
433
+ return args.model_copy(update={"summary": summary, "roadmap": roadmap})
434
+
435
+
436
+ @tool(
437
+ desc="Render final document analysis report with artifacts",
438
+ tags=["planner", "documents"],
439
+ side_effects="pure",
440
+ )
441
+ async def render_document_report(args: DocumentState, ctx: Any) -> FinalAnswer:
442
+ """Package results into structured final answer."""
443
+ roadmap = list(args.roadmap)
444
+ current = _mark_step_status(
445
+ roadmap, step_id=DOCUMENT_ROADMAP[3].id, status="running"
446
+ )
447
+ _publish_status(
448
+ ctx,
449
+ status="thinking",
450
+ message="Rendering document analysis report",
451
+ roadmap_step_id=current.id if current else None,
452
+ roadmap_step_status="running",
453
+ roadmap=roadmap,
454
+ )
455
+
456
+ roadmap_complete = all(s.status == "ok" for s in roadmap)
457
+ trace_id = _trace_id_from_ctx(ctx) or uuid4().hex
458
+
459
+ current = _mark_step_status(roadmap, step_id=DOCUMENT_ROADMAP[3].id, status="ok")
460
+ _publish_status(
461
+ ctx,
462
+ status="ok",
463
+ message="Document workflow complete",
464
+ roadmap_step_id=current.id if current else None,
465
+ roadmap_step_status="ok",
466
+ roadmap=roadmap,
467
+ )
468
+
469
+ return FinalAnswer(
470
+ text=args.summary or "No summary available",
471
+ route="documents",
472
+ artifacts={
473
+ "sources": args.sources,
474
+ "metadata": args.metadata,
475
+ },
476
+ metadata={
477
+ "source_count": len(args.sources),
478
+ "roadmap_complete": roadmap_complete,
479
+ "trace_id": trace_id,
480
+ },
481
+ )
482
+
483
+
484
+ @tool(
485
+ desc="Initialize bug triage workflow with diagnostic roadmap",
486
+ tags=["planner", "bugs"],
487
+ side_effects="stateful",
488
+ )
489
+ async def initialize_bug_workflow(args: RouteDecision, ctx: Any) -> BugState:
490
+ """Set up bug triage pipeline."""
491
+ if args.route != "bug":
492
+ raise FlowError(
493
+ trace_id=_trace_id_from_ctx(ctx),
494
+ node_name="init_bug",
495
+ code="INVALID_ROUTE",
496
+ message=f"Expected bug route, got {args.route}",
497
+ )
498
+
499
+ roadmap = _clone_roadmap(BUG_ROADMAP)
500
+ current = _mark_step_status(roadmap, step_id=BUG_ROADMAP[0].id, status="running")
501
+ _publish_status(
502
+ ctx,
503
+ status="thinking",
504
+ message="Bug triage workflow initialised",
505
+ roadmap_step_id=current.id if current else None,
506
+ roadmap_step_status="running",
507
+ roadmap=roadmap,
508
+ )
509
+
510
+ return BugState(query=args.query, roadmap=roadmap)
511
+
512
+
513
+ @tool(
514
+ desc="Collect error logs and stack traces from system",
515
+ tags=["planner", "bugs"],
516
+ side_effects="read",
517
+ )
518
+ async def collect_error_logs(args: BugState, ctx: Any) -> BugState:
519
+ """Gather diagnostic logs from error context."""
520
+ roadmap = list(args.roadmap)
521
+ current = _mark_step_status(roadmap, step_id=BUG_ROADMAP[0].id, status="running")
522
+ _publish_status(
523
+ ctx,
524
+ status="thinking",
525
+ message="Collecting error logs",
526
+ roadmap_step_id=current.id if current else None,
527
+ roadmap_step_status="running",
528
+ roadmap=roadmap,
529
+ )
530
+
531
+ logs = [
532
+ "ERROR: ValueError: Invalid configuration",
533
+ "Traceback (most recent call last):",
534
+ ' File "app.py", line 42, in process',
535
+ " validate_config(settings)",
536
+ "ValueError: Missing required field: api_key",
537
+ ]
538
+
539
+ current = _mark_step_status(roadmap, step_id=BUG_ROADMAP[0].id, status="ok")
540
+ _publish_status(
541
+ ctx,
542
+ status="ok",
543
+ message="Logs collected",
544
+ roadmap_step_id=current.id if current else None,
545
+ roadmap_step_status="ok",
546
+ roadmap=roadmap,
547
+ )
548
+
549
+ return args.model_copy(update={"logs": logs, "roadmap": roadmap})
550
+
551
+
552
+ @tool(
553
+ desc="Run automated diagnostics and health checks",
554
+ tags=["planner", "bugs"],
555
+ side_effects="external",
556
+ latency_hint_ms=1000, # High latency,
557
+ )
558
+ async def run_diagnostics(args: BugState, ctx: Any) -> BugState:
559
+ """Execute validation suite to isolate failure."""
560
+ roadmap = list(args.roadmap)
561
+ current = _mark_step_status(roadmap, step_id=BUG_ROADMAP[1].id, status="running")
562
+ _publish_status(
563
+ ctx,
564
+ status="thinking",
565
+ message="Running automated diagnostics",
566
+ roadmap_step_id=current.id if current else None,
567
+ roadmap_step_status="running",
568
+ roadmap=roadmap,
569
+ )
570
+
571
+ await asyncio.sleep(0.1) # Simulate diagnostic execution
572
+
573
+ diagnostics = {
574
+ "api_health": "degraded",
575
+ "database": "ok",
576
+ "cache": "ok",
577
+ "config_validation": "failed",
578
+ }
579
+
580
+ current = _mark_step_status(roadmap, step_id=BUG_ROADMAP[1].id, status="ok")
581
+ _publish_status(
582
+ ctx,
583
+ status="ok",
584
+ message="Diagnostics captured",
585
+ roadmap_step_id=current.id if current else None,
586
+ roadmap_step_status="ok",
587
+ roadmap=roadmap,
588
+ )
589
+
590
+ return args.model_copy(update={"diagnostics": diagnostics, "roadmap": roadmap})
591
+
592
+
593
+ @tool(
594
+ desc="Analyze diagnostics and recommend remediation steps",
595
+ tags=["planner", "bugs"],
596
+ side_effects="pure",
597
+ )
598
+ async def recommend_bug_fix(args: BugState, ctx: Any) -> FinalAnswer:
599
+ """Generate actionable fix recommendation."""
600
+ roadmap = list(args.roadmap)
601
+ current = _mark_step_status(roadmap, step_id=BUG_ROADMAP[2].id, status="running")
602
+ _publish_status(
603
+ ctx,
604
+ status="thinking",
605
+ message="Preparing remediation advice",
606
+ roadmap_step_id=current.id if current else None,
607
+ roadmap_step_status="running",
608
+ roadmap=roadmap,
609
+ )
610
+
611
+ failed_checks = [
612
+ k for k, v in args.diagnostics.items() if v in ("failed", "degraded")
613
+ ]
614
+
615
+ recommendation = (
616
+ f"Root cause: Configuration validation failure. "
617
+ f"Failed checks: {', '.join(failed_checks)}. "
618
+ f"Action: Review environment variables and ensure api_key is set."
619
+ )
620
+
621
+ current = _mark_step_status(roadmap, step_id=BUG_ROADMAP[2].id, status="ok")
622
+ _publish_status(
623
+ ctx,
624
+ status="ok",
625
+ message="Bug remediation plan ready",
626
+ roadmap_step_id=current.id if current else None,
627
+ roadmap_step_status="ok",
628
+ roadmap=roadmap,
629
+ )
630
+
631
+ trace_id = _trace_id_from_ctx(ctx) or uuid4().hex
632
+
633
+ return FinalAnswer(
634
+ text=recommendation,
635
+ route="bug",
636
+ artifacts={
637
+ "logs": args.logs,
638
+ "diagnostics": args.diagnostics,
639
+ },
640
+ metadata={
641
+ "failed_checks": failed_checks,
642
+ "roadmap_complete": all(s.status == "ok" for s in roadmap),
643
+ "trace_id": trace_id,
644
+ },
645
+ )
646
+
647
+
648
+ @tool(
649
+ desc="Handle simple general queries with direct LLM response",
650
+ tags=["planner", "general"],
651
+ side_effects="read",
652
+ latency_hint_ms=500, # Medium latency,
653
+ )
654
+ async def answer_general_query(args: RouteDecision, ctx: Any) -> FinalAnswer:
655
+ """Direct LLM answer for queries not requiring specialized workflows."""
656
+ _publish_status(
657
+ ctx,
658
+ status="thinking",
659
+ message="Handling general query",
660
+ )
661
+
662
+ await asyncio.sleep(0.05)
663
+
664
+ answer = (
665
+ f"I understand your query: '{args.query.text}'. "
666
+ f"This appears to be a general question. In production, this would "
667
+ f"invoke an LLM to generate a contextual response."
668
+ )
669
+
670
+ trace_id = _trace_id_from_ctx(ctx) or uuid4().hex
671
+
672
+ _publish_status(
673
+ ctx,
674
+ status="ok",
675
+ message="General response ready",
676
+ )
677
+
678
+ return FinalAnswer(
679
+ text=answer,
680
+ route="general",
681
+ metadata={"confidence": args.confidence, "trace_id": trace_id},
682
+ )
683
+
684
+
685
+ # ============================================================================
686
+ # Subflow Wrappers (Pattern A: Wrapped Multi-Node Pipeline)
687
+ # ============================================================================
688
+
689
+
690
+ async def _init_documents_flow(message: Message, ctx: Any) -> Message:
691
+ base = _ensure_message(message)
692
+ payload = base.payload
693
+ decision = (
694
+ payload
695
+ if isinstance(payload, RouteDecision)
696
+ else RouteDecision.model_validate(payload)
697
+ )
698
+ proxy_ctx = _flow_ctx(base.meta)
699
+ state = await initialize_document_workflow(decision, proxy_ctx)
700
+ return base.model_copy(update={"payload": state})
701
+
702
+
703
+ async def _parse_documents_flow(message: Message, ctx: Any) -> Message:
704
+ base = _ensure_message(message)
705
+ payload = base.payload
706
+ state = (
707
+ payload
708
+ if isinstance(payload, DocumentState)
709
+ else DocumentState.model_validate(payload)
710
+ )
711
+ proxy_ctx = _flow_ctx(base.meta)
712
+ updated = await parse_documents(state, proxy_ctx)
713
+ return base.model_copy(update={"payload": updated})
714
+
715
+
716
+ async def _extract_metadata_flow(message: Message, ctx: Any) -> Message:
717
+ base = _ensure_message(message)
718
+ payload = base.payload
719
+ state = (
720
+ payload
721
+ if isinstance(payload, DocumentState)
722
+ else DocumentState.model_validate(payload)
723
+ )
724
+ proxy_ctx = _flow_ctx(base.meta)
725
+ updated = await extract_metadata(state, proxy_ctx)
726
+ return base.model_copy(update={"payload": updated})
727
+
728
+
729
+ async def _generate_summary_flow(message: Message, ctx: Any) -> Message:
730
+ base = _ensure_message(message)
731
+ payload = base.payload
732
+ state = (
733
+ payload
734
+ if isinstance(payload, DocumentState)
735
+ else DocumentState.model_validate(payload)
736
+ )
737
+ proxy_ctx = _flow_ctx(base.meta)
738
+ updated = await generate_document_summary(state, proxy_ctx)
739
+ return base.model_copy(update={"payload": updated})
740
+
741
+
742
+ async def _render_report_flow(message: Message, ctx: Any) -> Message:
743
+ base = _ensure_message(message)
744
+ payload = base.payload
745
+ state = (
746
+ payload
747
+ if isinstance(payload, DocumentState)
748
+ else DocumentState.model_validate(payload)
749
+ )
750
+ proxy_ctx = _flow_ctx(base.meta)
751
+ final = await render_document_report(state, proxy_ctx)
752
+ return base.model_copy(update={"payload": final})
753
+
754
+
755
+ def build_document_analysis_subflow(
756
+ telemetry: AgentTelemetry | None = None,
757
+ ) -> tuple[PenguiFlow, ModelRegistry]:
758
+ """Build a 5-node subflow for document analysis."""
759
+
760
+ init_node = Node(_init_documents_flow, name="init_documents")
761
+ parse_node = Node(_parse_documents_flow, name="parse_documents")
762
+ extract_node = Node(_extract_metadata_flow, name="extract_metadata")
763
+ summarize_node = Node(_generate_summary_flow, name="generate_summary")
764
+ render_node = Node(_render_report_flow, name="render_report")
765
+
766
+ flow = create(
767
+ init_node.to(parse_node),
768
+ parse_node.to(extract_node),
769
+ extract_node.to(summarize_node),
770
+ summarize_node.to(render_node),
771
+ render_node.to(),
772
+ )
773
+
774
+ registry = ModelRegistry()
775
+ registry.register("init_documents", Message, Message)
776
+ registry.register("parse_documents", Message, Message)
777
+ registry.register("extract_metadata", Message, Message)
778
+ registry.register("generate_summary", Message, Message)
779
+ registry.register("render_report", Message, Message)
780
+
781
+ status_log = logging.getLogger("penguiflow.examples.document_flow")
782
+ flow.add_middleware(log_flow_events(status_log))
783
+ if telemetry is not None:
784
+ flow.add_middleware(telemetry.record_flow_event)
785
+
786
+ return flow, registry
787
+
788
+
789
+ @tool(
790
+ desc=(
791
+ "Complete document analysis pipeline "
792
+ "(parse, extract metadata, summarize, render report)"
793
+ ),
794
+ tags=["planner", "documents", "subflow"],
795
+ side_effects="read",
796
+ latency_hint_ms=2000, # Entire pipeline latency
797
+ cost_hint="medium", # Multiple internal operations
798
+ )
799
+ async def analyze_documents_pipeline(args: RouteDecision, ctx: Any) -> FinalAnswer:
800
+ """Execute complete document analysis workflow as a single operation."""
801
+ if args.route != "documents":
802
+ raise FlowError(
803
+ trace_id=_trace_id_from_ctx(ctx),
804
+ node_name="analyze_documents",
805
+ code="INVALID_ROUTE",
806
+ message=f"Expected documents route, got {args.route}",
807
+ )
808
+
809
+ meta = _resolve_meta(ctx) or {}
810
+ telemetry = meta.get("telemetry")
811
+
812
+ def _playbook() -> tuple[PenguiFlow, ModelRegistry]:
813
+ return build_document_analysis_subflow(telemetry)
814
+
815
+ trace_id = meta.get("trace_id")
816
+ if not isinstance(trace_id, str):
817
+ trace_id = uuid4().hex
818
+
819
+ headers = Headers(tenant=args.query.tenant_id, topic="documents")
820
+ message_meta = dict(meta)
821
+ message_meta.setdefault("route", "documents")
822
+
823
+ message = Message(
824
+ payload=args,
825
+ headers=headers,
826
+ trace_id=trace_id,
827
+ meta=message_meta,
828
+ )
829
+
830
+ try:
831
+ result = await call_playbook(_playbook, message)
832
+ except Exception as exc: # pragma: no cover - defensive
833
+ _publish_status(
834
+ message_meta,
835
+ status="error",
836
+ message="Document workflow failed",
837
+ roadmap_step_status="error",
838
+ )
839
+ raise FlowError(
840
+ trace_id=trace_id,
841
+ node_name="analyze_documents",
842
+ code="DOCUMENT_PIPELINE_FAILED",
843
+ message=str(exc) or exc.__class__.__name__,
844
+ original_exc=exc,
845
+ ) from exc
846
+
847
+ if not isinstance(result, FinalAnswer):
848
+ raise FlowError(
849
+ trace_id=trace_id,
850
+ node_name="analyze_documents",
851
+ code="DOCUMENT_PIPELINE_INVALID_OUTPUT",
852
+ message=f"Subflow returned {type(result).__name__}",
853
+ )
854
+
855
+ metadata = dict(result.metadata)
856
+ metadata.setdefault("trace_id", trace_id)
857
+ return result.model_copy(update={"metadata": metadata})
858
+
859
+
860
+ __all__ = [
861
+ "UserQuery",
862
+ "RouteDecision",
863
+ "DocumentState",
864
+ "BugState",
865
+ "FinalAnswer",
866
+ "StatusUpdate",
867
+ "DOCUMENT_ROADMAP",
868
+ "BUG_ROADMAP",
869
+ "triage_query",
870
+ "initialize_document_workflow",
871
+ "parse_documents",
872
+ "extract_metadata",
873
+ "generate_document_summary",
874
+ "render_document_report",
875
+ "initialize_bug_workflow",
876
+ "collect_error_logs",
877
+ "run_diagnostics",
878
+ "recommend_bug_fix",
879
+ "answer_general_query",
880
+ "analyze_documents_pipeline",
881
+ "build_document_analysis_subflow",
882
+ ]