penguiflow 2.2.5__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.
- examples/planner_enterprise_agent/__init__.py +30 -0
- examples/planner_enterprise_agent/config.py +93 -0
- examples/planner_enterprise_agent/main.py +709 -0
- examples/planner_enterprise_agent/nodes.py +882 -0
- examples/planner_enterprise_agent/telemetry.py +245 -0
- penguiflow/__init__.py +1 -1
- penguiflow/planner/__init__.py +6 -0
- penguiflow/planner/dspy_client.py +327 -0
- penguiflow/planner/react.py +465 -52
- penguiflow/remote.py +2 -2
- penguiflow/state.py +1 -1
- {penguiflow-2.2.5.dist-info → penguiflow-2.2.6.dist-info}/METADATA +2 -1
- {penguiflow-2.2.5.dist-info → penguiflow-2.2.6.dist-info}/RECORD +17 -11
- {penguiflow-2.2.5.dist-info → penguiflow-2.2.6.dist-info}/WHEEL +0 -0
- {penguiflow-2.2.5.dist-info → penguiflow-2.2.6.dist-info}/entry_points.txt +0 -0
- {penguiflow-2.2.5.dist-info → penguiflow-2.2.6.dist-info}/licenses/LICENSE +0 -0
- {penguiflow-2.2.5.dist-info → penguiflow-2.2.6.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
]
|