dacp 0.3.1__py3-none-any.whl → 0.3.3__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.
@@ -0,0 +1,508 @@
1
+ """
2
+ DACP Workflow Runtime - Declarative workflow execution from workflow.yaml
3
+
4
+ This module provides a runtime system that reads workflow.yaml files and
5
+ orchestrates agent collaboration through agent and task registries.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ import uuid
11
+ import yaml
12
+ import json
13
+ from typing import Dict, Any, List, Optional, Union
14
+ from pathlib import Path
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
17
+
18
+ logger = logging.getLogger("dacp.workflow_runtime")
19
+
20
+
21
+ class TaskStatus(Enum):
22
+ """Task execution status."""
23
+ PENDING = "pending"
24
+ RUNNING = "running"
25
+ COMPLETED = "completed"
26
+ FAILED = "failed"
27
+ CANCELLED = "cancelled"
28
+
29
+
30
+ @dataclass
31
+ class TaskExecution:
32
+ """Represents a task execution instance."""
33
+ id: str
34
+ workflow_id: str
35
+ step_id: str
36
+ agent_id: str
37
+ task_name: str
38
+ input_data: Dict[str, Any]
39
+ status: TaskStatus = TaskStatus.PENDING
40
+ output_data: Optional[Dict[str, Any]] = None
41
+ error: Optional[str] = None
42
+ created_at: float = field(default_factory=time.time)
43
+ started_at: Optional[float] = None
44
+ completed_at: Optional[float] = None
45
+ duration: Optional[float] = None
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ """Convert to dictionary representation."""
49
+ return {
50
+ "id": self.id,
51
+ "workflow_id": self.workflow_id,
52
+ "step_id": self.step_id,
53
+ "agent_id": self.agent_id,
54
+ "task_name": self.task_name,
55
+ "input_data": self.input_data,
56
+ "status": self.status.value,
57
+ "output_data": self.output_data,
58
+ "error": self.error,
59
+ "created_at": self.created_at,
60
+ "started_at": self.started_at,
61
+ "completed_at": self.completed_at,
62
+ "duration": self.duration,
63
+ }
64
+
65
+
66
+ @dataclass
67
+ class RegisteredAgent:
68
+ """Represents a registered agent in the registry."""
69
+ id: str
70
+ agent_instance: Any
71
+ spec_file: Optional[str] = None
72
+ metadata: Dict[str, Any] = field(default_factory=dict)
73
+ registered_at: float = field(default_factory=time.time)
74
+ last_activity: Optional[float] = None
75
+
76
+ def to_dict(self) -> Dict[str, Any]:
77
+ """Convert to dictionary representation."""
78
+ return {
79
+ "id": self.id,
80
+ "agent_type": type(self.agent_instance).__name__,
81
+ "spec_file": self.spec_file,
82
+ "metadata": self.metadata,
83
+ "registered_at": self.registered_at,
84
+ "last_activity": self.last_activity,
85
+ }
86
+
87
+
88
+ class AgentRegistry:
89
+ """Registry for managing agent instances."""
90
+
91
+ def __init__(self):
92
+ self.agents: Dict[str, RegisteredAgent] = {}
93
+
94
+ def register_agent(
95
+ self,
96
+ agent_id: str,
97
+ agent_instance: Any,
98
+ spec_file: Optional[str] = None,
99
+ metadata: Optional[Dict[str, Any]] = None
100
+ ) -> None:
101
+ """Register an agent instance."""
102
+ registered_agent = RegisteredAgent(
103
+ id=agent_id,
104
+ agent_instance=agent_instance,
105
+ spec_file=spec_file,
106
+ metadata=metadata or {}
107
+ )
108
+
109
+ self.agents[agent_id] = registered_agent
110
+ logger.info(f"🤖 Agent '{agent_id}' registered in registry")
111
+
112
+ def get_agent(self, agent_id: str) -> Optional[Any]:
113
+ """Get an agent instance by ID."""
114
+ if agent_id in self.agents:
115
+ self.agents[agent_id].last_activity = time.time()
116
+ return self.agents[agent_id].agent_instance
117
+ return None
118
+
119
+ def list_agents(self) -> List[str]:
120
+ """List all registered agent IDs."""
121
+ return list(self.agents.keys())
122
+
123
+ def get_agent_info(self, agent_id: str) -> Optional[Dict[str, Any]]:
124
+ """Get agent registration information."""
125
+ if agent_id in self.agents:
126
+ return self.agents[agent_id].to_dict()
127
+ return None
128
+
129
+ def unregister_agent(self, agent_id: str) -> bool:
130
+ """Unregister an agent."""
131
+ if agent_id in self.agents:
132
+ del self.agents[agent_id]
133
+ logger.info(f"🗑️ Agent '{agent_id}' unregistered from registry")
134
+ return True
135
+ return False
136
+
137
+
138
+ class TaskRegistry:
139
+ """Registry for managing task executions."""
140
+
141
+ def __init__(self):
142
+ self.tasks: Dict[str, TaskExecution] = {}
143
+ self.workflow_tasks: Dict[str, List[str]] = {} # workflow_id -> task_ids
144
+
145
+ def create_task(
146
+ self,
147
+ workflow_id: str,
148
+ step_id: str,
149
+ agent_id: str,
150
+ task_name: str,
151
+ input_data: Dict[str, Any]
152
+ ) -> str:
153
+ """Create a new task execution."""
154
+ task_id = str(uuid.uuid4())
155
+
156
+ task = TaskExecution(
157
+ id=task_id,
158
+ workflow_id=workflow_id,
159
+ step_id=step_id,
160
+ agent_id=agent_id,
161
+ task_name=task_name,
162
+ input_data=input_data
163
+ )
164
+
165
+ self.tasks[task_id] = task
166
+
167
+ # Add to workflow tasks
168
+ if workflow_id not in self.workflow_tasks:
169
+ self.workflow_tasks[workflow_id] = []
170
+ self.workflow_tasks[workflow_id].append(task_id)
171
+
172
+ logger.info(f"📋 Task '{task_id}' created for agent '{agent_id}' in workflow '{workflow_id}'")
173
+ return task_id
174
+
175
+ def get_task(self, task_id: str) -> Optional[TaskExecution]:
176
+ """Get a task by ID."""
177
+ return self.tasks.get(task_id)
178
+
179
+ def update_task_status(self, task_id: str, status: TaskStatus, **kwargs) -> bool:
180
+ """Update task status and optional fields."""
181
+ if task_id not in self.tasks:
182
+ return False
183
+
184
+ task = self.tasks[task_id]
185
+ task.status = status
186
+
187
+ # Update optional fields
188
+ for key, value in kwargs.items():
189
+ if hasattr(task, key):
190
+ setattr(task, key, value)
191
+
192
+ # Calculate duration if completed
193
+ if status in [TaskStatus.COMPLETED, TaskStatus.FAILED] and task.started_at:
194
+ task.completed_at = time.time()
195
+ task.duration = task.completed_at - task.started_at
196
+
197
+ logger.info(f"📊 Task '{task_id}' status updated to {status.value}")
198
+ return True
199
+
200
+ def get_workflow_tasks(self, workflow_id: str) -> List[TaskExecution]:
201
+ """Get all tasks for a workflow."""
202
+ task_ids = self.workflow_tasks.get(workflow_id, [])
203
+ return [self.tasks[tid] for tid in task_ids if tid in self.tasks]
204
+
205
+ def get_task_summary(self) -> Dict[str, Any]:
206
+ """Get summary of all tasks."""
207
+ status_counts = {}
208
+ for task in self.tasks.values():
209
+ status = task.status.value
210
+ status_counts[status] = status_counts.get(status, 0) + 1
211
+
212
+ return {
213
+ "total_tasks": len(self.tasks),
214
+ "status_counts": status_counts,
215
+ "workflows": len(self.workflow_tasks)
216
+ }
217
+
218
+
219
+ class WorkflowRuntime:
220
+ """DACP Workflow Runtime - Executes workflows from workflow.yaml"""
221
+
222
+ def __init__(self, orchestrator=None):
223
+ self.orchestrator = orchestrator
224
+ self.agent_registry = AgentRegistry()
225
+ self.task_registry = TaskRegistry()
226
+ self.workflow_config = {}
227
+ self.active_workflows: Dict[str, Dict[str, Any]] = {}
228
+
229
+ def load_workflow_config(self, config_path: str) -> None:
230
+ """Load workflow configuration from YAML file."""
231
+ config_file = Path(config_path)
232
+ if not config_file.exists():
233
+ raise FileNotFoundError(f"Workflow config file not found: {config_path}")
234
+
235
+ with open(config_file, 'r') as f:
236
+ self.workflow_config = yaml.safe_load(f)
237
+
238
+ logger.info(f"📁 Loaded workflow config from {config_path}")
239
+ logger.info(f"📋 Found {len(self.workflow_config.get('workflows', {}))} workflows")
240
+
241
+ def register_agent_from_config(self, agent_id: str, agent_instance: Any) -> None:
242
+ """Register an agent instance based on workflow config."""
243
+ # Find agent spec in config
244
+ agent_spec = None
245
+ for agent_config in self.workflow_config.get('agents', []):
246
+ if agent_config['id'] == agent_id:
247
+ agent_spec = agent_config.get('spec')
248
+ break
249
+
250
+ self.agent_registry.register_agent(
251
+ agent_id=agent_id,
252
+ agent_instance=agent_instance,
253
+ spec_file=agent_spec,
254
+ metadata={"config_based": True}
255
+ )
256
+
257
+ def execute_workflow(self, workflow_name: str, initial_input: Dict[str, Any] = None) -> str:
258
+ """Execute a workflow by name."""
259
+ if workflow_name not in self.workflow_config.get('workflows', {}):
260
+ raise ValueError(f"Workflow '{workflow_name}' not found in config")
261
+
262
+ workflow_def = self.workflow_config['workflows'][workflow_name]
263
+ workflow_id = str(uuid.uuid4())
264
+
265
+ logger.info(f"🚀 Starting workflow '{workflow_name}' with ID '{workflow_id}'")
266
+
267
+ # Initialize workflow state
268
+ self.active_workflows[workflow_id] = {
269
+ "name": workflow_name,
270
+ "definition": workflow_def,
271
+ "current_step": 0,
272
+ "context": initial_input or {},
273
+ "started_at": time.time()
274
+ }
275
+
276
+ # Execute first step
277
+ self._execute_workflow_step(workflow_id, 0)
278
+
279
+ return workflow_id
280
+
281
+ def _execute_workflow_step(self, workflow_id: str, step_index: int) -> None:
282
+ """Execute a specific workflow step."""
283
+ if workflow_id not in self.active_workflows:
284
+ logger.error(f"❌ Workflow '{workflow_id}' not found")
285
+ return
286
+
287
+ workflow_state = self.active_workflows[workflow_id]
288
+ workflow_def = workflow_state["definition"]
289
+ steps = workflow_def.get("steps", [])
290
+
291
+ if step_index >= len(steps):
292
+ logger.info(f"🏁 Workflow '{workflow_id}' completed")
293
+ return
294
+
295
+ step = steps[step_index]
296
+ step_id = f"step_{step_index}"
297
+
298
+ # Extract step configuration
299
+ agent_id = step.get("agent")
300
+ task_name = step.get("task")
301
+ step_input = step.get("input", {})
302
+
303
+ # Resolve input data with context
304
+ resolved_input = self._resolve_input_data(step_input, workflow_state["context"])
305
+
306
+ logger.info(f"📋 Executing step {step_index}: {agent_id}.{task_name}")
307
+
308
+ # Create task
309
+ task_id = self.task_registry.create_task(
310
+ workflow_id=workflow_id,
311
+ step_id=step_id,
312
+ agent_id=agent_id,
313
+ task_name=task_name,
314
+ input_data=resolved_input
315
+ )
316
+
317
+ # Execute task
318
+ self._execute_task(task_id, workflow_id, step_index)
319
+
320
+ def _execute_task(self, task_id: str, workflow_id: str, step_index: int) -> None:
321
+ """Execute a single task."""
322
+ task = self.task_registry.get_task(task_id)
323
+ if not task:
324
+ logger.error(f"❌ Task '{task_id}' not found")
325
+ return
326
+
327
+ # Get agent instance
328
+ agent = self.agent_registry.get_agent(task.agent_id)
329
+ if not agent:
330
+ self.task_registry.update_task_status(
331
+ task_id, TaskStatus.FAILED,
332
+ error=f"Agent '{task.agent_id}' not found"
333
+ )
334
+ return
335
+
336
+ # Update task status
337
+ self.task_registry.update_task_status(
338
+ task_id, TaskStatus.RUNNING,
339
+ started_at=time.time()
340
+ )
341
+
342
+ try:
343
+ # Prepare message for agent
344
+ message = {
345
+ "task": task.task_name,
346
+ **task.input_data
347
+ }
348
+
349
+ logger.info(f"📨 Sending task '{task.task_name}' to agent '{task.agent_id}'")
350
+
351
+ # Execute via orchestrator or direct call
352
+ if self.orchestrator:
353
+ result = self.orchestrator.send_message(task.agent_id, message)
354
+ else:
355
+ result = agent.handle_message(message)
356
+
357
+ # Check for errors
358
+ if isinstance(result, dict) and "error" in result:
359
+ self.task_registry.update_task_status(
360
+ task_id, TaskStatus.FAILED,
361
+ error=result["error"]
362
+ )
363
+ logger.error(f"❌ Task '{task_id}' failed: {result['error']}")
364
+ return
365
+
366
+ # Task completed successfully
367
+ self.task_registry.update_task_status(
368
+ task_id, TaskStatus.COMPLETED,
369
+ output_data=result
370
+ )
371
+
372
+ logger.info(f"✅ Task '{task_id}' completed successfully")
373
+
374
+ # Continue workflow
375
+ self._handle_task_completion(task_id, workflow_id, step_index, result)
376
+
377
+ except Exception as e:
378
+ self.task_registry.update_task_status(
379
+ task_id, TaskStatus.FAILED,
380
+ error=str(e)
381
+ )
382
+ logger.error(f"❌ Task '{task_id}' failed with exception: {e}")
383
+
384
+ def _handle_task_completion(self, task_id: str, workflow_id: str, step_index: int, result: Dict[str, Any]) -> None:
385
+ """Handle task completion and route to next step."""
386
+ workflow_state = self.active_workflows[workflow_id]
387
+ workflow_def = workflow_state["definition"]
388
+ steps = workflow_def.get("steps", [])
389
+
390
+ if step_index >= len(steps):
391
+ return
392
+
393
+ current_step = steps[step_index]
394
+
395
+ # Convert result to dictionary if it's a Pydantic model
396
+ if hasattr(result, 'model_dump'):
397
+ result_dict = result.model_dump()
398
+ logger.debug(f"🔧 Converted Pydantic model to dict: {result_dict}")
399
+ elif hasattr(result, 'dict'):
400
+ result_dict = result.dict()
401
+ logger.debug(f"🔧 Converted Pydantic model to dict (legacy): {result_dict}")
402
+ else:
403
+ result_dict = result
404
+
405
+ # Update workflow context with result
406
+ workflow_state["context"].update({"output": result_dict})
407
+
408
+ # Check for routing
409
+ route_config = current_step.get("route_output_to")
410
+ if route_config:
411
+ # Route to next agent
412
+ next_agent_id = route_config.get("agent")
413
+ next_task_name = route_config.get("task")
414
+ input_mapping = route_config.get("input_mapping", {})
415
+
416
+ logger.debug(f"🔍 Input mapping: {input_mapping}")
417
+ logger.debug(f"🔍 Available output data: {result_dict}")
418
+
419
+ # Resolve input mapping
420
+ next_input = self._resolve_input_mapping(input_mapping, result_dict, workflow_state["context"])
421
+
422
+ logger.info(f"🔄 Routing output to {next_agent_id}.{next_task_name}")
423
+ logger.debug(f"🔍 Resolved input for next task: {next_input}")
424
+
425
+ # Create and execute next task
426
+ next_task_id = self.task_registry.create_task(
427
+ workflow_id=workflow_id,
428
+ step_id=f"routed_step_{step_index}",
429
+ agent_id=next_agent_id,
430
+ task_name=next_task_name,
431
+ input_data=next_input
432
+ )
433
+
434
+ self._execute_task(next_task_id, workflow_id, step_index + 1)
435
+ else:
436
+ # Continue to next step
437
+ self._execute_workflow_step(workflow_id, step_index + 1)
438
+
439
+ def _resolve_input_data(self, input_config: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
440
+ """Resolve input data with context variables."""
441
+ resolved = {}
442
+ for key, value in input_config.items():
443
+ if isinstance(value, str) and value.startswith("{{") and value.endswith("}}"):
444
+ # Template variable
445
+ var_path = value[2:-2].strip()
446
+ resolved[key] = self._get_nested_value(context, var_path)
447
+ else:
448
+ resolved[key] = value
449
+ return resolved
450
+
451
+ def _resolve_input_mapping(self, mapping: Dict[str, str], output: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
452
+ """Resolve input mapping with output and context."""
453
+ resolved = {}
454
+ for target_key, source_template in mapping.items():
455
+ if isinstance(source_template, str) and source_template.startswith("{{") and source_template.endswith("}}"):
456
+ var_path = source_template[2:-2].strip()
457
+ if var_path.startswith("output."):
458
+ # From current output
459
+ field_name = var_path[7:] # Remove "output."
460
+ resolved[target_key] = output.get(field_name, "")
461
+ else:
462
+ # From context
463
+ resolved[target_key] = self._get_nested_value(context, var_path)
464
+ else:
465
+ resolved[target_key] = source_template
466
+ return resolved
467
+
468
+ def _get_nested_value(self, data: Dict[str, Any], path: str) -> Any:
469
+ """Get nested value from dictionary using dot notation."""
470
+ keys = path.split('.')
471
+ current = data
472
+ for key in keys:
473
+ if isinstance(current, dict) and key in current:
474
+ current = current[key]
475
+ else:
476
+ return None
477
+ return current
478
+
479
+ def get_workflow_status(self, workflow_id: str) -> Optional[Dict[str, Any]]:
480
+ """Get workflow execution status."""
481
+ if workflow_id not in self.active_workflows:
482
+ return None
483
+
484
+ workflow_state = self.active_workflows[workflow_id]
485
+ tasks = self.task_registry.get_workflow_tasks(workflow_id)
486
+
487
+ return {
488
+ "workflow_id": workflow_id,
489
+ "name": workflow_state["name"],
490
+ "current_step": workflow_state["current_step"],
491
+ "started_at": workflow_state["started_at"],
492
+ "context": workflow_state["context"],
493
+ "tasks": [task.to_dict() for task in tasks]
494
+ }
495
+
496
+ def get_runtime_status(self) -> Dict[str, Any]:
497
+ """Get overall runtime status."""
498
+ return {
499
+ "agents": {
500
+ "registered": len(self.agent_registry.agents),
501
+ "agents": [agent.to_dict() for agent in self.agent_registry.agents.values()]
502
+ },
503
+ "tasks": self.task_registry.get_task_summary(),
504
+ "workflows": {
505
+ "active": len(self.active_workflows),
506
+ "configured": len(self.workflow_config.get('workflows', {}))
507
+ }
508
+ }