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,709 @@
1
+ """Enterprise agent orchestrator with ReactPlanner and comprehensive observability.
2
+
3
+ This is the cornerstone implementation for production agent deployments,
4
+ demonstrating:
5
+ - ReactPlanner integration with auto-discovered nodes
6
+ - Telemetry middleware for full error visibility
7
+ - Status update sinks for frontend integration
8
+ - Streaming support for progressive UI updates
9
+ - Environment-based configuration
10
+ - Enterprise-grade error handling
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import sys
20
+ from collections import defaultdict
21
+ from collections.abc import Iterator, Mapping
22
+ from typing import Any
23
+ from uuid import uuid4
24
+
25
+ from examples.planner_enterprise_agent.config import AgentConfig
26
+ from examples.planner_enterprise_agent.nodes import (
27
+ FinalAnswer,
28
+ StatusUpdate,
29
+ UserQuery,
30
+ analyze_documents_pipeline, # Pattern A: Wrapped subflow
31
+ answer_general_query,
32
+ collect_error_logs, # Pattern B: Individual node
33
+ initialize_bug_workflow, # Pattern B: Individual node
34
+ recommend_bug_fix, # Pattern B: Individual node
35
+ run_diagnostics, # Pattern B: Individual node
36
+ triage_query,
37
+ )
38
+ from examples.planner_enterprise_agent.telemetry import AgentTelemetry
39
+ from penguiflow.catalog import build_catalog
40
+ from penguiflow.node import Node
41
+ from penguiflow.planner import DSPyLLMClient, PlannerPause, ReactPlanner
42
+ from penguiflow.registry import ModelRegistry
43
+
44
+ # Global buffers for demonstration (in production: use message queue/websocket)
45
+ STATUS_BUFFER: defaultdict[str, list[StatusUpdate]] = defaultdict(list)
46
+ EXECUTION_LOGS: list[str] = []
47
+
48
+
49
+ class _SerializableContext(Mapping[str, Any]):
50
+ """Context wrapper that filters non-serializable values for JSON serialization.
51
+
52
+ This class allows passing both serializable and non-serializable objects
53
+ in context_meta. When the planner serializes context for the LLM prompt,
54
+ only JSON-serializable values are included. But nodes can still access
55
+ all values (including functions, loggers, etc.) via ctx.meta.
56
+ """
57
+
58
+ def __init__(self, data: dict[str, Any]) -> None:
59
+ self._data = data
60
+ self._serializable_keys = self._find_serializable_keys()
61
+
62
+ def _find_serializable_keys(self) -> set[str]:
63
+ """Identify which keys have JSON-serializable values."""
64
+ serializable = set()
65
+ for key, value in self._data.items():
66
+ try:
67
+ json.dumps(value)
68
+ serializable.add(key)
69
+ except (TypeError, ValueError):
70
+ # Skip non-serializable values
71
+ pass
72
+ return serializable
73
+
74
+ def __getitem__(self, key: str) -> Any:
75
+ """Allow access to all values (both serializable and non-serializable)."""
76
+ return self._data[key]
77
+
78
+ def __iter__(self) -> Iterator[str]:
79
+ """Iterate only over serializable keys (for dict() and JSON conversion)."""
80
+ return iter(self._serializable_keys)
81
+
82
+ def __len__(self) -> int:
83
+ """Return count of serializable keys."""
84
+ return len(self._serializable_keys)
85
+
86
+ def get(self, key: str, default: Any = None) -> Any:
87
+ """Get value with default (allows access to all values)."""
88
+ return self._data.get(key, default)
89
+
90
+
91
+ class EnterpriseAgentOrchestrator:
92
+ """Production-ready agent orchestrator with ReactPlanner.
93
+
94
+ This orchestrator demonstrates enterprise deployment patterns:
95
+ - Injectable telemetry for testing and monitoring
96
+ - Middleware integration for error visibility
97
+ - Event callback for planner observability
98
+ - Clean separation of concerns
99
+ - Graceful degradation and error handling
100
+
101
+ Thread Safety:
102
+ NOT thread-safe. Create separate instances per request/session.
103
+
104
+ Example:
105
+ config = AgentConfig.from_env()
106
+ agent = EnterpriseAgentOrchestrator(config)
107
+ result = await agent.execute("Analyze recent deployment logs")
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ config: AgentConfig,
113
+ *,
114
+ telemetry: AgentTelemetry | None = None,
115
+ ) -> None:
116
+ self.config = config
117
+ self.telemetry = telemetry or AgentTelemetry(config)
118
+
119
+ # Configure logging
120
+ logging.basicConfig(
121
+ level=getattr(logging, config.log_level),
122
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
123
+ )
124
+
125
+ # Build node registry
126
+ self._nodes = self._build_nodes()
127
+ self._registry = self._build_registry()
128
+
129
+ # Build planner with telemetry
130
+ self._planner = self._build_planner()
131
+
132
+ self.telemetry.logger.info(
133
+ "orchestrator_initialized",
134
+ extra={
135
+ "environment": config.environment,
136
+ "agent_name": config.agent_name,
137
+ "node_count": len(self._nodes),
138
+ },
139
+ )
140
+
141
+ def _build_nodes(self) -> list[Node]:
142
+ """Construct all planner-discoverable nodes.
143
+
144
+ This demonstrates TWO patterns for organizing workflows:
145
+
146
+ Pattern A - Wrapped Subflow (Document Analysis):
147
+ The entire document workflow is wrapped as a single tool that
148
+ internally executes a 5-node subflow. The planner sees ONE tool:
149
+ "analyze_documents_pipeline" that handles everything from parsing
150
+ to final report generation.
151
+
152
+ Benefits:
153
+ - Simpler for planner (1 tool vs 5 nodes)
154
+ - Lower LLM cost (1 decision vs 5 decisions)
155
+ - Better for deterministic pipelines
156
+ - Abstraction and reusability
157
+
158
+ Pattern B - Individual Nodes (Bug Triage):
159
+ Each step is exposed as a separate planner-discoverable node.
160
+ The planner can dynamically decide whether to collect logs,
161
+ run diagnostics, or skip steps based on context.
162
+
163
+ Benefits:
164
+ - Maximum flexibility and control
165
+ - Planner can adapt mid-workflow
166
+ - Better observability of each step
167
+ - Enables conditional/parallel execution
168
+
169
+ Use Pattern A when workflows are deterministic and linear.
170
+ Use Pattern B when workflows need dynamic decision-making.
171
+ """
172
+ return [
173
+ # Router (always individual)
174
+ Node(triage_query, name="triage_query"),
175
+ # PATTERN A: Document workflow as wrapped subflow
176
+ Node(analyze_documents_pipeline, name="analyze_documents"),
177
+ # PATTERN B: Bug workflow as individual nodes
178
+ Node(initialize_bug_workflow, name="init_bug"),
179
+ Node(collect_error_logs, name="collect_logs"),
180
+ Node(run_diagnostics, name="run_diagnostics"),
181
+ Node(recommend_bug_fix, name="recommend_fix"),
182
+ # General (individual node)
183
+ Node(answer_general_query, name="answer_general"),
184
+ ]
185
+
186
+ def _build_registry(self) -> ModelRegistry:
187
+ """Register all type mappings for validation.
188
+
189
+ Note the difference in registrations:
190
+
191
+ Pattern A (Wrapped Subflow):
192
+ analyze_documents: RouteDecision → FinalAnswer
193
+ The planner sees this as a single-step transformation.
194
+ Internally it executes 5 nodes, but externally it's one tool.
195
+
196
+ Pattern B (Individual Nodes):
197
+ init_bug: RouteDecision → BugState
198
+ collect_logs: BugState → BugState
199
+ run_diagnostics: BugState → BugState
200
+ recommend_fix: BugState → FinalAnswer
201
+ The planner sees each step and can make decisions between them.
202
+ """
203
+ registry = ModelRegistry()
204
+
205
+ # Import types
206
+ from examples.planner_enterprise_agent.nodes import (
207
+ BugState,
208
+ RouteDecision,
209
+ )
210
+
211
+ # Router
212
+ registry.register("triage_query", UserQuery, RouteDecision)
213
+
214
+ # PATTERN A: Document workflow as wrapped subflow
215
+ # Single registration: RouteDecision → FinalAnswer
216
+ # (Internal subflow nodes are NOT registered in planner catalog)
217
+ registry.register("analyze_documents", RouteDecision, FinalAnswer)
218
+
219
+ # PATTERN B: Bug workflow as individual nodes
220
+ # Each step registered separately for granular control
221
+ registry.register("init_bug", RouteDecision, BugState)
222
+ registry.register("collect_logs", BugState, BugState)
223
+ registry.register("run_diagnostics", BugState, BugState)
224
+ registry.register("recommend_fix", BugState, FinalAnswer)
225
+
226
+ # General
227
+ registry.register("answer_general", RouteDecision, FinalAnswer)
228
+
229
+ return registry
230
+
231
+ def _build_planner(self) -> ReactPlanner:
232
+ """Construct ReactPlanner with enterprise configuration."""
233
+ catalog = build_catalog(self._nodes, self._registry)
234
+
235
+ # Use DSPy for better structured output handling across providers
236
+ # DSPy is especially beneficial for models that don't support native
237
+ # JSON schema mode (like Databricks), but works well with all providers
238
+ use_dspy = self.config.llm_model.startswith("databricks/")
239
+
240
+ # Configure LLM client
241
+ llm_client = None
242
+ if use_dspy:
243
+ llm_client = DSPyLLMClient(
244
+ llm=self.config.llm_model,
245
+ temperature=self.config.llm_temperature,
246
+ max_retries=self.config.llm_max_retries,
247
+ timeout_s=self.config.llm_timeout_s,
248
+ )
249
+ self.telemetry.logger.info(
250
+ "using_dspy_client",
251
+ extra={"model": self.config.llm_model},
252
+ )
253
+
254
+ # CRITICAL: Set event_callback for planner observability
255
+ planner = ReactPlanner(
256
+ llm=self.config.llm_model,
257
+ catalog=catalog,
258
+ max_iters=self.config.planner_max_iters,
259
+ temperature=self.config.llm_temperature,
260
+ json_schema_mode=True if not use_dspy else False,
261
+ llm_client=llm_client,
262
+ token_budget=self.config.planner_token_budget,
263
+ deadline_s=self.config.planner_deadline_s,
264
+ hop_budget=self.config.planner_hop_budget,
265
+ summarizer_llm=self.config.summarizer_model,
266
+ llm_timeout_s=self.config.llm_timeout_s,
267
+ llm_max_retries=self.config.llm_max_retries,
268
+ absolute_max_parallel=self.config.planner_absolute_max_parallel,
269
+ # Wire up telemetry callback
270
+ event_callback=self.telemetry.record_planner_event,
271
+ )
272
+
273
+ self.telemetry.logger.info(
274
+ "planner_configured",
275
+ extra={
276
+ "model": self.config.llm_model,
277
+ "max_iters": self.config.planner_max_iters,
278
+ "token_budget": self.config.planner_token_budget,
279
+ },
280
+ )
281
+
282
+ return planner
283
+
284
+ async def execute(
285
+ self,
286
+ query: str,
287
+ *,
288
+ tenant_id: str = "default",
289
+ memories: list[dict[str, Any]] | None = None,
290
+ ) -> FinalAnswer:
291
+ """Execute agent planning for a user query.
292
+
293
+ Args:
294
+ query: The user's question or request
295
+ tenant_id: Tenant identifier for multi-tenancy
296
+ memories: Optional conversation history and context memories.
297
+ Each memory can be a dict with fields like:
298
+ - role: "user" | "assistant" | "system"
299
+ - content: str
300
+ - timestamp: str
301
+ - metadata: dict
302
+
303
+ Example:
304
+ >>> memories = [
305
+ ... {
306
+ ... "role": "user",
307
+ ... "content": "Deploy version 2.3.1",
308
+ ... "timestamp": "2025-10-20",
309
+ ... },
310
+ ... {"role": "assistant", "content": "Deployed successfully to prod"},
311
+ ... {"role": "system", "content": "User prefers verbose explanations"},
312
+ ... ]
313
+ >>> result = await agent.execute("What was deployed?", memories=memories)
314
+ """
315
+ trace_id = uuid4().hex
316
+ status_history = STATUS_BUFFER[trace_id]
317
+
318
+ def publish_status(update: StatusUpdate) -> None:
319
+ status_history.append(update)
320
+ message_text = update.message or ""
321
+ step_ref = (
322
+ str(update.roadmap_step_id)
323
+ if update.roadmap_step_id is not None
324
+ else "-"
325
+ )
326
+ EXECUTION_LOGS.append(
327
+ f"{trace_id}:{update.status}:{message_text}:{step_ref}"
328
+ )
329
+ self.telemetry.logger.debug(
330
+ "status_update_buffered",
331
+ extra={
332
+ "trace_id": trace_id,
333
+ "status": update.status,
334
+ "message": update.message,
335
+ "step_id": update.roadmap_step_id,
336
+ "step_status": update.roadmap_step_status,
337
+ },
338
+ )
339
+
340
+ # Use _SerializableContext to allow both serializable and non-serializable
341
+ # values. The planner will only see serializable values in the LLM prompt,
342
+ # but nodes can access all values via ctx.meta.
343
+ #
344
+ # Serializable values (sent to LLM):
345
+ # - tenant_id, query, trace_id: Basic metadata
346
+ # - memories: Conversation history and context (if provided)
347
+ # - status_history: Real-time execution updates
348
+ #
349
+ # Non-serializable values (only for nodes):
350
+ # - status_publisher: Function to publish status updates
351
+ # - telemetry: Telemetry object for metrics
352
+ # - status_logger: Logger instance
353
+ context_meta_dict = {
354
+ "tenant_id": tenant_id,
355
+ "query": query,
356
+ "trace_id": trace_id,
357
+ "status_publisher": publish_status,
358
+ "status_history": status_history,
359
+ "telemetry": self.telemetry,
360
+ "status_logger": self.telemetry.logger,
361
+ }
362
+
363
+ # Add memories if provided - these will be sent to the LLM!
364
+ if memories:
365
+ context_meta_dict["memories"] = memories
366
+
367
+ context_meta = _SerializableContext(context_meta_dict)
368
+
369
+ self.telemetry.logger.info(
370
+ "execute_start",
371
+ extra={"query": query, "tenant_id": tenant_id, "trace_id": trace_id},
372
+ )
373
+
374
+ finish: FinalAnswer | None = None
375
+
376
+ try:
377
+ planner_result = await self._planner.run(
378
+ query=query,
379
+ context_meta=context_meta,
380
+ )
381
+
382
+ if isinstance(planner_result, PlannerPause):
383
+ publish_status(
384
+ StatusUpdate(
385
+ status="thinking",
386
+ message="Planner paused awaiting external input",
387
+ )
388
+ )
389
+ finish = FinalAnswer(
390
+ text="Workflow paused awaiting external input.",
391
+ route="pause",
392
+ metadata={
393
+ "reason": planner_result.reason,
394
+ "payload": dict(planner_result.payload),
395
+ "resume_token": planner_result.resume_token,
396
+ },
397
+ )
398
+ elif planner_result.reason == "answer_complete":
399
+ final_answer = FinalAnswer.model_validate(planner_result.payload)
400
+ metadata = dict(final_answer.metadata)
401
+ planner_meta = dict(planner_result.metadata)
402
+ metadata.setdefault("trace_id", trace_id)
403
+ if planner_meta:
404
+ metadata.setdefault("planner", planner_meta)
405
+ final_answer = final_answer.model_copy(update={"metadata": metadata})
406
+ self.telemetry.logger.info(
407
+ "execute_success",
408
+ extra={
409
+ "route": final_answer.route,
410
+ "trace_id": trace_id,
411
+ "step_count": planner_meta.get("step_count", 0),
412
+ },
413
+ )
414
+ finish = final_answer
415
+ elif planner_result.reason == "no_path":
416
+ publish_status(
417
+ StatusUpdate(
418
+ status="error",
419
+ message="Planner could not find a viable path",
420
+ )
421
+ )
422
+ planner_meta = dict(planner_result.metadata)
423
+ meta = {"error": "no_path", "planner": planner_meta}
424
+ finish = FinalAnswer(
425
+ text=(
426
+ f"I couldn't complete the task. "
427
+ f"Reason: {planner_meta.get('thought', 'Unknown')}"
428
+ ),
429
+ route="error",
430
+ metadata=meta,
431
+ )
432
+ self.telemetry.logger.warning(
433
+ "execute_no_path",
434
+ extra={
435
+ "trace_id": trace_id,
436
+ "reason": planner_meta.get("thought"),
437
+ "step_count": planner_meta.get("step_count", 0),
438
+ },
439
+ )
440
+ elif planner_result.reason == "budget_exhausted":
441
+ publish_status(
442
+ StatusUpdate(
443
+ status="error",
444
+ message="Budget exhausted before completion",
445
+ )
446
+ )
447
+ planner_meta = dict(planner_result.metadata)
448
+ meta = {"error": "budget_exhausted", "planner": planner_meta}
449
+ finish = FinalAnswer(
450
+ text=(
451
+ "Task interrupted due to resource constraints. "
452
+ "Partial results may be available."
453
+ ),
454
+ route="error",
455
+ metadata=meta,
456
+ )
457
+ self.telemetry.logger.warning(
458
+ "execute_budget_exhausted",
459
+ extra={
460
+ "trace_id": trace_id,
461
+ "constraints": planner_meta.get("constraints", {}),
462
+ "step_count": planner_meta.get("step_count", 0),
463
+ },
464
+ )
465
+ else:
466
+ raise RuntimeError(
467
+ f"Unexpected planner result: {planner_result.reason}"
468
+ )
469
+
470
+ assert finish is not None
471
+ metadata = dict(finish.metadata)
472
+ metadata.setdefault("trace_id", trace_id)
473
+ finish = finish.model_copy(update={"metadata": metadata})
474
+ return finish
475
+
476
+ except Exception as exc:
477
+ self.telemetry.logger.exception(
478
+ "execute_error",
479
+ extra={
480
+ "query": query,
481
+ "tenant_id": tenant_id,
482
+ "trace_id": trace_id,
483
+ "error_class": exc.__class__.__name__,
484
+ "error_message": str(exc),
485
+ },
486
+ )
487
+ raise
488
+ finally:
489
+ if self.config.enable_telemetry:
490
+ self.telemetry.emit_collected_events()
491
+
492
+ def get_metrics(self) -> dict:
493
+ """Return current telemetry metrics."""
494
+ return dict(self.telemetry.get_metrics())
495
+
496
+ def reset_metrics(self) -> None:
497
+ """Reset telemetry counters (for testing)."""
498
+ self.telemetry.reset_metrics()
499
+
500
+
501
+ def _format_status_for_terminal(update: StatusUpdate, trace_id: str) -> str:
502
+ """Format status update for terminal display (simulating WebSocket/SSE)."""
503
+ status_icon = {
504
+ "thinking": "🤔",
505
+ "ok": "✅",
506
+ "error": "❌",
507
+ }.get(update.status, "ℹ️")
508
+
509
+ step_status_icon = {
510
+ "running": "▶️ ",
511
+ "ok": "✓ ",
512
+ "error": "✗ ",
513
+ }.get(update.roadmap_step_status or "", "")
514
+
515
+ parts = [f"{status_icon} [{update.status.upper()}]"]
516
+
517
+ if update.roadmap_step_id is not None:
518
+ parts.append(f"[Step {update.roadmap_step_id}]")
519
+
520
+ if update.roadmap_step_status:
521
+ parts.append(f"{step_status_icon}{update.roadmap_step_status}")
522
+
523
+ if update.message:
524
+ parts.append(f"{update.message}")
525
+
526
+ if update.roadmap_step_id is None and update.roadmap_step_list:
527
+ parts.append(f"Roadmap: {len(update.roadmap_step_list)} steps")
528
+
529
+ return " ".join(parts)
530
+
531
+
532
+ async def _monitor_and_stream_status(stream_enabled: bool = False) -> str | None:
533
+ """Monitor STATUS_BUFFER for new trace_ids and stream updates in real-time."""
534
+ if not stream_enabled:
535
+ return None
536
+
537
+ seen_traces = set(STATUS_BUFFER.keys())
538
+ trace_counts: dict[str, int] = {
539
+ tid: len(updates) for tid, updates in STATUS_BUFFER.items()
540
+ }
541
+
542
+ while True:
543
+ # Check for new trace IDs
544
+ current_traces = set(STATUS_BUFFER.keys())
545
+ new_traces = current_traces - seen_traces
546
+
547
+ if new_traces:
548
+ # Found a new trace - this is the one we're tracking
549
+ trace_id = list(new_traces)[0]
550
+ seen_traces.add(trace_id)
551
+ trace_counts[trace_id] = 0
552
+
553
+ # Stream updates for all active traces
554
+ for trace_id in list(current_traces):
555
+ updates = STATUS_BUFFER.get(trace_id, [])
556
+ last_count = trace_counts.get(trace_id, 0)
557
+
558
+ if len(updates) > last_count:
559
+ for update in updates[last_count:]:
560
+ formatted = _format_status_for_terminal(update, trace_id)
561
+ print(f" │ {formatted}", file=sys.stderr, flush=True)
562
+ trace_counts[trace_id] = len(updates)
563
+
564
+ await asyncio.sleep(0.05) # Check every 50ms
565
+
566
+
567
+ async def main() -> None:
568
+ """Example usage of enterprise agent."""
569
+ # Parse CLI arguments
570
+ parser = argparse.ArgumentParser(
571
+ description="Enterprise Agent with ReactPlanner",
572
+ formatter_class=argparse.RawDescriptionHelpFormatter,
573
+ epilog="""
574
+ Examples:
575
+ python main.py # Run without streaming
576
+ python main.py --stream # Show real-time status updates
577
+ python main.py --query "Analyze logs" # Custom query
578
+ """,
579
+ )
580
+ parser.add_argument(
581
+ "--stream",
582
+ action="store_true",
583
+ help="Display real-time status updates (simulates WebSocket/SSE feed)",
584
+ )
585
+ parser.add_argument(
586
+ "--query",
587
+ type=str,
588
+ help="Run a single custom query instead of examples",
589
+ )
590
+ args = parser.parse_args()
591
+
592
+ # Load environment variables from .env file
593
+ from pathlib import Path
594
+
595
+ from dotenv import load_dotenv
596
+
597
+ # Try loading from example directory first, then project root
598
+ example_dir = Path(__file__).parent
599
+ project_root = example_dir.parent.parent
600
+ env_path = example_dir / ".env"
601
+ if not env_path.exists():
602
+ env_path = project_root / ".env"
603
+ load_dotenv(env_path)
604
+
605
+ # Load configuration from environment
606
+ config = AgentConfig.from_env()
607
+
608
+ # Create orchestrator
609
+ agent = EnterpriseAgentOrchestrator(config)
610
+
611
+ # Determine which queries to run
612
+ if args.query:
613
+ queries = [args.query]
614
+ else:
615
+ queries = [
616
+ "Analyze the latest deployment logs and summarize findings",
617
+ "We're seeing a ValueError in production, help diagnose",
618
+ "What's the status of the API service?",
619
+ ]
620
+
621
+ # Example: Passing memories for context-aware planning
622
+ # In production, these would come from a conversation database
623
+ example_memories = [
624
+ {
625
+ "role": "user",
626
+ "content": "Deploy version 2.3.1 to production",
627
+ "timestamp": "2025-10-20T14:30:00Z",
628
+ },
629
+ {
630
+ "role": "assistant",
631
+ "content": "Deployed v2.3.1 to production successfully",
632
+ "timestamp": "2025-10-20T14:35:00Z",
633
+ },
634
+ {
635
+ "role": "system",
636
+ "content": "User prefers detailed explanations with code snippets",
637
+ "metadata": {"user_preference": "verbose"},
638
+ },
639
+ ]
640
+
641
+ # Start global streaming monitor if enabled
642
+ stream_task = None
643
+ if args.stream:
644
+ stream_task = asyncio.create_task(_monitor_and_stream_status(args.stream))
645
+
646
+ for i, query in enumerate(queries, 1):
647
+ print(f"\n{'=' * 80}")
648
+ print(f"Query {i}: {query}")
649
+ print("=" * 80)
650
+
651
+ if args.stream:
652
+ print("\n ┌─ Real-time Status Stream ─────────────")
653
+
654
+ try:
655
+ # Pass memories on the first query to demonstrate context awareness
656
+ memories = example_memories if i == 1 and not args.query else None
657
+ if memories:
658
+ print(f"\n[Using {len(memories)} memories for context]")
659
+
660
+ # Execute query
661
+ result = await agent.execute(query, memories=memories)
662
+
663
+ # Wait a bit for any final status updates
664
+ if stream_task and not stream_task.done():
665
+ await asyncio.sleep(0.2)
666
+
667
+ if args.stream:
668
+ print(" └───────────────────────────────────────\n")
669
+
670
+ print(f"\nRoute: {result.route}")
671
+ print(f"Answer: {result.text}")
672
+
673
+ if result.artifacts:
674
+ print("\nArtifacts:")
675
+ for key, value in result.artifacts.items():
676
+ if isinstance(value, list) and len(value) > 3:
677
+ print(f" {key}: [{len(value)} items]")
678
+ else:
679
+ print(f" {key}: {value}")
680
+
681
+ if result.metadata:
682
+ print("\nMetadata:")
683
+ for key, value in result.metadata.items():
684
+ print(f" {key}: {value}")
685
+
686
+ except Exception as exc:
687
+ if args.stream:
688
+ print(" └───────────────────────────────────────\n")
689
+ print(f"\nError: {exc.__class__.__name__}: {exc}")
690
+
691
+ # Clean up global streaming task
692
+ if stream_task and not stream_task.done():
693
+ stream_task.cancel()
694
+ try:
695
+ await stream_task
696
+ except asyncio.CancelledError:
697
+ pass
698
+
699
+ # Show metrics
700
+ print(f"\n{'=' * 80}")
701
+ print("Telemetry Metrics")
702
+ print("=" * 80)
703
+ metrics = agent.get_metrics()
704
+ for key, value in metrics.items():
705
+ print(f" {key}: {value}")
706
+
707
+
708
+ if __name__ == "__main__":
709
+ asyncio.run(main())