empathy-framework 3.8.3__py3-none-any.whl → 3.9.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.
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.0.dist-info}/METADATA +25 -6
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.0.dist-info}/RECORD +50 -39
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.0.dist-info}/top_level.txt +0 -4
- empathy_os/.empathy/costs.json +60 -0
- empathy_os/.empathy/discovery_stats.json +15 -0
- empathy_os/.empathy/workflow_runs.json +45 -0
- empathy_os/cli.py +372 -13
- empathy_os/cli_unified.py +111 -0
- empathy_os/config/xml_config.py +45 -3
- empathy_os/config.py +46 -2
- empathy_os/memory/control_panel.py +128 -8
- empathy_os/memory/long_term.py +26 -4
- empathy_os/memory/short_term.py +110 -0
- empathy_os/models/token_estimator.py +25 -0
- empathy_os/pattern_library.py +81 -8
- empathy_os/patterns/debugging/all_patterns.json +81 -0
- empathy_os/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- empathy_os/patterns/refactoring_memory.json +89 -0
- empathy_os/telemetry/__init__.py +11 -0
- empathy_os/telemetry/cli.py +451 -0
- empathy_os/telemetry/usage_tracker.py +475 -0
- empathy_os/tier_recommender.py +422 -0
- empathy_os/workflows/base.py +220 -23
- empathy_os/workflows/config.py +50 -5
- empathy_os/workflows/tier_tracking.py +408 -0
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.0.dist-info}/WHEEL +0 -0
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.0.dist-info}/entry_points.txt +0 -0
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.0.dist-info}/licenses/LICENSE +0 -0
- {hot_reload → empathy_os/hot_reload}/README.md +0 -0
- {hot_reload → empathy_os/hot_reload}/__init__.py +0 -0
- {hot_reload → empathy_os/hot_reload}/config.py +0 -0
- {hot_reload → empathy_os/hot_reload}/integration.py +0 -0
- {hot_reload → empathy_os/hot_reload}/reloader.py +0 -0
- {hot_reload → empathy_os/hot_reload}/watcher.py +0 -0
- {hot_reload → empathy_os/hot_reload}/websocket.py +0 -0
- {scaffolding → empathy_os/scaffolding}/README.md +0 -0
- {scaffolding → empathy_os/scaffolding}/__init__.py +0 -0
- {scaffolding → empathy_os/scaffolding}/__main__.py +0 -0
- {scaffolding → empathy_os/scaffolding}/cli.py +0 -0
- {test_generator → empathy_os/test_generator}/__init__.py +0 -0
- {test_generator → empathy_os/test_generator}/__main__.py +0 -0
- {test_generator → empathy_os/test_generator}/cli.py +0 -0
- {test_generator → empathy_os/test_generator}/generator.py +0 -0
- {test_generator → empathy_os/test_generator}/risk_analyzer.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/__init__.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/behavior.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/core.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/output.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/registry.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/structural.py +0 -0
empathy_os/workflows/base.py
CHANGED
|
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import json
|
|
19
19
|
import logging
|
|
20
|
+
import time
|
|
20
21
|
import uuid
|
|
21
22
|
from abc import ABC, abstractmethod
|
|
22
23
|
from dataclasses import dataclass, field
|
|
@@ -53,6 +54,14 @@ from empathy_os.models import ModelTier as UnifiedModelTier
|
|
|
53
54
|
# Import progress tracking
|
|
54
55
|
from .progress import ProgressCallback, ProgressTracker
|
|
55
56
|
|
|
57
|
+
# Import telemetry tracking
|
|
58
|
+
try:
|
|
59
|
+
from empathy_os.telemetry import UsageTracker
|
|
60
|
+
TELEMETRY_AVAILABLE = True
|
|
61
|
+
except ImportError:
|
|
62
|
+
TELEMETRY_AVAILABLE = False
|
|
63
|
+
UsageTracker = None # type: ignore
|
|
64
|
+
|
|
56
65
|
if TYPE_CHECKING:
|
|
57
66
|
from .config import WorkflowConfig
|
|
58
67
|
from .step_config import WorkflowStepConfig
|
|
@@ -376,6 +385,7 @@ class BaseWorkflow(ABC):
|
|
|
376
385
|
progress_callback: ProgressCallback | None = None,
|
|
377
386
|
cache: BaseCache | None = None,
|
|
378
387
|
enable_cache: bool = True,
|
|
388
|
+
enable_tier_tracking: bool = True,
|
|
379
389
|
):
|
|
380
390
|
"""Initialize workflow with optional cost tracker, provider, and config.
|
|
381
391
|
|
|
@@ -394,6 +404,7 @@ class BaseWorkflow(ABC):
|
|
|
394
404
|
cache: Optional cache instance. If None and enable_cache=True,
|
|
395
405
|
auto-creates cache with one-time setup prompt.
|
|
396
406
|
enable_cache: Whether to enable caching (default True).
|
|
407
|
+
enable_tier_tracking: Whether to enable automatic tier tracking (default True).
|
|
397
408
|
|
|
398
409
|
"""
|
|
399
410
|
from .config import WorkflowConfig
|
|
@@ -416,6 +427,25 @@ class BaseWorkflow(ABC):
|
|
|
416
427
|
self._enable_cache = enable_cache
|
|
417
428
|
self._cache_setup_attempted = False
|
|
418
429
|
|
|
430
|
+
# Tier tracking support
|
|
431
|
+
self._enable_tier_tracking = enable_tier_tracking
|
|
432
|
+
self._tier_tracker = None
|
|
433
|
+
|
|
434
|
+
# Telemetry tracking (singleton instance)
|
|
435
|
+
self._telemetry_tracker: UsageTracker | None = None
|
|
436
|
+
self._enable_telemetry = True # Enable by default
|
|
437
|
+
if TELEMETRY_AVAILABLE and UsageTracker is not None:
|
|
438
|
+
try:
|
|
439
|
+
self._telemetry_tracker = UsageTracker.get_instance()
|
|
440
|
+
except (OSError, PermissionError) as e:
|
|
441
|
+
# File system errors - log but disable telemetry
|
|
442
|
+
logger.debug(f"Failed to initialize telemetry tracker (file system error): {e}")
|
|
443
|
+
self._enable_telemetry = False
|
|
444
|
+
except (AttributeError, TypeError, ValueError) as e:
|
|
445
|
+
# Configuration or initialization errors
|
|
446
|
+
logger.debug(f"Failed to initialize telemetry tracker (config error): {e}")
|
|
447
|
+
self._enable_telemetry = False
|
|
448
|
+
|
|
419
449
|
# Load config if not provided
|
|
420
450
|
self._config = config or WorkflowConfig.load()
|
|
421
451
|
|
|
@@ -475,15 +505,19 @@ class BaseWorkflow(ABC):
|
|
|
475
505
|
auto_setup_cache()
|
|
476
506
|
self._cache = create_cache()
|
|
477
507
|
logger.info(f"Cache initialized for workflow: {self.name}")
|
|
478
|
-
except ImportError:
|
|
508
|
+
except ImportError as e:
|
|
479
509
|
# Hybrid cache dependencies not available, fall back to hash-only
|
|
480
510
|
logger.info(
|
|
481
|
-
"Using hash-only cache (install empathy-framework[cache] for semantic caching)"
|
|
511
|
+
f"Using hash-only cache (install empathy-framework[cache] for semantic caching): {e}"
|
|
482
512
|
)
|
|
483
513
|
self._cache = create_cache(cache_type="hash")
|
|
484
|
-
except
|
|
485
|
-
#
|
|
486
|
-
logger.warning("Cache setup failed, continuing without cache")
|
|
514
|
+
except (OSError, PermissionError) as e:
|
|
515
|
+
# File system errors - disable cache
|
|
516
|
+
logger.warning(f"Cache setup failed (file system error): {e}, continuing without cache")
|
|
517
|
+
self._enable_cache = False
|
|
518
|
+
except (ValueError, TypeError, AttributeError) as e:
|
|
519
|
+
# Configuration errors - disable cache
|
|
520
|
+
logger.warning(f"Cache setup failed (config error): {e}, continuing without cache")
|
|
487
521
|
self._enable_cache = False
|
|
488
522
|
|
|
489
523
|
async def _call_llm(
|
|
@@ -500,6 +534,7 @@ class BaseWorkflow(ABC):
|
|
|
500
534
|
that respect the configured provider (anthropic, openai, google, etc.).
|
|
501
535
|
|
|
502
536
|
Supports automatic caching to reduce API costs and latency.
|
|
537
|
+
Tracks telemetry for usage analysis and cost savings measurement.
|
|
503
538
|
|
|
504
539
|
Args:
|
|
505
540
|
tier: Model tier to use (CHEAP, CAPABLE, PREMIUM)
|
|
@@ -514,9 +549,13 @@ class BaseWorkflow(ABC):
|
|
|
514
549
|
"""
|
|
515
550
|
from .step_config import WorkflowStepConfig
|
|
516
551
|
|
|
552
|
+
# Start timing for telemetry
|
|
553
|
+
start_time = time.time()
|
|
554
|
+
|
|
517
555
|
# Determine stage name for cache key
|
|
518
556
|
stage = stage_name or f"llm_call_{tier.value}"
|
|
519
557
|
model = self.get_model_for_tier(tier)
|
|
558
|
+
cache_type = None
|
|
520
559
|
|
|
521
560
|
# Try cache lookup if enabled
|
|
522
561
|
if self._enable_cache and self._cache is not None:
|
|
@@ -527,15 +566,43 @@ class BaseWorkflow(ABC):
|
|
|
527
566
|
|
|
528
567
|
if cached_response is not None:
|
|
529
568
|
logger.debug(f"Cache hit for {self.name}:{stage}")
|
|
569
|
+
# Determine cache type
|
|
570
|
+
if hasattr(self._cache, "cache_type"):
|
|
571
|
+
ct = self._cache.cache_type # type: ignore
|
|
572
|
+
# Ensure it's a string (not a Mock object)
|
|
573
|
+
cache_type = str(ct) if ct and isinstance(ct, str) else "hash"
|
|
574
|
+
else:
|
|
575
|
+
cache_type = "hash" # Default assumption
|
|
576
|
+
|
|
577
|
+
# Track telemetry for cache hit
|
|
578
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
579
|
+
in_tokens = cached_response["input_tokens"]
|
|
580
|
+
out_tokens = cached_response["output_tokens"]
|
|
581
|
+
cost = self._calculate_cost(tier, in_tokens, out_tokens)
|
|
582
|
+
|
|
583
|
+
self._track_telemetry(
|
|
584
|
+
stage=stage,
|
|
585
|
+
tier=tier,
|
|
586
|
+
model=model,
|
|
587
|
+
cost=cost,
|
|
588
|
+
tokens={"input": in_tokens, "output": out_tokens},
|
|
589
|
+
cache_hit=True,
|
|
590
|
+
cache_type=cache_type,
|
|
591
|
+
duration_ms=duration_ms,
|
|
592
|
+
)
|
|
593
|
+
|
|
530
594
|
# Cached response is dict with content, input_tokens, output_tokens
|
|
531
595
|
return (
|
|
532
596
|
cached_response["content"],
|
|
533
597
|
cached_response["input_tokens"],
|
|
534
598
|
cached_response["output_tokens"],
|
|
535
599
|
)
|
|
536
|
-
except
|
|
537
|
-
#
|
|
538
|
-
logger.debug("Cache lookup failed, continuing with LLM call")
|
|
600
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
601
|
+
# Malformed cache data - continue with LLM call
|
|
602
|
+
logger.debug(f"Cache lookup failed (malformed data): {e}, continuing with LLM call")
|
|
603
|
+
except (OSError, PermissionError) as e:
|
|
604
|
+
# File system errors - continue with LLM call
|
|
605
|
+
logger.debug(f"Cache lookup failed (file system error): {e}, continuing with LLM call")
|
|
539
606
|
|
|
540
607
|
# Create a step config for this call
|
|
541
608
|
step = WorkflowStepConfig(
|
|
@@ -547,12 +614,27 @@ class BaseWorkflow(ABC):
|
|
|
547
614
|
)
|
|
548
615
|
|
|
549
616
|
try:
|
|
550
|
-
content, in_tokens, out_tokens,
|
|
617
|
+
content, in_tokens, out_tokens, cost = await self.run_step_with_executor(
|
|
551
618
|
step=step,
|
|
552
619
|
prompt=user_message,
|
|
553
620
|
system=system,
|
|
554
621
|
)
|
|
555
622
|
|
|
623
|
+
# Calculate duration
|
|
624
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
625
|
+
|
|
626
|
+
# Track telemetry for actual LLM call
|
|
627
|
+
self._track_telemetry(
|
|
628
|
+
stage=stage,
|
|
629
|
+
tier=tier,
|
|
630
|
+
model=model,
|
|
631
|
+
cost=cost,
|
|
632
|
+
tokens={"input": in_tokens, "output": out_tokens},
|
|
633
|
+
cache_hit=False,
|
|
634
|
+
cache_type=None,
|
|
635
|
+
duration_ms=duration_ms,
|
|
636
|
+
)
|
|
637
|
+
|
|
556
638
|
# Store in cache if enabled
|
|
557
639
|
if self._enable_cache and self._cache is not None:
|
|
558
640
|
try:
|
|
@@ -564,21 +646,78 @@ class BaseWorkflow(ABC):
|
|
|
564
646
|
}
|
|
565
647
|
self._cache.put(self.name, stage, full_prompt, model, response_data)
|
|
566
648
|
logger.debug(f"Cached response for {self.name}:{stage}")
|
|
567
|
-
except
|
|
568
|
-
#
|
|
569
|
-
logger.debug("Failed to cache response")
|
|
649
|
+
except (OSError, PermissionError) as e:
|
|
650
|
+
# File system errors - log but continue
|
|
651
|
+
logger.debug(f"Failed to cache response (file system error): {e}")
|
|
652
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
653
|
+
# Data serialization errors - log but continue
|
|
654
|
+
logger.debug(f"Failed to cache response (serialization error): {e}")
|
|
570
655
|
|
|
571
656
|
return content, in_tokens, out_tokens
|
|
572
657
|
except (ValueError, TypeError, KeyError) as e:
|
|
573
658
|
# Invalid input or configuration errors
|
|
659
|
+
logger.warning(f"LLM call failed (invalid input): {e}")
|
|
574
660
|
return f"Error calling LLM (invalid input): {e}", 0, 0
|
|
575
|
-
except (TimeoutError, RuntimeError) as e:
|
|
576
|
-
# Timeout or
|
|
661
|
+
except (TimeoutError, RuntimeError, ConnectionError) as e:
|
|
662
|
+
# Timeout, API errors, or connection failures
|
|
663
|
+
logger.warning(f"LLM call failed (timeout/API/connection error): {e}")
|
|
577
664
|
return f"Error calling LLM (timeout/API error): {e}", 0, 0
|
|
578
|
-
except
|
|
665
|
+
except (OSError, PermissionError) as e:
|
|
666
|
+
# File system or permission errors
|
|
667
|
+
logger.warning(f"LLM call failed (file system error): {e}")
|
|
668
|
+
return f"Error calling LLM (file system error): {e}", 0, 0
|
|
669
|
+
except Exception as e:
|
|
579
670
|
# INTENTIONAL: Graceful degradation - return error message rather than crashing workflow
|
|
580
|
-
logger.exception("Unexpected error calling LLM")
|
|
581
|
-
return "Error calling LLM:
|
|
671
|
+
logger.exception(f"Unexpected error calling LLM: {e}")
|
|
672
|
+
return f"Error calling LLM: {type(e).__name__}", 0, 0
|
|
673
|
+
|
|
674
|
+
def _track_telemetry(
|
|
675
|
+
self,
|
|
676
|
+
stage: str,
|
|
677
|
+
tier: ModelTier,
|
|
678
|
+
model: str,
|
|
679
|
+
cost: float,
|
|
680
|
+
tokens: dict[str, int],
|
|
681
|
+
cache_hit: bool,
|
|
682
|
+
cache_type: str | None,
|
|
683
|
+
duration_ms: int,
|
|
684
|
+
) -> None:
|
|
685
|
+
"""Track telemetry for an LLM call.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
stage: Stage name
|
|
689
|
+
tier: Model tier used
|
|
690
|
+
model: Model ID used
|
|
691
|
+
cost: Cost in USD
|
|
692
|
+
tokens: Dictionary with "input" and "output" token counts
|
|
693
|
+
cache_hit: Whether this was a cache hit
|
|
694
|
+
cache_type: Cache type if cache hit
|
|
695
|
+
duration_ms: Duration in milliseconds
|
|
696
|
+
|
|
697
|
+
"""
|
|
698
|
+
if not self._enable_telemetry or self._telemetry_tracker is None:
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
provider_str = getattr(self, "_provider_str", "unknown")
|
|
703
|
+
self._telemetry_tracker.track_llm_call(
|
|
704
|
+
workflow=self.name,
|
|
705
|
+
stage=stage,
|
|
706
|
+
tier=tier.value.upper(),
|
|
707
|
+
model=model,
|
|
708
|
+
provider=provider_str,
|
|
709
|
+
cost=cost,
|
|
710
|
+
tokens=tokens,
|
|
711
|
+
cache_hit=cache_hit,
|
|
712
|
+
cache_type=cache_type,
|
|
713
|
+
duration_ms=duration_ms,
|
|
714
|
+
)
|
|
715
|
+
except (AttributeError, TypeError, ValueError) as e:
|
|
716
|
+
# INTENTIONAL: Telemetry tracking failures should never crash workflows
|
|
717
|
+
logger.debug(f"Failed to track telemetry (config/data error): {e}")
|
|
718
|
+
except (OSError, PermissionError) as e:
|
|
719
|
+
# File system errors - log but never crash workflow
|
|
720
|
+
logger.debug(f"Failed to track telemetry (file system error): {e}")
|
|
582
721
|
|
|
583
722
|
def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
|
|
584
723
|
"""Calculate cost for a stage."""
|
|
@@ -709,6 +848,20 @@ class BaseWorkflow(ABC):
|
|
|
709
848
|
# Set run ID for telemetry correlation
|
|
710
849
|
self._run_id = str(uuid.uuid4())
|
|
711
850
|
|
|
851
|
+
# Auto tier recommendation
|
|
852
|
+
if self._enable_tier_tracking:
|
|
853
|
+
try:
|
|
854
|
+
from .tier_tracking import WorkflowTierTracker
|
|
855
|
+
|
|
856
|
+
self._tier_tracker = WorkflowTierTracker(self.name, self.description)
|
|
857
|
+
files_affected = kwargs.get("files_affected") or kwargs.get("path")
|
|
858
|
+
if files_affected and not isinstance(files_affected, list):
|
|
859
|
+
files_affected = [str(files_affected)]
|
|
860
|
+
self._tier_tracker.show_recommendation(files_affected)
|
|
861
|
+
except Exception as e:
|
|
862
|
+
logger.debug(f"Tier tracking disabled: {e}")
|
|
863
|
+
self._enable_tier_tracking = False
|
|
864
|
+
|
|
712
865
|
started_at = datetime.now()
|
|
713
866
|
self._stages_run = []
|
|
714
867
|
current_data = kwargs
|
|
@@ -793,6 +946,18 @@ class BaseWorkflow(ABC):
|
|
|
793
946
|
task_type=f"workflow:{self.name}:{stage_name}",
|
|
794
947
|
)
|
|
795
948
|
|
|
949
|
+
# Track telemetry for this stage
|
|
950
|
+
self._track_telemetry(
|
|
951
|
+
stage=stage_name,
|
|
952
|
+
tier=tier,
|
|
953
|
+
model=model_id,
|
|
954
|
+
cost=cost,
|
|
955
|
+
tokens={"input": input_tokens, "output": output_tokens},
|
|
956
|
+
cache_hit=False,
|
|
957
|
+
cache_type=None,
|
|
958
|
+
duration_ms=duration_ms,
|
|
959
|
+
)
|
|
960
|
+
|
|
796
961
|
# Pass output to next stage
|
|
797
962
|
current_data = output if isinstance(output, dict) else {"result": output}
|
|
798
963
|
|
|
@@ -802,16 +967,22 @@ class BaseWorkflow(ABC):
|
|
|
802
967
|
logger.error(error)
|
|
803
968
|
if self._progress_tracker:
|
|
804
969
|
self._progress_tracker.fail_workflow(error)
|
|
805
|
-
except (TimeoutError, RuntimeError) as e:
|
|
806
|
-
# Timeout or
|
|
807
|
-
error = f"Workflow execution error (timeout/API): {e}"
|
|
970
|
+
except (TimeoutError, RuntimeError, ConnectionError) as e:
|
|
971
|
+
# Timeout, API errors, or connection failures
|
|
972
|
+
error = f"Workflow execution error (timeout/API/connection): {e}"
|
|
808
973
|
logger.error(error)
|
|
809
974
|
if self._progress_tracker:
|
|
810
975
|
self._progress_tracker.fail_workflow(error)
|
|
811
|
-
except
|
|
976
|
+
except (OSError, PermissionError) as e:
|
|
977
|
+
# File system or permission errors
|
|
978
|
+
error = f"Workflow execution error (file system): {e}"
|
|
979
|
+
logger.error(error)
|
|
980
|
+
if self._progress_tracker:
|
|
981
|
+
self._progress_tracker.fail_workflow(error)
|
|
982
|
+
except Exception as e:
|
|
812
983
|
# INTENTIONAL: Workflow orchestration - catch all errors to report failure gracefully
|
|
813
|
-
logger.exception("Unexpected error in workflow execution")
|
|
814
|
-
error = "Workflow execution failed:
|
|
984
|
+
logger.exception(f"Unexpected error in workflow execution: {type(e).__name__}")
|
|
985
|
+
error = f"Workflow execution failed: {type(e).__name__}"
|
|
815
986
|
if self._progress_tracker:
|
|
816
987
|
self._progress_tracker.fail_workflow(error)
|
|
817
988
|
|
|
@@ -881,6 +1052,32 @@ class BaseWorkflow(ABC):
|
|
|
881
1052
|
# Emit workflow telemetry to backend
|
|
882
1053
|
self._emit_workflow_telemetry(result)
|
|
883
1054
|
|
|
1055
|
+
# Auto-save tier progression
|
|
1056
|
+
if self._enable_tier_tracking and self._tier_tracker:
|
|
1057
|
+
try:
|
|
1058
|
+
files_affected = kwargs.get("files_affected") or kwargs.get("path")
|
|
1059
|
+
if files_affected and not isinstance(files_affected, list):
|
|
1060
|
+
files_affected = [str(files_affected)]
|
|
1061
|
+
|
|
1062
|
+
# Determine bug type from workflow name
|
|
1063
|
+
bug_type_map = {
|
|
1064
|
+
"code-review": "code_quality",
|
|
1065
|
+
"bug-predict": "bug_prediction",
|
|
1066
|
+
"security-audit": "security_issue",
|
|
1067
|
+
"test-gen": "test_coverage",
|
|
1068
|
+
"refactor-plan": "refactoring",
|
|
1069
|
+
"health-check": "health_check",
|
|
1070
|
+
}
|
|
1071
|
+
bug_type = bug_type_map.get(self.name, "workflow_run")
|
|
1072
|
+
|
|
1073
|
+
self._tier_tracker.save_progression(
|
|
1074
|
+
workflow_result=result,
|
|
1075
|
+
files_affected=files_affected,
|
|
1076
|
+
bug_type=bug_type,
|
|
1077
|
+
)
|
|
1078
|
+
except Exception as e:
|
|
1079
|
+
logger.debug(f"Failed to save tier progression: {e}")
|
|
1080
|
+
|
|
884
1081
|
return result
|
|
885
1082
|
|
|
886
1083
|
def describe(self) -> str:
|
empathy_os/workflows/config.py
CHANGED
|
@@ -38,6 +38,48 @@ except ImportError:
|
|
|
38
38
|
YAML_AVAILABLE = False
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def _validate_file_path(path: str, allowed_dir: str | None = None) -> Path:
|
|
42
|
+
"""Validate file path to prevent path traversal and arbitrary writes.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
path: File path to validate
|
|
46
|
+
allowed_dir: Optional directory to restrict writes to
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Validated Path object
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If path is invalid or unsafe
|
|
53
|
+
"""
|
|
54
|
+
if not path or not isinstance(path, str):
|
|
55
|
+
raise ValueError("path must be a non-empty string")
|
|
56
|
+
|
|
57
|
+
# Check for null bytes
|
|
58
|
+
if "\x00" in path:
|
|
59
|
+
raise ValueError("path contains null bytes")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
resolved = Path(path).resolve()
|
|
63
|
+
except (OSError, RuntimeError) as e:
|
|
64
|
+
raise ValueError(f"Invalid path: {e}")
|
|
65
|
+
|
|
66
|
+
# Check if within allowed directory
|
|
67
|
+
if allowed_dir:
|
|
68
|
+
try:
|
|
69
|
+
allowed = Path(allowed_dir).resolve()
|
|
70
|
+
resolved.relative_to(allowed)
|
|
71
|
+
except ValueError:
|
|
72
|
+
raise ValueError(f"path must be within {allowed_dir}")
|
|
73
|
+
|
|
74
|
+
# Check for dangerous system paths
|
|
75
|
+
dangerous_paths = ["/etc", "/sys", "/proc", "/dev"]
|
|
76
|
+
for dangerous in dangerous_paths:
|
|
77
|
+
if str(resolved).startswith(dangerous):
|
|
78
|
+
raise ValueError(f"Cannot write to system directory: {dangerous}")
|
|
79
|
+
|
|
80
|
+
return resolved
|
|
81
|
+
|
|
82
|
+
|
|
41
83
|
# Re-export for backward compatibility
|
|
42
84
|
__all__ = [
|
|
43
85
|
"DEFAULT_MODELS",
|
|
@@ -371,7 +413,10 @@ class WorkflowConfig:
|
|
|
371
413
|
|
|
372
414
|
def save(self, path: str | Path) -> None:
|
|
373
415
|
"""Save configuration to file."""
|
|
374
|
-
path
|
|
416
|
+
# Validate path first (convert Path to string for validation)
|
|
417
|
+
path_str = str(path)
|
|
418
|
+
validated_path = _validate_file_path(path_str)
|
|
419
|
+
|
|
375
420
|
data = {
|
|
376
421
|
"default_provider": self.default_provider,
|
|
377
422
|
"workflow_providers": self.workflow_providers,
|
|
@@ -387,15 +432,15 @@ class WorkflowConfig:
|
|
|
387
432
|
"audit_level": self.audit_level,
|
|
388
433
|
}
|
|
389
434
|
|
|
390
|
-
|
|
435
|
+
validated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
391
436
|
|
|
392
|
-
if
|
|
437
|
+
if validated_path.suffix in (".yaml", ".yml"):
|
|
393
438
|
if not YAML_AVAILABLE:
|
|
394
439
|
raise ImportError("PyYAML required for YAML config")
|
|
395
|
-
with open(
|
|
440
|
+
with open(validated_path, "w") as f:
|
|
396
441
|
yaml.dump(data, f, default_flow_style=False)
|
|
397
442
|
else:
|
|
398
|
-
with open(
|
|
443
|
+
with open(validated_path, "w") as f:
|
|
399
444
|
json.dump(data, f, indent=2)
|
|
400
445
|
|
|
401
446
|
|