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.
- 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
- examples/quickstart/flow.py +3 -6
- examples/trace_cancel/flow.py +9 -8
- 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.4.dist-info → penguiflow-2.2.6.dist-info}/METADATA +2 -1
- {penguiflow-2.2.4.dist-info → penguiflow-2.2.6.dist-info}/RECORD +19 -13
- {penguiflow-2.2.4.dist-info → penguiflow-2.2.6.dist-info}/WHEEL +0 -0
- {penguiflow-2.2.4.dist-info → penguiflow-2.2.6.dist-info}/entry_points.txt +0 -0
- {penguiflow-2.2.4.dist-info → penguiflow-2.2.6.dist-info}/licenses/LICENSE +0 -0
- {penguiflow-2.2.4.dist-info → penguiflow-2.2.6.dist-info}/top_level.txt +0 -0
|
@@ -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())
|