empathy-framework 4.9.0__py3-none-any.whl → 5.0.0__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.
Files changed (47) hide show
  1. {empathy_framework-4.9.0.dist-info → empathy_framework-5.0.0.dist-info}/METADATA +64 -25
  2. {empathy_framework-4.9.0.dist-info → empathy_framework-5.0.0.dist-info}/RECORD +47 -26
  3. empathy_os/__init__.py +2 -2
  4. empathy_os/cache/hash_only.py +6 -3
  5. empathy_os/cache/hybrid.py +6 -3
  6. empathy_os/cli_legacy.py +27 -1
  7. empathy_os/cli_minimal.py +512 -15
  8. empathy_os/cli_router.py +145 -113
  9. empathy_os/cli_unified.py +25 -0
  10. empathy_os/dashboard/__init__.py +42 -0
  11. empathy_os/dashboard/app.py +512 -0
  12. empathy_os/dashboard/simple_server.py +403 -0
  13. empathy_os/dashboard/standalone_server.py +536 -0
  14. empathy_os/memory/__init__.py +19 -5
  15. empathy_os/memory/short_term.py +4 -70
  16. empathy_os/memory/types.py +2 -2
  17. empathy_os/models/__init__.py +3 -0
  18. empathy_os/models/adaptive_routing.py +437 -0
  19. empathy_os/models/registry.py +4 -4
  20. empathy_os/socratic/ab_testing.py +1 -1
  21. empathy_os/telemetry/__init__.py +29 -1
  22. empathy_os/telemetry/agent_coordination.py +478 -0
  23. empathy_os/telemetry/agent_tracking.py +350 -0
  24. empathy_os/telemetry/approval_gates.py +563 -0
  25. empathy_os/telemetry/event_streaming.py +405 -0
  26. empathy_os/telemetry/feedback_loop.py +557 -0
  27. empathy_os/vscode_bridge 2.py +173 -0
  28. empathy_os/workflows/__init__.py +4 -4
  29. empathy_os/workflows/base.py +495 -43
  30. empathy_os/workflows/history.py +3 -5
  31. empathy_os/workflows/output.py +410 -0
  32. empathy_os/workflows/progress.py +324 -22
  33. empathy_os/workflows/progressive/README 2.md +454 -0
  34. empathy_os/workflows/progressive/__init__ 2.py +92 -0
  35. empathy_os/workflows/progressive/cli 2.py +242 -0
  36. empathy_os/workflows/progressive/core 2.py +488 -0
  37. empathy_os/workflows/progressive/orchestrator 2.py +701 -0
  38. empathy_os/workflows/progressive/reports 2.py +528 -0
  39. empathy_os/workflows/progressive/telemetry 2.py +280 -0
  40. empathy_os/workflows/progressive/test_gen 2.py +514 -0
  41. empathy_os/workflows/progressive/workflow 2.py +628 -0
  42. empathy_os/workflows/routing.py +5 -0
  43. empathy_os/workflows/security_audit.py +189 -0
  44. {empathy_framework-4.9.0.dist-info → empathy_framework-5.0.0.dist-info}/WHEEL +0 -0
  45. {empathy_framework-4.9.0.dist-info → empathy_framework-5.0.0.dist-info}/entry_points.txt +0 -0
  46. {empathy_framework-4.9.0.dist-info → empathy_framework-5.0.0.dist-info}/licenses/LICENSE +0 -0
  47. {empathy_framework-4.9.0.dist-info → empathy_framework-5.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,563 @@
1
+ """Human Approval Gates for Workflow Control.
2
+
3
+ Pattern 5 from Agent Coordination Architecture - Pause workflow execution
4
+ for human approval on critical decisions.
5
+
6
+ Usage:
7
+ # In workflow: Request approval
8
+ gate = ApprovalGate(agent_id="code-review-workflow")
9
+ approval = gate.request_approval(
10
+ approval_type="deploy_to_production",
11
+ context={
12
+ "deployment": "v2.0.0",
13
+ "changes": ["feature-x", "bugfix-y"],
14
+ "risk_level": "medium"
15
+ },
16
+ timeout=300.0 # 5 minutes
17
+ )
18
+
19
+ if approval.approved:
20
+ deploy_to_production()
21
+ else:
22
+ logger.info(f"Deployment rejected: {approval.reason}")
23
+
24
+ # From UI: Respond to approval request
25
+ gate = ApprovalGate()
26
+ pending = gate.get_pending_approvals()
27
+ for request in pending:
28
+ # Display to user, get decision
29
+ gate.respond_to_approval(
30
+ request_id=request.request_id,
31
+ approved=True,
32
+ responder="user@example.com",
33
+ reason="Looks good to deploy"
34
+ )
35
+
36
+ Copyright 2025 Smart-AI-Memory
37
+ Licensed under Fair Source License 0.9
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ import time
44
+ from dataclasses import dataclass, field
45
+ from datetime import datetime
46
+ from typing import Any
47
+ from uuid import uuid4
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ @dataclass
53
+ class ApprovalRequest:
54
+ """Approval request with context for human decision.
55
+
56
+ Represents a pending approval request from a workflow.
57
+ """
58
+
59
+ request_id: str
60
+ approval_type: str # "deploy", "delete", "refactor", etc.
61
+ agent_id: str # Requesting agent/workflow
62
+ context: dict[str, Any] # Decision context
63
+ timestamp: datetime
64
+ timeout_seconds: float
65
+ status: str = "pending" # "pending", "approved", "rejected", "timeout"
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ """Convert to dictionary for serialization."""
69
+ return {
70
+ "request_id": self.request_id,
71
+ "approval_type": self.approval_type,
72
+ "agent_id": self.agent_id,
73
+ "context": self.context,
74
+ "timestamp": self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp,
75
+ "timeout_seconds": self.timeout_seconds,
76
+ "status": self.status,
77
+ }
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: dict[str, Any]) -> ApprovalRequest:
81
+ """Create from dictionary."""
82
+ timestamp = data.get("timestamp")
83
+ if isinstance(timestamp, str):
84
+ timestamp = datetime.fromisoformat(timestamp)
85
+ elif not isinstance(timestamp, datetime):
86
+ timestamp = datetime.utcnow()
87
+
88
+ return cls(
89
+ request_id=data["request_id"],
90
+ approval_type=data["approval_type"],
91
+ agent_id=data["agent_id"],
92
+ context=data.get("context", {}),
93
+ timestamp=timestamp,
94
+ timeout_seconds=data.get("timeout_seconds", 300.0),
95
+ status=data.get("status", "pending"),
96
+ )
97
+
98
+
99
+ @dataclass
100
+ class ApprovalResponse:
101
+ """Response to an approval request.
102
+
103
+ Represents a human's decision on an approval request.
104
+ """
105
+
106
+ request_id: str
107
+ approved: bool
108
+ responder: str # User who approved/rejected
109
+ reason: str = ""
110
+ timestamp: datetime = field(default_factory=datetime.utcnow)
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ """Convert to dictionary for serialization."""
114
+ return {
115
+ "request_id": self.request_id,
116
+ "approved": self.approved,
117
+ "responder": self.responder,
118
+ "reason": self.reason,
119
+ "timestamp": self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp,
120
+ }
121
+
122
+ @classmethod
123
+ def from_dict(cls, data: dict[str, Any]) -> ApprovalResponse:
124
+ """Create from dictionary."""
125
+ timestamp = data.get("timestamp")
126
+ if isinstance(timestamp, str):
127
+ timestamp = datetime.fromisoformat(timestamp)
128
+ elif not isinstance(timestamp, datetime):
129
+ timestamp = datetime.utcnow()
130
+
131
+ return cls(
132
+ request_id=data["request_id"],
133
+ approved=data.get("approved", False),
134
+ responder=data.get("responder", "unknown"),
135
+ reason=data.get("reason", ""),
136
+ timestamp=timestamp,
137
+ )
138
+
139
+
140
+ class ApprovalGate:
141
+ """Human approval gates for workflow control.
142
+
143
+ Workflows can pause execution and wait for human approval before
144
+ proceeding with critical actions.
145
+
146
+ Uses coordination signals under the hood:
147
+ - "approval_request" signal: Workflow → Human
148
+ - "approval_response" signal: Human → Workflow
149
+
150
+ Attributes:
151
+ DEFAULT_TIMEOUT: Default approval timeout (300s = 5 minutes)
152
+ POLL_INTERVAL: Poll interval when waiting for approval (1s)
153
+ """
154
+
155
+ DEFAULT_TIMEOUT = 300.0 # 5 minutes default timeout
156
+ POLL_INTERVAL = 1.0 # Check for response every 1 second
157
+
158
+ def __init__(self, memory=None, agent_id: str | None = None):
159
+ """Initialize approval gate.
160
+
161
+ Args:
162
+ memory: Memory instance for storing approval requests/responses
163
+ agent_id: This agent's ID (for workflow requesting approval)
164
+ """
165
+ self.memory = memory
166
+ self.agent_id = agent_id
167
+
168
+ if self.memory is None:
169
+ try:
170
+ from empathy_os.telemetry import UsageTracker
171
+
172
+ tracker = UsageTracker.get_instance()
173
+ if hasattr(tracker, "_memory"):
174
+ self.memory = tracker._memory
175
+ except (ImportError, AttributeError):
176
+ pass
177
+
178
+ if self.memory is None:
179
+ logger.warning("No memory backend available for approval gates")
180
+
181
+ def request_approval(
182
+ self,
183
+ approval_type: str,
184
+ context: dict[str, Any] | None = None,
185
+ timeout: float | None = None,
186
+ ) -> ApprovalResponse:
187
+ """Request human approval and wait for response.
188
+
189
+ This is a blocking operation that waits for human approval with timeout.
190
+
191
+ Args:
192
+ approval_type: Type of approval needed (e.g., "deploy", "delete")
193
+ context: Context information for decision making
194
+ timeout: Maximum wait time in seconds (default: DEFAULT_TIMEOUT)
195
+
196
+ Returns:
197
+ ApprovalResponse with decision (approved or rejected)
198
+
199
+ Raises:
200
+ ValueError: If approval times out
201
+
202
+ Example:
203
+ >>> gate = ApprovalGate(agent_id="my-workflow")
204
+ >>> approval = gate.request_approval(
205
+ ... approval_type="deploy_to_production",
206
+ ... context={"version": "2.0.0", "risk": "medium"},
207
+ ... timeout=300.0
208
+ ... )
209
+ >>> if approval.approved:
210
+ ... deploy()
211
+ """
212
+ if not self.memory or not self.agent_id:
213
+ logger.warning("Cannot request approval: no memory backend or agent_id")
214
+ # Return auto-rejected response
215
+ return ApprovalResponse(
216
+ request_id="",
217
+ approved=False,
218
+ responder="system",
219
+ reason="Approval gates not available (no memory backend)",
220
+ )
221
+
222
+ timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT
223
+ request_id = f"approval_{uuid4().hex[:8]}"
224
+
225
+ # Create approval request
226
+ request = ApprovalRequest(
227
+ request_id=request_id,
228
+ approval_type=approval_type,
229
+ agent_id=self.agent_id,
230
+ context=context or {},
231
+ timestamp=datetime.utcnow(),
232
+ timeout_seconds=timeout,
233
+ status="pending",
234
+ )
235
+
236
+ # Store approval request (for UI to retrieve)
237
+ request_key = f"approval_request:{request_id}"
238
+ try:
239
+ if hasattr(self.memory, "stash"):
240
+ self.memory.stash(
241
+ key=request_key,
242
+ data=request.to_dict(),
243
+ credentials=None,
244
+ ttl_seconds=int(timeout) + 60, # TTL = timeout + buffer
245
+ )
246
+ elif hasattr(self.memory, "_redis"):
247
+ import json
248
+
249
+ self.memory._redis.setex(request_key, int(timeout) + 60, json.dumps(request.to_dict()))
250
+ else:
251
+ logger.warning("Cannot store approval request: unsupported memory type")
252
+ except Exception as e:
253
+ logger.error(f"Failed to store approval request: {e}")
254
+ return ApprovalResponse(
255
+ request_id=request_id, approved=False, responder="system", reason=f"Storage error: {e}"
256
+ )
257
+
258
+ # Send approval_request signal (for notifications)
259
+ try:
260
+ from empathy_os.telemetry import CoordinationSignals
261
+
262
+ signals = CoordinationSignals(memory=self.memory, agent_id=self.agent_id)
263
+ signals.signal(
264
+ signal_type="approval_request",
265
+ source_agent=self.agent_id,
266
+ target_agent="*", # Broadcast to UI/monitoring systems
267
+ payload=request.to_dict(),
268
+ ttl_seconds=int(timeout) + 60,
269
+ )
270
+ except Exception as e:
271
+ logger.warning(f"Failed to send approval_request signal: {e}")
272
+
273
+ # Wait for approval response (blocking with timeout)
274
+ logger.info(
275
+ f"Waiting for approval: {approval_type} (request_id={request_id}, timeout={timeout}s)"
276
+ )
277
+
278
+ start_time = time.time()
279
+ while time.time() - start_time < timeout:
280
+ # Check for response
281
+ response = self._check_for_response(request_id)
282
+ if response:
283
+ logger.info(
284
+ f"Approval received: {approval_type} → {'APPROVED' if response.approved else 'REJECTED'}"
285
+ )
286
+ return response
287
+
288
+ # Sleep before next check
289
+ time.sleep(self.POLL_INTERVAL)
290
+
291
+ # Timeout - no response received
292
+ logger.warning(f"Approval timeout: {approval_type} (request_id={request_id})")
293
+
294
+ # Update request status to timeout
295
+ request.status = "timeout"
296
+ try:
297
+ if hasattr(self.memory, "stash"):
298
+ self.memory.stash(key=request_key, data=request.to_dict(), credentials=None, ttl_seconds=60)
299
+ elif hasattr(self.memory, "_redis"):
300
+ import json
301
+
302
+ self.memory._redis.setex(request_key, 60, json.dumps(request.to_dict()))
303
+ except Exception:
304
+ pass
305
+
306
+ return ApprovalResponse(
307
+ request_id=request_id,
308
+ approved=False,
309
+ responder="system",
310
+ reason=f"Approval timeout after {timeout}s",
311
+ )
312
+
313
+ def _check_for_response(self, request_id: str) -> ApprovalResponse | None:
314
+ """Check if approval response has been received."""
315
+ if not self.memory:
316
+ return None
317
+
318
+ response_key = f"approval_response:{request_id}"
319
+
320
+ try:
321
+ # Try retrieve method first (UnifiedMemory)
322
+ if hasattr(self.memory, "retrieve"):
323
+ data = self.memory.retrieve(response_key, credentials=None)
324
+ # Try direct Redis access
325
+ elif hasattr(self.memory, "_redis"):
326
+ import json
327
+
328
+ raw_data = self.memory._redis.get(response_key)
329
+ if raw_data:
330
+ if isinstance(raw_data, bytes):
331
+ raw_data = raw_data.decode("utf-8")
332
+ data = json.loads(raw_data)
333
+ else:
334
+ data = None
335
+ else:
336
+ data = None
337
+
338
+ if data:
339
+ return ApprovalResponse.from_dict(data)
340
+ return None
341
+ except Exception as e:
342
+ logger.debug(f"Failed to check for approval response: {e}")
343
+ return None
344
+
345
+ def respond_to_approval(
346
+ self, request_id: str, approved: bool, responder: str, reason: str = ""
347
+ ) -> bool:
348
+ """Respond to an approval request (called from UI/human).
349
+
350
+ Args:
351
+ request_id: ID of approval request to respond to
352
+ approved: Whether to approve or reject
353
+ responder: User/system responding (e.g., email, username)
354
+ reason: Optional reason for decision
355
+
356
+ Returns:
357
+ True if response was stored successfully, False otherwise
358
+
359
+ Example:
360
+ >>> gate = ApprovalGate()
361
+ >>> success = gate.respond_to_approval(
362
+ ... request_id="approval_abc123",
363
+ ... approved=True,
364
+ ... responder="user@example.com",
365
+ ... reason="Looks good to deploy"
366
+ ... )
367
+ """
368
+ if not self.memory:
369
+ logger.warning("Cannot respond to approval: no memory backend")
370
+ return False
371
+
372
+ response = ApprovalResponse(
373
+ request_id=request_id, approved=approved, responder=responder, reason=reason, timestamp=datetime.utcnow()
374
+ )
375
+
376
+ # Store approval response (for workflow to retrieve)
377
+ response_key = f"approval_response:{request_id}"
378
+ try:
379
+ if hasattr(self.memory, "stash"):
380
+ self.memory.stash(
381
+ key=response_key, data=response.to_dict(), credentials=None, ttl_seconds=300 # 5 min TTL
382
+ )
383
+ elif hasattr(self.memory, "_redis"):
384
+ import json
385
+
386
+ self.memory._redis.setex(response_key, 300, json.dumps(response.to_dict()))
387
+ else:
388
+ logger.warning("Cannot store approval response: unsupported memory type")
389
+ return False
390
+ except Exception as e:
391
+ logger.error(f"Failed to store approval response: {e}")
392
+ return False
393
+
394
+ # Update request status
395
+ request_key = f"approval_request:{request_id}"
396
+ try:
397
+ if hasattr(self.memory, "retrieve"):
398
+ request_data = self.memory.retrieve(request_key, credentials=None)
399
+ elif hasattr(self.memory, "_redis"):
400
+ import json
401
+
402
+ raw_data = self.memory._redis.get(request_key)
403
+ if raw_data:
404
+ if isinstance(raw_data, bytes):
405
+ raw_data = raw_data.decode("utf-8")
406
+ request_data = json.loads(raw_data)
407
+ else:
408
+ request_data = None
409
+ else:
410
+ request_data = None
411
+
412
+ if request_data:
413
+ request = ApprovalRequest.from_dict(request_data)
414
+ request.status = "approved" if approved else "rejected"
415
+
416
+ if hasattr(self.memory, "stash"):
417
+ self.memory.stash(key=request_key, data=request.to_dict(), credentials=None, ttl_seconds=300)
418
+ elif hasattr(self.memory, "_redis"):
419
+ import json
420
+
421
+ self.memory._redis.setex(request_key, 300, json.dumps(request.to_dict()))
422
+ except Exception as e:
423
+ logger.debug(f"Failed to update request status: {e}")
424
+
425
+ # Send approval_response signal (for notifications)
426
+ try:
427
+ from empathy_os.telemetry import CoordinationSignals
428
+
429
+ signals = CoordinationSignals(memory=self.memory, agent_id=responder)
430
+ signals.signal(
431
+ signal_type="approval_response",
432
+ source_agent=responder,
433
+ target_agent="*", # Broadcast
434
+ payload=response.to_dict(),
435
+ ttl_seconds=300,
436
+ )
437
+ except Exception as e:
438
+ logger.debug(f"Failed to send approval_response signal: {e}")
439
+
440
+ logger.info(
441
+ f"Approval response recorded: {request_id} → {'APPROVED' if approved else 'REJECTED'} by {responder}"
442
+ )
443
+ return True
444
+
445
+ def get_pending_approvals(self, approval_type: str | None = None) -> list[ApprovalRequest]:
446
+ """Get all pending approval requests (called from UI).
447
+
448
+ Args:
449
+ approval_type: Optional filter by approval type
450
+
451
+ Returns:
452
+ List of pending approval requests
453
+
454
+ Example:
455
+ >>> gate = ApprovalGate()
456
+ >>> pending = gate.get_pending_approvals()
457
+ >>> for request in pending:
458
+ ... print(f"{request.approval_type}: {request.context}")
459
+ """
460
+ if not self.memory or not hasattr(self.memory, "_redis"):
461
+ return []
462
+
463
+ try:
464
+ # Scan for approval_request:* keys
465
+ keys = self.memory._redis.keys("approval_request:*")
466
+
467
+ requests = []
468
+ for key in keys:
469
+ if isinstance(key, bytes):
470
+ key = key.decode("utf-8")
471
+
472
+ # Retrieve request
473
+ if hasattr(self.memory, "retrieve"):
474
+ data = self.memory.retrieve(key, credentials=None)
475
+ else:
476
+ import json
477
+
478
+ raw_data = self.memory._redis.get(key)
479
+ if raw_data:
480
+ if isinstance(raw_data, bytes):
481
+ raw_data = raw_data.decode("utf-8")
482
+ data = json.loads(raw_data)
483
+ else:
484
+ data = None
485
+
486
+ if not data:
487
+ continue
488
+
489
+ request = ApprovalRequest.from_dict(data)
490
+
491
+ # Filter by status (only pending)
492
+ if request.status != "pending":
493
+ continue
494
+
495
+ # Filter by type if specified
496
+ if approval_type and request.approval_type != approval_type:
497
+ continue
498
+
499
+ requests.append(request)
500
+
501
+ # Sort by timestamp (oldest first)
502
+ requests.sort(key=lambda r: r.timestamp)
503
+
504
+ return requests
505
+ except Exception as e:
506
+ logger.error(f"Failed to get pending approvals: {e}")
507
+ return []
508
+
509
+ def clear_expired_requests(self) -> int:
510
+ """Clear approval requests that have timed out.
511
+
512
+ Returns:
513
+ Number of requests cleared
514
+ """
515
+ if not self.memory or not hasattr(self.memory, "_redis"):
516
+ return 0
517
+
518
+ try:
519
+ keys = self.memory._redis.keys("approval_request:*")
520
+ now = datetime.utcnow()
521
+ cleared = 0
522
+
523
+ for key in keys:
524
+ if isinstance(key, bytes):
525
+ key = key.decode("utf-8")
526
+
527
+ # Retrieve request
528
+ if hasattr(self.memory, "retrieve"):
529
+ data = self.memory.retrieve(key, credentials=None)
530
+ else:
531
+ import json
532
+
533
+ raw_data = self.memory._redis.get(key)
534
+ if raw_data:
535
+ if isinstance(raw_data, bytes):
536
+ raw_data = raw_data.decode("utf-8")
537
+ data = json.loads(raw_data)
538
+ else:
539
+ data = None
540
+
541
+ if not data:
542
+ continue
543
+
544
+ request = ApprovalRequest.from_dict(data)
545
+
546
+ # Check if expired
547
+ elapsed = (now - request.timestamp).total_seconds()
548
+ if elapsed > request.timeout_seconds and request.status == "pending":
549
+ # Update to timeout status
550
+ request.status = "timeout"
551
+ if hasattr(self.memory, "stash"):
552
+ self.memory.stash(key=key, data=request.to_dict(), credentials=None, ttl_seconds=60)
553
+ elif hasattr(self.memory, "_redis"):
554
+ import json
555
+
556
+ self.memory._redis.setex(key, 60, json.dumps(request.to_dict()))
557
+
558
+ cleared += 1
559
+
560
+ return cleared
561
+ except Exception as e:
562
+ logger.error(f"Failed to clear expired requests: {e}")
563
+ return 0