attune-ai 2.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.
- attune/__init__.py +358 -0
- attune/adaptive/__init__.py +13 -0
- attune/adaptive/task_complexity.py +127 -0
- attune/agent_monitoring.py +414 -0
- attune/cache/__init__.py +117 -0
- attune/cache/base.py +166 -0
- attune/cache/dependency_manager.py +256 -0
- attune/cache/hash_only.py +251 -0
- attune/cache/hybrid.py +457 -0
- attune/cache/storage.py +285 -0
- attune/cache_monitor.py +356 -0
- attune/cache_stats.py +298 -0
- attune/cli/__init__.py +152 -0
- attune/cli/__main__.py +12 -0
- attune/cli/commands/__init__.py +1 -0
- attune/cli/commands/batch.py +264 -0
- attune/cli/commands/cache.py +248 -0
- attune/cli/commands/help.py +331 -0
- attune/cli/commands/info.py +140 -0
- attune/cli/commands/inspect.py +436 -0
- attune/cli/commands/inspection.py +57 -0
- attune/cli/commands/memory.py +48 -0
- attune/cli/commands/metrics.py +92 -0
- attune/cli/commands/orchestrate.py +184 -0
- attune/cli/commands/patterns.py +207 -0
- attune/cli/commands/profiling.py +202 -0
- attune/cli/commands/provider.py +98 -0
- attune/cli/commands/routing.py +285 -0
- attune/cli/commands/setup.py +96 -0
- attune/cli/commands/status.py +235 -0
- attune/cli/commands/sync.py +166 -0
- attune/cli/commands/tier.py +121 -0
- attune/cli/commands/utilities.py +114 -0
- attune/cli/commands/workflow.py +579 -0
- attune/cli/core.py +32 -0
- attune/cli/parsers/__init__.py +68 -0
- attune/cli/parsers/batch.py +118 -0
- attune/cli/parsers/cache.py +65 -0
- attune/cli/parsers/help.py +41 -0
- attune/cli/parsers/info.py +26 -0
- attune/cli/parsers/inspect.py +66 -0
- attune/cli/parsers/metrics.py +42 -0
- attune/cli/parsers/orchestrate.py +61 -0
- attune/cli/parsers/patterns.py +54 -0
- attune/cli/parsers/provider.py +40 -0
- attune/cli/parsers/routing.py +110 -0
- attune/cli/parsers/setup.py +42 -0
- attune/cli/parsers/status.py +47 -0
- attune/cli/parsers/sync.py +31 -0
- attune/cli/parsers/tier.py +33 -0
- attune/cli/parsers/workflow.py +77 -0
- attune/cli/utils/__init__.py +1 -0
- attune/cli/utils/data.py +242 -0
- attune/cli/utils/helpers.py +68 -0
- attune/cli_legacy.py +3957 -0
- attune/cli_minimal.py +1159 -0
- attune/cli_router.py +437 -0
- attune/cli_unified.py +814 -0
- attune/config/__init__.py +66 -0
- attune/config/xml_config.py +286 -0
- attune/config.py +545 -0
- attune/coordination.py +870 -0
- attune/core.py +1511 -0
- attune/core_modules/__init__.py +15 -0
- attune/cost_tracker.py +626 -0
- attune/dashboard/__init__.py +41 -0
- attune/dashboard/app.py +512 -0
- attune/dashboard/simple_server.py +435 -0
- attune/dashboard/standalone_server.py +547 -0
- attune/discovery.py +306 -0
- attune/emergence.py +306 -0
- attune/exceptions.py +123 -0
- attune/feedback_loops.py +373 -0
- attune/hot_reload/README.md +473 -0
- attune/hot_reload/__init__.py +62 -0
- attune/hot_reload/config.py +83 -0
- attune/hot_reload/integration.py +229 -0
- attune/hot_reload/reloader.py +298 -0
- attune/hot_reload/watcher.py +183 -0
- attune/hot_reload/websocket.py +177 -0
- attune/levels.py +577 -0
- attune/leverage_points.py +441 -0
- attune/logging_config.py +261 -0
- attune/mcp/__init__.py +10 -0
- attune/mcp/server.py +506 -0
- attune/memory/__init__.py +237 -0
- attune/memory/claude_memory.py +469 -0
- attune/memory/config.py +224 -0
- attune/memory/control_panel.py +1290 -0
- attune/memory/control_panel_support.py +145 -0
- attune/memory/cross_session.py +845 -0
- attune/memory/edges.py +179 -0
- attune/memory/encryption.py +159 -0
- attune/memory/file_session.py +770 -0
- attune/memory/graph.py +570 -0
- attune/memory/long_term.py +913 -0
- attune/memory/long_term_types.py +99 -0
- attune/memory/mixins/__init__.py +25 -0
- attune/memory/mixins/backend_init_mixin.py +249 -0
- attune/memory/mixins/capabilities_mixin.py +208 -0
- attune/memory/mixins/handoff_mixin.py +208 -0
- attune/memory/mixins/lifecycle_mixin.py +49 -0
- attune/memory/mixins/long_term_mixin.py +352 -0
- attune/memory/mixins/promotion_mixin.py +109 -0
- attune/memory/mixins/short_term_mixin.py +182 -0
- attune/memory/nodes.py +179 -0
- attune/memory/redis_bootstrap.py +540 -0
- attune/memory/security/__init__.py +31 -0
- attune/memory/security/audit_logger.py +932 -0
- attune/memory/security/pii_scrubber.py +640 -0
- attune/memory/security/secrets_detector.py +678 -0
- attune/memory/short_term.py +2192 -0
- attune/memory/simple_storage.py +302 -0
- attune/memory/storage/__init__.py +15 -0
- attune/memory/storage_backend.py +167 -0
- attune/memory/summary_index.py +583 -0
- attune/memory/types.py +446 -0
- attune/memory/unified.py +182 -0
- attune/meta_workflows/__init__.py +74 -0
- attune/meta_workflows/agent_creator.py +248 -0
- attune/meta_workflows/builtin_templates.py +567 -0
- attune/meta_workflows/cli_commands/__init__.py +56 -0
- attune/meta_workflows/cli_commands/agent_commands.py +321 -0
- attune/meta_workflows/cli_commands/analytics_commands.py +442 -0
- attune/meta_workflows/cli_commands/config_commands.py +232 -0
- attune/meta_workflows/cli_commands/memory_commands.py +182 -0
- attune/meta_workflows/cli_commands/template_commands.py +354 -0
- attune/meta_workflows/cli_commands/workflow_commands.py +382 -0
- attune/meta_workflows/cli_meta_workflows.py +59 -0
- attune/meta_workflows/form_engine.py +292 -0
- attune/meta_workflows/intent_detector.py +409 -0
- attune/meta_workflows/models.py +569 -0
- attune/meta_workflows/pattern_learner.py +738 -0
- attune/meta_workflows/plan_generator.py +384 -0
- attune/meta_workflows/session_context.py +397 -0
- attune/meta_workflows/template_registry.py +229 -0
- attune/meta_workflows/workflow.py +984 -0
- attune/metrics/__init__.py +12 -0
- attune/metrics/collector.py +31 -0
- attune/metrics/prompt_metrics.py +194 -0
- attune/models/__init__.py +172 -0
- attune/models/__main__.py +13 -0
- attune/models/adaptive_routing.py +437 -0
- attune/models/auth_cli.py +444 -0
- attune/models/auth_strategy.py +450 -0
- attune/models/cli.py +655 -0
- attune/models/empathy_executor.py +354 -0
- attune/models/executor.py +257 -0
- attune/models/fallback.py +762 -0
- attune/models/provider_config.py +282 -0
- attune/models/registry.py +472 -0
- attune/models/tasks.py +359 -0
- attune/models/telemetry/__init__.py +71 -0
- attune/models/telemetry/analytics.py +594 -0
- attune/models/telemetry/backend.py +196 -0
- attune/models/telemetry/data_models.py +431 -0
- attune/models/telemetry/storage.py +489 -0
- attune/models/token_estimator.py +420 -0
- attune/models/validation.py +280 -0
- attune/monitoring/__init__.py +52 -0
- attune/monitoring/alerts.py +946 -0
- attune/monitoring/alerts_cli.py +448 -0
- attune/monitoring/multi_backend.py +271 -0
- attune/monitoring/otel_backend.py +362 -0
- attune/optimization/__init__.py +19 -0
- attune/optimization/context_optimizer.py +272 -0
- attune/orchestration/__init__.py +67 -0
- attune/orchestration/agent_templates.py +707 -0
- attune/orchestration/config_store.py +499 -0
- attune/orchestration/execution_strategies.py +2111 -0
- attune/orchestration/meta_orchestrator.py +1168 -0
- attune/orchestration/pattern_learner.py +696 -0
- attune/orchestration/real_tools.py +931 -0
- attune/pattern_cache.py +187 -0
- attune/pattern_library.py +542 -0
- attune/patterns/debugging/all_patterns.json +81 -0
- attune/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- attune/patterns/refactoring_memory.json +89 -0
- attune/persistence.py +564 -0
- attune/platform_utils.py +265 -0
- attune/plugins/__init__.py +28 -0
- attune/plugins/base.py +361 -0
- attune/plugins/registry.py +268 -0
- attune/project_index/__init__.py +32 -0
- attune/project_index/cli.py +335 -0
- attune/project_index/index.py +667 -0
- attune/project_index/models.py +504 -0
- attune/project_index/reports.py +474 -0
- attune/project_index/scanner.py +777 -0
- attune/project_index/scanner_parallel.py +291 -0
- attune/prompts/__init__.py +61 -0
- attune/prompts/config.py +77 -0
- attune/prompts/context.py +177 -0
- attune/prompts/parser.py +285 -0
- attune/prompts/registry.py +313 -0
- attune/prompts/templates.py +208 -0
- attune/redis_config.py +302 -0
- attune/redis_memory.py +799 -0
- attune/resilience/__init__.py +56 -0
- attune/resilience/circuit_breaker.py +256 -0
- attune/resilience/fallback.py +179 -0
- attune/resilience/health.py +300 -0
- attune/resilience/retry.py +209 -0
- attune/resilience/timeout.py +135 -0
- attune/routing/__init__.py +43 -0
- attune/routing/chain_executor.py +433 -0
- attune/routing/classifier.py +217 -0
- attune/routing/smart_router.py +234 -0
- attune/routing/workflow_registry.py +343 -0
- attune/scaffolding/README.md +589 -0
- attune/scaffolding/__init__.py +35 -0
- attune/scaffolding/__main__.py +14 -0
- attune/scaffolding/cli.py +240 -0
- attune/scaffolding/templates/base_wizard.py.jinja2 +121 -0
- attune/scaffolding/templates/coach_wizard.py.jinja2 +321 -0
- attune/scaffolding/templates/domain_wizard.py.jinja2 +408 -0
- attune/scaffolding/templates/linear_flow_wizard.py.jinja2 +203 -0
- attune/socratic/__init__.py +256 -0
- attune/socratic/ab_testing.py +958 -0
- attune/socratic/blueprint.py +533 -0
- attune/socratic/cli.py +703 -0
- attune/socratic/collaboration.py +1114 -0
- attune/socratic/domain_templates.py +924 -0
- attune/socratic/embeddings.py +738 -0
- attune/socratic/engine.py +794 -0
- attune/socratic/explainer.py +682 -0
- attune/socratic/feedback.py +772 -0
- attune/socratic/forms.py +629 -0
- attune/socratic/generator.py +732 -0
- attune/socratic/llm_analyzer.py +637 -0
- attune/socratic/mcp_server.py +702 -0
- attune/socratic/session.py +312 -0
- attune/socratic/storage.py +667 -0
- attune/socratic/success.py +730 -0
- attune/socratic/visual_editor.py +860 -0
- attune/socratic/web_ui.py +958 -0
- attune/telemetry/__init__.py +39 -0
- attune/telemetry/agent_coordination.py +475 -0
- attune/telemetry/agent_tracking.py +367 -0
- attune/telemetry/approval_gates.py +545 -0
- attune/telemetry/cli.py +1231 -0
- attune/telemetry/commands/__init__.py +14 -0
- attune/telemetry/commands/dashboard_commands.py +696 -0
- attune/telemetry/event_streaming.py +409 -0
- attune/telemetry/feedback_loop.py +567 -0
- attune/telemetry/usage_tracker.py +591 -0
- attune/templates.py +754 -0
- attune/test_generator/__init__.py +38 -0
- attune/test_generator/__main__.py +14 -0
- attune/test_generator/cli.py +234 -0
- attune/test_generator/generator.py +355 -0
- attune/test_generator/risk_analyzer.py +216 -0
- attune/test_generator/templates/unit_test.py.jinja2 +272 -0
- attune/tier_recommender.py +384 -0
- attune/tools.py +183 -0
- attune/trust/__init__.py +28 -0
- attune/trust/circuit_breaker.py +579 -0
- attune/trust_building.py +527 -0
- attune/validation/__init__.py +19 -0
- attune/validation/xml_validator.py +281 -0
- attune/vscode_bridge.py +173 -0
- attune/workflow_commands.py +780 -0
- attune/workflow_patterns/__init__.py +33 -0
- attune/workflow_patterns/behavior.py +249 -0
- attune/workflow_patterns/core.py +76 -0
- attune/workflow_patterns/output.py +99 -0
- attune/workflow_patterns/registry.py +255 -0
- attune/workflow_patterns/structural.py +288 -0
- attune/workflows/__init__.py +539 -0
- attune/workflows/autonomous_test_gen.py +1268 -0
- attune/workflows/base.py +2667 -0
- attune/workflows/batch_processing.py +342 -0
- attune/workflows/bug_predict.py +1084 -0
- attune/workflows/builder.py +273 -0
- attune/workflows/caching.py +253 -0
- attune/workflows/code_review.py +1048 -0
- attune/workflows/code_review_adapters.py +312 -0
- attune/workflows/code_review_pipeline.py +722 -0
- attune/workflows/config.py +645 -0
- attune/workflows/dependency_check.py +644 -0
- attune/workflows/document_gen/__init__.py +25 -0
- attune/workflows/document_gen/config.py +30 -0
- attune/workflows/document_gen/report_formatter.py +162 -0
- attune/workflows/document_gen/workflow.py +1426 -0
- attune/workflows/document_manager.py +216 -0
- attune/workflows/document_manager_README.md +134 -0
- attune/workflows/documentation_orchestrator.py +1205 -0
- attune/workflows/history.py +510 -0
- attune/workflows/keyboard_shortcuts/__init__.py +39 -0
- attune/workflows/keyboard_shortcuts/generators.py +391 -0
- attune/workflows/keyboard_shortcuts/parsers.py +416 -0
- attune/workflows/keyboard_shortcuts/prompts.py +295 -0
- attune/workflows/keyboard_shortcuts/schema.py +193 -0
- attune/workflows/keyboard_shortcuts/workflow.py +509 -0
- attune/workflows/llm_base.py +363 -0
- attune/workflows/manage_docs.py +87 -0
- attune/workflows/manage_docs_README.md +134 -0
- attune/workflows/manage_documentation.py +821 -0
- attune/workflows/new_sample_workflow1.py +149 -0
- attune/workflows/new_sample_workflow1_README.md +150 -0
- attune/workflows/orchestrated_health_check.py +849 -0
- attune/workflows/orchestrated_release_prep.py +600 -0
- attune/workflows/output.py +413 -0
- attune/workflows/perf_audit.py +863 -0
- attune/workflows/pr_review.py +762 -0
- attune/workflows/progress.py +785 -0
- attune/workflows/progress_server.py +322 -0
- attune/workflows/progressive/README 2.md +454 -0
- attune/workflows/progressive/README.md +454 -0
- attune/workflows/progressive/__init__.py +82 -0
- attune/workflows/progressive/cli.py +219 -0
- attune/workflows/progressive/core.py +488 -0
- attune/workflows/progressive/orchestrator.py +723 -0
- attune/workflows/progressive/reports.py +520 -0
- attune/workflows/progressive/telemetry.py +274 -0
- attune/workflows/progressive/test_gen.py +495 -0
- attune/workflows/progressive/workflow.py +589 -0
- attune/workflows/refactor_plan.py +694 -0
- attune/workflows/release_prep.py +895 -0
- attune/workflows/release_prep_crew.py +969 -0
- attune/workflows/research_synthesis.py +404 -0
- attune/workflows/routing.py +168 -0
- attune/workflows/secure_release.py +593 -0
- attune/workflows/security_adapters.py +297 -0
- attune/workflows/security_audit.py +1329 -0
- attune/workflows/security_audit_phase3.py +355 -0
- attune/workflows/seo_optimization.py +633 -0
- attune/workflows/step_config.py +234 -0
- attune/workflows/telemetry_mixin.py +269 -0
- attune/workflows/test5.py +125 -0
- attune/workflows/test5_README.md +158 -0
- attune/workflows/test_coverage_boost_crew.py +849 -0
- attune/workflows/test_gen/__init__.py +52 -0
- attune/workflows/test_gen/ast_analyzer.py +249 -0
- attune/workflows/test_gen/config.py +88 -0
- attune/workflows/test_gen/data_models.py +38 -0
- attune/workflows/test_gen/report_formatter.py +289 -0
- attune/workflows/test_gen/test_templates.py +381 -0
- attune/workflows/test_gen/workflow.py +655 -0
- attune/workflows/test_gen.py +54 -0
- attune/workflows/test_gen_behavioral.py +477 -0
- attune/workflows/test_gen_parallel.py +341 -0
- attune/workflows/test_lifecycle.py +526 -0
- attune/workflows/test_maintenance.py +627 -0
- attune/workflows/test_maintenance_cli.py +590 -0
- attune/workflows/test_maintenance_crew.py +840 -0
- attune/workflows/test_runner.py +622 -0
- attune/workflows/tier_tracking.py +531 -0
- attune/workflows/xml_enhanced_crew.py +285 -0
- attune_ai-2.0.0.dist-info/METADATA +1026 -0
- attune_ai-2.0.0.dist-info/RECORD +457 -0
- attune_ai-2.0.0.dist-info/WHEEL +5 -0
- attune_ai-2.0.0.dist-info/entry_points.txt +26 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE +201 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +101 -0
- attune_ai-2.0.0.dist-info/top_level.txt +5 -0
- attune_healthcare/__init__.py +13 -0
- attune_healthcare/monitors/__init__.py +9 -0
- attune_healthcare/monitors/clinical_protocol_monitor.py +315 -0
- attune_healthcare/monitors/monitoring/__init__.py +44 -0
- attune_healthcare/monitors/monitoring/protocol_checker.py +300 -0
- attune_healthcare/monitors/monitoring/protocol_loader.py +214 -0
- attune_healthcare/monitors/monitoring/sensor_parsers.py +306 -0
- attune_healthcare/monitors/monitoring/trajectory_analyzer.py +389 -0
- attune_llm/README.md +553 -0
- attune_llm/__init__.py +28 -0
- attune_llm/agent_factory/__init__.py +53 -0
- attune_llm/agent_factory/adapters/__init__.py +85 -0
- attune_llm/agent_factory/adapters/autogen_adapter.py +312 -0
- attune_llm/agent_factory/adapters/crewai_adapter.py +483 -0
- attune_llm/agent_factory/adapters/haystack_adapter.py +298 -0
- attune_llm/agent_factory/adapters/langchain_adapter.py +362 -0
- attune_llm/agent_factory/adapters/langgraph_adapter.py +333 -0
- attune_llm/agent_factory/adapters/native.py +228 -0
- attune_llm/agent_factory/adapters/wizard_adapter.py +423 -0
- attune_llm/agent_factory/base.py +305 -0
- attune_llm/agent_factory/crews/__init__.py +67 -0
- attune_llm/agent_factory/crews/code_review.py +1113 -0
- attune_llm/agent_factory/crews/health_check.py +1262 -0
- attune_llm/agent_factory/crews/refactoring.py +1128 -0
- attune_llm/agent_factory/crews/security_audit.py +1018 -0
- attune_llm/agent_factory/decorators.py +287 -0
- attune_llm/agent_factory/factory.py +558 -0
- attune_llm/agent_factory/framework.py +193 -0
- attune_llm/agent_factory/memory_integration.py +328 -0
- attune_llm/agent_factory/resilient.py +320 -0
- attune_llm/agents_md/__init__.py +22 -0
- attune_llm/agents_md/loader.py +218 -0
- attune_llm/agents_md/parser.py +271 -0
- attune_llm/agents_md/registry.py +307 -0
- attune_llm/claude_memory.py +466 -0
- attune_llm/cli/__init__.py +8 -0
- attune_llm/cli/sync_claude.py +487 -0
- attune_llm/code_health.py +1313 -0
- attune_llm/commands/__init__.py +51 -0
- attune_llm/commands/context.py +375 -0
- attune_llm/commands/loader.py +301 -0
- attune_llm/commands/models.py +231 -0
- attune_llm/commands/parser.py +371 -0
- attune_llm/commands/registry.py +429 -0
- attune_llm/config/__init__.py +29 -0
- attune_llm/config/unified.py +291 -0
- attune_llm/context/__init__.py +22 -0
- attune_llm/context/compaction.py +455 -0
- attune_llm/context/manager.py +434 -0
- attune_llm/contextual_patterns.py +361 -0
- attune_llm/core.py +907 -0
- attune_llm/git_pattern_extractor.py +435 -0
- attune_llm/hooks/__init__.py +24 -0
- attune_llm/hooks/config.py +306 -0
- attune_llm/hooks/executor.py +289 -0
- attune_llm/hooks/registry.py +302 -0
- attune_llm/hooks/scripts/__init__.py +39 -0
- attune_llm/hooks/scripts/evaluate_session.py +201 -0
- attune_llm/hooks/scripts/first_time_init.py +285 -0
- attune_llm/hooks/scripts/pre_compact.py +207 -0
- attune_llm/hooks/scripts/session_end.py +183 -0
- attune_llm/hooks/scripts/session_start.py +163 -0
- attune_llm/hooks/scripts/suggest_compact.py +225 -0
- attune_llm/learning/__init__.py +30 -0
- attune_llm/learning/evaluator.py +438 -0
- attune_llm/learning/extractor.py +514 -0
- attune_llm/learning/storage.py +560 -0
- attune_llm/levels.py +227 -0
- attune_llm/pattern_confidence.py +414 -0
- attune_llm/pattern_resolver.py +272 -0
- attune_llm/pattern_summary.py +350 -0
- attune_llm/providers.py +967 -0
- attune_llm/routing/__init__.py +32 -0
- attune_llm/routing/model_router.py +362 -0
- attune_llm/security/IMPLEMENTATION_SUMMARY.md +413 -0
- attune_llm/security/PHASE2_COMPLETE.md +384 -0
- attune_llm/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
- attune_llm/security/QUICK_REFERENCE.md +316 -0
- attune_llm/security/README.md +262 -0
- attune_llm/security/__init__.py +62 -0
- attune_llm/security/audit_logger.py +929 -0
- attune_llm/security/audit_logger_example.py +152 -0
- attune_llm/security/pii_scrubber.py +640 -0
- attune_llm/security/secrets_detector.py +678 -0
- attune_llm/security/secrets_detector_example.py +304 -0
- attune_llm/security/secure_memdocs.py +1192 -0
- attune_llm/security/secure_memdocs_example.py +278 -0
- attune_llm/session_status.py +745 -0
- attune_llm/state.py +246 -0
- attune_llm/utils/__init__.py +5 -0
- attune_llm/utils/tokens.py +349 -0
- attune_software/SOFTWARE_PLUGIN_README.md +57 -0
- attune_software/__init__.py +13 -0
- attune_software/cli/__init__.py +120 -0
- attune_software/cli/inspect.py +362 -0
- attune_software/cli.py +574 -0
- attune_software/plugin.py +188 -0
- workflow_scaffolding/__init__.py +11 -0
- workflow_scaffolding/__main__.py +12 -0
- workflow_scaffolding/cli.py +206 -0
- workflow_scaffolding/generator.py +265 -0
attune/workflows/base.py
ADDED
|
@@ -0,0 +1,2667 @@
|
|
|
1
|
+
"""Base Workflow Class for Multi-Model Pipelines
|
|
2
|
+
|
|
3
|
+
Provides a framework for creating cost-optimized workflows that
|
|
4
|
+
route tasks to the appropriate model tier.
|
|
5
|
+
|
|
6
|
+
Integration with attune.models:
|
|
7
|
+
- Uses unified ModelTier/ModelProvider from attune.models
|
|
8
|
+
- Supports LLMExecutor for abstracted LLM calls
|
|
9
|
+
- Supports TelemetryBackend for telemetry storage
|
|
10
|
+
- WorkflowStepConfig for declarative step definitions
|
|
11
|
+
|
|
12
|
+
Copyright 2025 Smart-AI-Memory
|
|
13
|
+
Licensed under Fair Source License 0.9
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .routing import TierRoutingStrategy
|
|
32
|
+
from .tier_tracking import WorkflowTierTracker
|
|
33
|
+
|
|
34
|
+
# Load .env file for API keys if python-dotenv is available
|
|
35
|
+
try:
|
|
36
|
+
from dotenv import load_dotenv
|
|
37
|
+
|
|
38
|
+
load_dotenv()
|
|
39
|
+
except ImportError:
|
|
40
|
+
pass # python-dotenv not installed, rely on environment variables
|
|
41
|
+
|
|
42
|
+
# Import caching infrastructure
|
|
43
|
+
from attune.cache import BaseCache
|
|
44
|
+
from attune.config import _validate_file_path
|
|
45
|
+
from attune.cost_tracker import MODEL_PRICING, CostTracker
|
|
46
|
+
|
|
47
|
+
# Import unified types from attune.models
|
|
48
|
+
from attune.models import (
|
|
49
|
+
ExecutionContext,
|
|
50
|
+
LLMExecutor,
|
|
51
|
+
TaskRoutingRecord,
|
|
52
|
+
TelemetryBackend,
|
|
53
|
+
)
|
|
54
|
+
from attune.models import ModelProvider as UnifiedModelProvider
|
|
55
|
+
from attune.models import ModelTier as UnifiedModelTier
|
|
56
|
+
|
|
57
|
+
# Import mixins (extracted for maintainability)
|
|
58
|
+
from .caching import CachedResponse, CachingMixin
|
|
59
|
+
|
|
60
|
+
# Import progress tracking
|
|
61
|
+
from .progress import (
|
|
62
|
+
RICH_AVAILABLE,
|
|
63
|
+
ProgressCallback,
|
|
64
|
+
ProgressTracker,
|
|
65
|
+
RichProgressReporter,
|
|
66
|
+
)
|
|
67
|
+
from .telemetry_mixin import TelemetryMixin
|
|
68
|
+
|
|
69
|
+
# Import telemetry tracking
|
|
70
|
+
try:
|
|
71
|
+
from attune.telemetry import UsageTracker
|
|
72
|
+
|
|
73
|
+
TELEMETRY_AVAILABLE = True
|
|
74
|
+
except ImportError:
|
|
75
|
+
TELEMETRY_AVAILABLE = False
|
|
76
|
+
UsageTracker = None # type: ignore
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING:
|
|
79
|
+
from .config import WorkflowConfig
|
|
80
|
+
from .step_config import WorkflowStepConfig
|
|
81
|
+
|
|
82
|
+
logger = logging.getLogger(__name__)
|
|
83
|
+
|
|
84
|
+
# Default path for workflow run history
|
|
85
|
+
WORKFLOW_HISTORY_FILE = ".attune/workflow_runs.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Local enums for backward compatibility - DEPRECATED
|
|
89
|
+
# New code should use attune.models.ModelTier/ModelProvider
|
|
90
|
+
class ModelTier(Enum):
|
|
91
|
+
"""DEPRECATED: Model tier for cost optimization.
|
|
92
|
+
|
|
93
|
+
This enum is deprecated and will be removed in v5.0.
|
|
94
|
+
Use attune.models.ModelTier instead.
|
|
95
|
+
|
|
96
|
+
Migration:
|
|
97
|
+
# Old:
|
|
98
|
+
from attune.workflows.base import ModelTier
|
|
99
|
+
|
|
100
|
+
# New:
|
|
101
|
+
from attune.models import ModelTier
|
|
102
|
+
|
|
103
|
+
Why deprecated:
|
|
104
|
+
- Creates confusion with dual definitions
|
|
105
|
+
- attune.models.ModelTier is the canonical location
|
|
106
|
+
- Simplifies imports and reduces duplication
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
CHEAP = "cheap" # Haiku/GPT-4o-mini - $0.25-1.25/M tokens
|
|
110
|
+
CAPABLE = "capable" # Sonnet/GPT-4o - $3-15/M tokens
|
|
111
|
+
PREMIUM = "premium" # Opus/o1 - $15-75/M tokens
|
|
112
|
+
|
|
113
|
+
def __init__(self, value: str):
|
|
114
|
+
"""Initialize with deprecation warning."""
|
|
115
|
+
# Only warn once per process, not per instance
|
|
116
|
+
import warnings
|
|
117
|
+
|
|
118
|
+
# Use self.__class__ instead of ModelTier (class not yet defined during creation)
|
|
119
|
+
if not hasattr(self.__class__, "_deprecation_warned"):
|
|
120
|
+
warnings.warn(
|
|
121
|
+
"workflows.base.ModelTier is deprecated and will be removed in v5.0. "
|
|
122
|
+
"Use attune.models.ModelTier instead. "
|
|
123
|
+
"Update imports: from attune.models import ModelTier",
|
|
124
|
+
DeprecationWarning,
|
|
125
|
+
stacklevel=4,
|
|
126
|
+
)
|
|
127
|
+
self.__class__._deprecation_warned = True
|
|
128
|
+
|
|
129
|
+
def to_unified(self) -> UnifiedModelTier:
|
|
130
|
+
"""Convert to unified ModelTier from attune.models."""
|
|
131
|
+
return UnifiedModelTier(self.value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ModelProvider(Enum):
|
|
135
|
+
"""Supported model providers."""
|
|
136
|
+
|
|
137
|
+
ANTHROPIC = "anthropic"
|
|
138
|
+
OPENAI = "openai"
|
|
139
|
+
GOOGLE = "google" # Google Gemini models
|
|
140
|
+
OLLAMA = "ollama"
|
|
141
|
+
HYBRID = "hybrid" # Mix of best models from different providers
|
|
142
|
+
CUSTOM = "custom" # User-defined custom models
|
|
143
|
+
|
|
144
|
+
def to_unified(self) -> UnifiedModelProvider:
|
|
145
|
+
"""Convert to unified ModelProvider from attune.models.
|
|
146
|
+
|
|
147
|
+
As of v5.0.0, framework is Claude-native. All providers map to ANTHROPIC.
|
|
148
|
+
"""
|
|
149
|
+
# v5.0.0: Framework is Claude-native, only ANTHROPIC supported
|
|
150
|
+
return UnifiedModelProvider.ANTHROPIC
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Import unified MODEL_REGISTRY as single source of truth
|
|
154
|
+
# This import is placed here intentionally to avoid circular imports
|
|
155
|
+
from attune.models import MODEL_REGISTRY # noqa: E402
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_provider_models() -> dict[ModelProvider, dict[ModelTier, str]]:
|
|
159
|
+
"""Build PROVIDER_MODELS from MODEL_REGISTRY.
|
|
160
|
+
|
|
161
|
+
This ensures PROVIDER_MODELS stays in sync with the single source of truth.
|
|
162
|
+
"""
|
|
163
|
+
result: dict[ModelProvider, dict[ModelTier, str]] = {}
|
|
164
|
+
|
|
165
|
+
# Map string provider names to ModelProvider enum
|
|
166
|
+
provider_map = {
|
|
167
|
+
"anthropic": ModelProvider.ANTHROPIC,
|
|
168
|
+
"openai": ModelProvider.OPENAI,
|
|
169
|
+
"google": ModelProvider.GOOGLE,
|
|
170
|
+
"ollama": ModelProvider.OLLAMA,
|
|
171
|
+
"hybrid": ModelProvider.HYBRID,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Map string tier names to ModelTier enum
|
|
175
|
+
tier_map = {
|
|
176
|
+
"cheap": ModelTier.CHEAP,
|
|
177
|
+
"capable": ModelTier.CAPABLE,
|
|
178
|
+
"premium": ModelTier.PREMIUM,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for provider_str, tiers in MODEL_REGISTRY.items():
|
|
182
|
+
if provider_str not in provider_map:
|
|
183
|
+
continue # Skip custom providers
|
|
184
|
+
provider_enum = provider_map[provider_str]
|
|
185
|
+
result[provider_enum] = {}
|
|
186
|
+
for tier_str, model_info in tiers.items():
|
|
187
|
+
if tier_str in tier_map:
|
|
188
|
+
result[provider_enum][tier_map[tier_str]] = model_info.id
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Model mappings by provider and tier (derived from MODEL_REGISTRY)
|
|
194
|
+
PROVIDER_MODELS: dict[ModelProvider, dict[ModelTier, str]] = _build_provider_models()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class WorkflowStage:
|
|
199
|
+
"""Represents a single stage in a workflow."""
|
|
200
|
+
|
|
201
|
+
name: str
|
|
202
|
+
tier: ModelTier
|
|
203
|
+
description: str
|
|
204
|
+
input_tokens: int = 0
|
|
205
|
+
output_tokens: int = 0
|
|
206
|
+
cost: float = 0.0
|
|
207
|
+
result: Any = None
|
|
208
|
+
duration_ms: int = 0
|
|
209
|
+
skipped: bool = False
|
|
210
|
+
skip_reason: str | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class CostReport:
|
|
215
|
+
"""Cost breakdown for a workflow execution."""
|
|
216
|
+
|
|
217
|
+
total_cost: float
|
|
218
|
+
baseline_cost: float # If all stages used premium
|
|
219
|
+
savings: float
|
|
220
|
+
savings_percent: float
|
|
221
|
+
by_stage: dict[str, float] = field(default_factory=dict)
|
|
222
|
+
by_tier: dict[str, float] = field(default_factory=dict)
|
|
223
|
+
# Cache metrics
|
|
224
|
+
cache_hits: int = 0
|
|
225
|
+
cache_misses: int = 0
|
|
226
|
+
cache_hit_rate: float = 0.0
|
|
227
|
+
estimated_cost_without_cache: float = 0.0
|
|
228
|
+
savings_from_cache: float = 0.0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class StageQualityMetrics:
|
|
233
|
+
"""Quality metrics for stage output validation."""
|
|
234
|
+
|
|
235
|
+
execution_succeeded: bool
|
|
236
|
+
output_valid: bool
|
|
237
|
+
quality_improved: bool # Workflow-specific (e.g., health score improved)
|
|
238
|
+
error_type: str | None
|
|
239
|
+
validation_error: str | None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class WorkflowResult:
|
|
244
|
+
"""Result of a workflow execution."""
|
|
245
|
+
|
|
246
|
+
success: bool
|
|
247
|
+
stages: list[WorkflowStage]
|
|
248
|
+
final_output: Any
|
|
249
|
+
cost_report: CostReport
|
|
250
|
+
started_at: datetime
|
|
251
|
+
completed_at: datetime
|
|
252
|
+
total_duration_ms: int
|
|
253
|
+
provider: str = "unknown"
|
|
254
|
+
error: str | None = None
|
|
255
|
+
# Structured error taxonomy for reliability
|
|
256
|
+
error_type: str | None = None # "config" | "runtime" | "provider" | "timeout" | "validation"
|
|
257
|
+
transient: bool = False # True if retry is reasonable (e.g., provider timeout)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Global singleton for workflow history store (lazy-initialized)
|
|
261
|
+
_history_store: Any = None # WorkflowHistoryStore | None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _get_history_store():
|
|
265
|
+
"""Get or create workflow history store singleton.
|
|
266
|
+
|
|
267
|
+
Returns SQLite-based history store. Falls back to None if initialization fails.
|
|
268
|
+
"""
|
|
269
|
+
global _history_store
|
|
270
|
+
|
|
271
|
+
if _history_store is None:
|
|
272
|
+
try:
|
|
273
|
+
from .history import WorkflowHistoryStore
|
|
274
|
+
|
|
275
|
+
_history_store = WorkflowHistoryStore()
|
|
276
|
+
logger.debug("Workflow history store initialized (SQLite)")
|
|
277
|
+
except (ImportError, OSError, PermissionError) as e:
|
|
278
|
+
# File system errors or missing dependencies
|
|
279
|
+
logger.warning(f"Failed to initialize SQLite history store: {e}")
|
|
280
|
+
_history_store = False # Mark as failed to avoid repeated attempts
|
|
281
|
+
|
|
282
|
+
# Return store or None if initialization failed
|
|
283
|
+
return _history_store if _history_store is not False else None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _load_workflow_history(history_file: str = WORKFLOW_HISTORY_FILE) -> list[dict]:
|
|
287
|
+
"""Load workflow run history from disk (legacy JSON support).
|
|
288
|
+
|
|
289
|
+
DEPRECATED: Use WorkflowHistoryStore for new code.
|
|
290
|
+
This function is maintained for backward compatibility.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
history_file: Path to JSON history file
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of workflow run dictionaries
|
|
297
|
+
"""
|
|
298
|
+
import warnings
|
|
299
|
+
|
|
300
|
+
warnings.warn(
|
|
301
|
+
"_load_workflow_history is deprecated. Use WorkflowHistoryStore instead.",
|
|
302
|
+
DeprecationWarning,
|
|
303
|
+
stacklevel=2,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
path = Path(history_file)
|
|
307
|
+
if not path.exists():
|
|
308
|
+
return []
|
|
309
|
+
try:
|
|
310
|
+
with open(path) as f:
|
|
311
|
+
data = json.load(f)
|
|
312
|
+
return list(data) if isinstance(data, list) else []
|
|
313
|
+
except (json.JSONDecodeError, OSError):
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _save_workflow_run(
|
|
318
|
+
workflow_name: str,
|
|
319
|
+
provider: str,
|
|
320
|
+
result: WorkflowResult,
|
|
321
|
+
history_file: str = WORKFLOW_HISTORY_FILE,
|
|
322
|
+
max_history: int = 100,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Save a workflow run to history.
|
|
325
|
+
|
|
326
|
+
Uses SQLite-based storage by default. Falls back to JSON if SQLite unavailable.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
workflow_name: Name of the workflow
|
|
330
|
+
provider: Provider used (anthropic, openai, google)
|
|
331
|
+
result: WorkflowResult object
|
|
332
|
+
history_file: Legacy JSON path (ignored if SQLite available)
|
|
333
|
+
max_history: Legacy max history limit (ignored if SQLite available)
|
|
334
|
+
"""
|
|
335
|
+
# Try SQLite first (new approach)
|
|
336
|
+
store = _get_history_store()
|
|
337
|
+
if store is not None:
|
|
338
|
+
try:
|
|
339
|
+
run_id = str(uuid.uuid4())
|
|
340
|
+
store.record_run(run_id, workflow_name, provider, result)
|
|
341
|
+
logger.debug(f"Workflow run saved to SQLite: {run_id}")
|
|
342
|
+
return
|
|
343
|
+
except (OSError, PermissionError, ValueError) as e:
|
|
344
|
+
# SQLite failed, fall back to JSON
|
|
345
|
+
logger.warning(f"Failed to save to SQLite, falling back to JSON: {e}")
|
|
346
|
+
|
|
347
|
+
# Fallback: Legacy JSON storage
|
|
348
|
+
logger.debug("Using legacy JSON storage for workflow history")
|
|
349
|
+
path = Path(history_file)
|
|
350
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
|
|
352
|
+
history = []
|
|
353
|
+
if path.exists():
|
|
354
|
+
try:
|
|
355
|
+
with open(path) as f:
|
|
356
|
+
data = json.load(f)
|
|
357
|
+
history = list(data) if isinstance(data, list) else []
|
|
358
|
+
except (json.JSONDecodeError, OSError):
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
# Create run record
|
|
362
|
+
run: dict = {
|
|
363
|
+
"workflow": workflow_name,
|
|
364
|
+
"provider": provider,
|
|
365
|
+
"success": result.success,
|
|
366
|
+
"started_at": result.started_at.isoformat(),
|
|
367
|
+
"completed_at": result.completed_at.isoformat(),
|
|
368
|
+
"duration_ms": result.total_duration_ms,
|
|
369
|
+
"cost": result.cost_report.total_cost,
|
|
370
|
+
"baseline_cost": result.cost_report.baseline_cost,
|
|
371
|
+
"savings": result.cost_report.savings,
|
|
372
|
+
"savings_percent": result.cost_report.savings_percent,
|
|
373
|
+
"stages": [
|
|
374
|
+
{
|
|
375
|
+
"name": s.name,
|
|
376
|
+
"tier": s.tier.value,
|
|
377
|
+
"skipped": s.skipped,
|
|
378
|
+
"cost": s.cost,
|
|
379
|
+
"duration_ms": s.duration_ms,
|
|
380
|
+
}
|
|
381
|
+
for s in result.stages
|
|
382
|
+
],
|
|
383
|
+
"error": result.error,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# Extract XML-parsed fields from final_output if present
|
|
387
|
+
if isinstance(result.final_output, dict):
|
|
388
|
+
if result.final_output.get("xml_parsed"):
|
|
389
|
+
run["xml_parsed"] = True
|
|
390
|
+
run["summary"] = result.final_output.get("summary")
|
|
391
|
+
run["findings"] = result.final_output.get("findings", [])
|
|
392
|
+
run["checklist"] = result.final_output.get("checklist", [])
|
|
393
|
+
|
|
394
|
+
# Add to history and trim
|
|
395
|
+
history.append(run)
|
|
396
|
+
history = history[-max_history:]
|
|
397
|
+
|
|
398
|
+
validated_path = _validate_file_path(str(path))
|
|
399
|
+
with open(validated_path, "w") as f:
|
|
400
|
+
json.dump(history, f, indent=2)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def get_workflow_stats(history_file: str = WORKFLOW_HISTORY_FILE) -> dict:
|
|
404
|
+
"""Get workflow statistics for dashboard.
|
|
405
|
+
|
|
406
|
+
Uses SQLite-based storage by default. Falls back to JSON if unavailable.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
history_file: Legacy JSON path (used only if SQLite unavailable)
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Dictionary with workflow stats including:
|
|
413
|
+
- total_runs: Total workflow runs
|
|
414
|
+
- successful_runs: Number of successful runs
|
|
415
|
+
- by_workflow: Per-workflow stats
|
|
416
|
+
- by_provider: Per-provider stats
|
|
417
|
+
- by_tier: Cost breakdown by tier
|
|
418
|
+
- recent_runs: Last 10 runs
|
|
419
|
+
- total_cost: Total cost across all runs
|
|
420
|
+
- total_savings: Total cost savings
|
|
421
|
+
- avg_savings_percent: Average savings percentage
|
|
422
|
+
"""
|
|
423
|
+
# Try SQLite first (new approach)
|
|
424
|
+
store = _get_history_store()
|
|
425
|
+
if store is not None:
|
|
426
|
+
try:
|
|
427
|
+
return store.get_stats()
|
|
428
|
+
except (OSError, PermissionError, ValueError) as e:
|
|
429
|
+
# SQLite failed, fall back to JSON
|
|
430
|
+
logger.warning(f"Failed to get stats from SQLite, falling back to JSON: {e}")
|
|
431
|
+
|
|
432
|
+
# Fallback: Legacy JSON storage
|
|
433
|
+
logger.debug("Using legacy JSON storage for workflow stats")
|
|
434
|
+
history = []
|
|
435
|
+
path = Path(history_file)
|
|
436
|
+
if path.exists():
|
|
437
|
+
try:
|
|
438
|
+
with open(path) as f:
|
|
439
|
+
data = json.load(f)
|
|
440
|
+
history = list(data) if isinstance(data, list) else []
|
|
441
|
+
except (json.JSONDecodeError, OSError):
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
if not history:
|
|
445
|
+
return {
|
|
446
|
+
"total_runs": 0,
|
|
447
|
+
"successful_runs": 0,
|
|
448
|
+
"by_workflow": {},
|
|
449
|
+
"by_provider": {},
|
|
450
|
+
"by_tier": {"cheap": 0, "capable": 0, "premium": 0},
|
|
451
|
+
"recent_runs": [],
|
|
452
|
+
"total_cost": 0.0,
|
|
453
|
+
"total_savings": 0.0,
|
|
454
|
+
"avg_savings_percent": 0.0,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
# Aggregate stats
|
|
458
|
+
by_workflow: dict[str, dict] = {}
|
|
459
|
+
by_provider: dict[str, dict] = {}
|
|
460
|
+
by_tier: dict[str, float] = {"cheap": 0.0, "capable": 0.0, "premium": 0.0}
|
|
461
|
+
total_cost = 0.0
|
|
462
|
+
total_savings = 0.0
|
|
463
|
+
successful_runs = 0
|
|
464
|
+
|
|
465
|
+
for run in history:
|
|
466
|
+
wf_name = run.get("workflow", "unknown")
|
|
467
|
+
provider = run.get("provider", "unknown")
|
|
468
|
+
cost = run.get("cost", 0.0)
|
|
469
|
+
savings = run.get("savings", 0.0)
|
|
470
|
+
|
|
471
|
+
# By workflow
|
|
472
|
+
if wf_name not in by_workflow:
|
|
473
|
+
by_workflow[wf_name] = {"runs": 0, "cost": 0.0, "savings": 0.0, "success": 0}
|
|
474
|
+
by_workflow[wf_name]["runs"] += 1
|
|
475
|
+
by_workflow[wf_name]["cost"] += cost
|
|
476
|
+
by_workflow[wf_name]["savings"] += savings
|
|
477
|
+
if run.get("success"):
|
|
478
|
+
by_workflow[wf_name]["success"] += 1
|
|
479
|
+
|
|
480
|
+
# By provider
|
|
481
|
+
if provider not in by_provider:
|
|
482
|
+
by_provider[provider] = {"runs": 0, "cost": 0.0}
|
|
483
|
+
by_provider[provider]["runs"] += 1
|
|
484
|
+
by_provider[provider]["cost"] += cost
|
|
485
|
+
|
|
486
|
+
# By tier (from stages)
|
|
487
|
+
for stage in run.get("stages", []):
|
|
488
|
+
if not stage.get("skipped"):
|
|
489
|
+
tier = stage.get("tier", "capable")
|
|
490
|
+
by_tier[tier] = by_tier.get(tier, 0.0) + stage.get("cost", 0.0)
|
|
491
|
+
|
|
492
|
+
total_cost += cost
|
|
493
|
+
total_savings += savings
|
|
494
|
+
if run.get("success"):
|
|
495
|
+
successful_runs += 1
|
|
496
|
+
|
|
497
|
+
# Calculate average savings percent
|
|
498
|
+
avg_savings_percent = 0.0
|
|
499
|
+
if history:
|
|
500
|
+
savings_percents = [r.get("savings_percent", 0) for r in history if r.get("success")]
|
|
501
|
+
if savings_percents:
|
|
502
|
+
avg_savings_percent = sum(savings_percents) / len(savings_percents)
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
"total_runs": len(history),
|
|
506
|
+
"successful_runs": successful_runs,
|
|
507
|
+
"by_workflow": by_workflow,
|
|
508
|
+
"by_provider": by_provider,
|
|
509
|
+
"by_tier": by_tier,
|
|
510
|
+
"recent_runs": history[-10:][::-1], # Last 10, most recent first
|
|
511
|
+
"total_cost": total_cost,
|
|
512
|
+
"total_savings": total_savings,
|
|
513
|
+
"avg_savings_percent": avg_savings_percent,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class BaseWorkflow(CachingMixin, TelemetryMixin, ABC):
|
|
518
|
+
"""Base class for multi-model workflows.
|
|
519
|
+
|
|
520
|
+
Inherits from CachingMixin and TelemetryMixin (extracted for maintainability).
|
|
521
|
+
|
|
522
|
+
Subclasses define stages and tier mappings:
|
|
523
|
+
|
|
524
|
+
class MyWorkflow(BaseWorkflow):
|
|
525
|
+
name = "my-workflow"
|
|
526
|
+
description = "Does something useful"
|
|
527
|
+
stages = ["stage1", "stage2", "stage3"]
|
|
528
|
+
tier_map = {
|
|
529
|
+
"stage1": ModelTier.CHEAP,
|
|
530
|
+
"stage2": ModelTier.CAPABLE,
|
|
531
|
+
"stage3": ModelTier.PREMIUM,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async def run_stage(self, stage_name, tier, input_data):
|
|
535
|
+
# Implement stage logic
|
|
536
|
+
return output_data
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
name: str = "base-workflow"
|
|
540
|
+
description: str = "Base workflow template"
|
|
541
|
+
stages: list[str] = []
|
|
542
|
+
tier_map: dict[str, ModelTier] = {}
|
|
543
|
+
|
|
544
|
+
def __init__(
|
|
545
|
+
self,
|
|
546
|
+
cost_tracker: CostTracker | None = None,
|
|
547
|
+
provider: ModelProvider | str | None = None,
|
|
548
|
+
config: WorkflowConfig | None = None,
|
|
549
|
+
executor: LLMExecutor | None = None,
|
|
550
|
+
telemetry_backend: TelemetryBackend | None = None,
|
|
551
|
+
progress_callback: ProgressCallback | None = None,
|
|
552
|
+
cache: BaseCache | None = None,
|
|
553
|
+
enable_cache: bool = True,
|
|
554
|
+
enable_tier_tracking: bool = True,
|
|
555
|
+
enable_tier_fallback: bool = False,
|
|
556
|
+
routing_strategy: TierRoutingStrategy | None = None,
|
|
557
|
+
enable_rich_progress: bool = False,
|
|
558
|
+
enable_adaptive_routing: bool = False,
|
|
559
|
+
enable_heartbeat_tracking: bool = False,
|
|
560
|
+
enable_coordination: bool = False,
|
|
561
|
+
agent_id: str | None = None,
|
|
562
|
+
):
|
|
563
|
+
"""Initialize workflow with optional cost tracker, provider, and config.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
cost_tracker: CostTracker instance for logging costs
|
|
567
|
+
provider: Model provider (anthropic, openai, ollama) or ModelProvider enum.
|
|
568
|
+
If None, uses config or defaults to anthropic.
|
|
569
|
+
config: WorkflowConfig for model customization. If None, loads from
|
|
570
|
+
.attune/workflows.yaml or uses defaults.
|
|
571
|
+
executor: LLMExecutor for abstracted LLM calls (optional).
|
|
572
|
+
If provided, enables unified execution with telemetry.
|
|
573
|
+
telemetry_backend: TelemetryBackend for storing telemetry records.
|
|
574
|
+
Defaults to TelemetryStore (JSONL file backend).
|
|
575
|
+
progress_callback: Callback for real-time progress updates.
|
|
576
|
+
If provided, enables live progress tracking during execution.
|
|
577
|
+
cache: Optional cache instance. If None and enable_cache=True,
|
|
578
|
+
auto-creates cache with one-time setup prompt.
|
|
579
|
+
enable_cache: Whether to enable caching (default True).
|
|
580
|
+
enable_tier_tracking: Whether to enable automatic tier tracking (default True).
|
|
581
|
+
enable_tier_fallback: Whether to enable intelligent tier fallback
|
|
582
|
+
(CHEAP → CAPABLE → PREMIUM). Opt-in feature (default False).
|
|
583
|
+
routing_strategy: Optional TierRoutingStrategy for dynamic tier selection.
|
|
584
|
+
When provided, overrides static tier_map for stage tier decisions.
|
|
585
|
+
Strategies: CostOptimizedRouting, PerformanceOptimizedRouting,
|
|
586
|
+
BalancedRouting, HybridRouting.
|
|
587
|
+
enable_rich_progress: Whether to enable Rich-based live progress display
|
|
588
|
+
(default False). When enabled and output is a TTY, shows live
|
|
589
|
+
progress bars with spinners. Default is False because most users
|
|
590
|
+
run workflows from IDEs (VSCode, etc.) where TTY is not available.
|
|
591
|
+
The console reporter works reliably in all environments.
|
|
592
|
+
enable_adaptive_routing: Whether to enable adaptive model routing based
|
|
593
|
+
on telemetry history (default False). When enabled, uses historical
|
|
594
|
+
performance data to select the optimal Anthropic model for each stage,
|
|
595
|
+
automatically upgrading tiers when failure rates exceed 20%.
|
|
596
|
+
Opt-in feature for cost optimization and automatic quality improvement.
|
|
597
|
+
enable_heartbeat_tracking: Whether to enable agent heartbeat tracking
|
|
598
|
+
(default False). When enabled, publishes TTL-based heartbeat updates
|
|
599
|
+
to Redis for agent liveness monitoring. Requires Redis backend.
|
|
600
|
+
Pattern 1 from Agent Coordination Architecture.
|
|
601
|
+
enable_coordination: Whether to enable inter-agent coordination signals
|
|
602
|
+
(default False). When enabled, workflow can send and receive TTL-based
|
|
603
|
+
ephemeral signals for agent-to-agent communication. Requires Redis backend.
|
|
604
|
+
Pattern 2 from Agent Coordination Architecture.
|
|
605
|
+
agent_id: Optional agent ID for heartbeat tracking and coordination.
|
|
606
|
+
If None, auto-generates ID from workflow name and run ID.
|
|
607
|
+
Used as identifier in Redis keys (heartbeat:{agent_id}, signal:{agent_id}:...).
|
|
608
|
+
|
|
609
|
+
"""
|
|
610
|
+
from .config import WorkflowConfig
|
|
611
|
+
|
|
612
|
+
self.cost_tracker = cost_tracker or CostTracker()
|
|
613
|
+
self._stages_run: list[WorkflowStage] = []
|
|
614
|
+
|
|
615
|
+
# Progress tracking
|
|
616
|
+
self._progress_callback = progress_callback
|
|
617
|
+
self._progress_tracker: ProgressTracker | None = None
|
|
618
|
+
self._enable_rich_progress = enable_rich_progress
|
|
619
|
+
self._rich_reporter: RichProgressReporter | None = None
|
|
620
|
+
|
|
621
|
+
# New: LLMExecutor support
|
|
622
|
+
self._executor = executor
|
|
623
|
+
self._api_key: str | None = None # For default executor creation
|
|
624
|
+
|
|
625
|
+
# Cache support
|
|
626
|
+
self._cache: BaseCache | None = cache
|
|
627
|
+
self._enable_cache = enable_cache
|
|
628
|
+
self._cache_setup_attempted = False
|
|
629
|
+
|
|
630
|
+
# Tier tracking support
|
|
631
|
+
self._enable_tier_tracking = enable_tier_tracking
|
|
632
|
+
self._tier_tracker: WorkflowTierTracker | None = None
|
|
633
|
+
|
|
634
|
+
# Tier fallback support
|
|
635
|
+
self._enable_tier_fallback = enable_tier_fallback
|
|
636
|
+
self._tier_progression: list[tuple[str, str, bool]] = [] # (stage, tier, success)
|
|
637
|
+
|
|
638
|
+
# Routing strategy support
|
|
639
|
+
self._routing_strategy: TierRoutingStrategy | None = routing_strategy
|
|
640
|
+
|
|
641
|
+
# Adaptive routing support (Pattern 3 from AGENT_COORDINATION_ARCHITECTURE)
|
|
642
|
+
self._enable_adaptive_routing = enable_adaptive_routing
|
|
643
|
+
self._adaptive_router = None # Lazy initialization on first use
|
|
644
|
+
|
|
645
|
+
# Agent tracking and coordination (Pattern 1 & 2 from AGENT_COORDINATION_ARCHITECTURE)
|
|
646
|
+
self._enable_heartbeat_tracking = enable_heartbeat_tracking
|
|
647
|
+
self._enable_coordination = enable_coordination
|
|
648
|
+
self._agent_id = agent_id # Will be set during execute() if None
|
|
649
|
+
self._heartbeat_coordinator = None # Lazy initialization on first use
|
|
650
|
+
self._coordination_signals = None # Lazy initialization on first use
|
|
651
|
+
|
|
652
|
+
# Telemetry tracking (uses TelemetryMixin)
|
|
653
|
+
self._init_telemetry(telemetry_backend)
|
|
654
|
+
|
|
655
|
+
# Load config if not provided
|
|
656
|
+
self._config = config or WorkflowConfig.load()
|
|
657
|
+
|
|
658
|
+
# Determine provider (priority: arg > config > default)
|
|
659
|
+
if provider is None:
|
|
660
|
+
provider = self._config.get_provider_for_workflow(self.name)
|
|
661
|
+
|
|
662
|
+
# Handle string provider input
|
|
663
|
+
if isinstance(provider, str):
|
|
664
|
+
provider_str = provider.lower()
|
|
665
|
+
try:
|
|
666
|
+
provider = ModelProvider(provider_str)
|
|
667
|
+
self._provider_str = provider_str
|
|
668
|
+
except ValueError:
|
|
669
|
+
# Custom provider, keep as string
|
|
670
|
+
self._provider_str = provider_str
|
|
671
|
+
provider = ModelProvider.CUSTOM
|
|
672
|
+
else:
|
|
673
|
+
self._provider_str = provider.value
|
|
674
|
+
|
|
675
|
+
self.provider = provider
|
|
676
|
+
|
|
677
|
+
def get_tier_for_stage(self, stage_name: str) -> ModelTier:
|
|
678
|
+
"""Get the model tier for a stage from static tier_map."""
|
|
679
|
+
return self.tier_map.get(stage_name, ModelTier.CAPABLE)
|
|
680
|
+
|
|
681
|
+
def _get_adaptive_router(self):
|
|
682
|
+
"""Get or create AdaptiveModelRouter instance (lazy initialization).
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
AdaptiveModelRouter instance if telemetry is available, None otherwise
|
|
686
|
+
"""
|
|
687
|
+
if not self._enable_adaptive_routing:
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
if self._adaptive_router is None:
|
|
691
|
+
# Lazy import to avoid circular dependencies
|
|
692
|
+
try:
|
|
693
|
+
from attune.models import AdaptiveModelRouter
|
|
694
|
+
|
|
695
|
+
if TELEMETRY_AVAILABLE and UsageTracker is not None:
|
|
696
|
+
self._adaptive_router = AdaptiveModelRouter(
|
|
697
|
+
telemetry=UsageTracker.get_instance()
|
|
698
|
+
)
|
|
699
|
+
logger.debug(
|
|
700
|
+
"adaptive_routing_initialized",
|
|
701
|
+
workflow=self.name,
|
|
702
|
+
message="Adaptive routing enabled for cost optimization"
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
logger.warning(
|
|
706
|
+
"adaptive_routing_unavailable",
|
|
707
|
+
workflow=self.name,
|
|
708
|
+
message="Telemetry not available, adaptive routing disabled"
|
|
709
|
+
)
|
|
710
|
+
self._enable_adaptive_routing = False
|
|
711
|
+
except ImportError as e:
|
|
712
|
+
logger.warning(
|
|
713
|
+
"adaptive_routing_import_error",
|
|
714
|
+
workflow=self.name,
|
|
715
|
+
error=str(e),
|
|
716
|
+
message="Failed to import AdaptiveModelRouter"
|
|
717
|
+
)
|
|
718
|
+
self._enable_adaptive_routing = False
|
|
719
|
+
|
|
720
|
+
return self._adaptive_router
|
|
721
|
+
|
|
722
|
+
def _get_heartbeat_coordinator(self):
|
|
723
|
+
"""Get or create HeartbeatCoordinator instance (lazy initialization).
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
HeartbeatCoordinator instance if heartbeat tracking is enabled, None otherwise
|
|
727
|
+
"""
|
|
728
|
+
if not self._enable_heartbeat_tracking:
|
|
729
|
+
return None
|
|
730
|
+
|
|
731
|
+
if self._heartbeat_coordinator is None:
|
|
732
|
+
try:
|
|
733
|
+
from attune.telemetry import HeartbeatCoordinator
|
|
734
|
+
|
|
735
|
+
self._heartbeat_coordinator = HeartbeatCoordinator()
|
|
736
|
+
logger.debug(
|
|
737
|
+
"heartbeat_tracking_initialized",
|
|
738
|
+
workflow=self.name,
|
|
739
|
+
agent_id=self._agent_id,
|
|
740
|
+
message="Heartbeat tracking enabled for agent liveness monitoring"
|
|
741
|
+
)
|
|
742
|
+
except ImportError as e:
|
|
743
|
+
logger.warning(
|
|
744
|
+
"heartbeat_tracking_import_error",
|
|
745
|
+
workflow=self.name,
|
|
746
|
+
error=str(e),
|
|
747
|
+
message="Failed to import HeartbeatCoordinator"
|
|
748
|
+
)
|
|
749
|
+
self._enable_heartbeat_tracking = False
|
|
750
|
+
except Exception as e:
|
|
751
|
+
logger.warning(
|
|
752
|
+
"heartbeat_tracking_init_error",
|
|
753
|
+
workflow=self.name,
|
|
754
|
+
error=str(e),
|
|
755
|
+
message="Failed to initialize HeartbeatCoordinator (Redis unavailable?)"
|
|
756
|
+
)
|
|
757
|
+
self._enable_heartbeat_tracking = False
|
|
758
|
+
|
|
759
|
+
return self._heartbeat_coordinator
|
|
760
|
+
|
|
761
|
+
def _get_coordination_signals(self):
|
|
762
|
+
"""Get or create CoordinationSignals instance (lazy initialization).
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
CoordinationSignals instance if coordination is enabled, None otherwise
|
|
766
|
+
"""
|
|
767
|
+
if not self._enable_coordination:
|
|
768
|
+
return None
|
|
769
|
+
|
|
770
|
+
if self._coordination_signals is None:
|
|
771
|
+
try:
|
|
772
|
+
from attune.telemetry import CoordinationSignals
|
|
773
|
+
|
|
774
|
+
self._coordination_signals = CoordinationSignals(agent_id=self._agent_id)
|
|
775
|
+
logger.debug(
|
|
776
|
+
"coordination_initialized",
|
|
777
|
+
workflow=self.name,
|
|
778
|
+
agent_id=self._agent_id,
|
|
779
|
+
message="Coordination signals enabled for inter-agent communication"
|
|
780
|
+
)
|
|
781
|
+
except ImportError as e:
|
|
782
|
+
logger.warning(
|
|
783
|
+
"coordination_import_error",
|
|
784
|
+
workflow=self.name,
|
|
785
|
+
error=str(e),
|
|
786
|
+
message="Failed to import CoordinationSignals"
|
|
787
|
+
)
|
|
788
|
+
self._enable_coordination = False
|
|
789
|
+
except Exception as e:
|
|
790
|
+
logger.warning(
|
|
791
|
+
"coordination_init_error",
|
|
792
|
+
workflow=self.name,
|
|
793
|
+
error=str(e),
|
|
794
|
+
message="Failed to initialize CoordinationSignals (Redis unavailable?)"
|
|
795
|
+
)
|
|
796
|
+
self._enable_coordination = False
|
|
797
|
+
|
|
798
|
+
return self._coordination_signals
|
|
799
|
+
|
|
800
|
+
def _check_adaptive_tier_upgrade(self, stage_name: str, current_tier: ModelTier) -> ModelTier:
|
|
801
|
+
"""Check if adaptive routing recommends a tier upgrade.
|
|
802
|
+
|
|
803
|
+
Uses historical telemetry to detect if the current tier has a high
|
|
804
|
+
failure rate (>20%) and automatically upgrades to the next tier.
|
|
805
|
+
|
|
806
|
+
Args:
|
|
807
|
+
stage_name: Name of the stage
|
|
808
|
+
current_tier: Currently selected tier
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
Upgraded tier if recommended, otherwise current_tier
|
|
812
|
+
"""
|
|
813
|
+
router = self._get_adaptive_router()
|
|
814
|
+
if router is None:
|
|
815
|
+
return current_tier
|
|
816
|
+
|
|
817
|
+
# Check if tier upgrade is recommended
|
|
818
|
+
should_upgrade, reason = router.recommend_tier_upgrade(
|
|
819
|
+
workflow=self.name,
|
|
820
|
+
stage=stage_name
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
if should_upgrade:
|
|
824
|
+
# Upgrade to next tier: CHEAP → CAPABLE → PREMIUM
|
|
825
|
+
if current_tier == ModelTier.CHEAP:
|
|
826
|
+
new_tier = ModelTier.CAPABLE
|
|
827
|
+
elif current_tier == ModelTier.CAPABLE:
|
|
828
|
+
new_tier = ModelTier.PREMIUM
|
|
829
|
+
else:
|
|
830
|
+
new_tier = current_tier # Already at highest tier
|
|
831
|
+
|
|
832
|
+
logger.warning(
|
|
833
|
+
"adaptive_routing_tier_upgrade",
|
|
834
|
+
workflow=self.name,
|
|
835
|
+
stage=stage_name,
|
|
836
|
+
old_tier=current_tier.value,
|
|
837
|
+
new_tier=new_tier.value,
|
|
838
|
+
reason=reason
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
return new_tier
|
|
842
|
+
|
|
843
|
+
return current_tier
|
|
844
|
+
|
|
845
|
+
def send_signal(
|
|
846
|
+
self,
|
|
847
|
+
signal_type: str,
|
|
848
|
+
target_agent: str | None = None,
|
|
849
|
+
payload: dict[str, Any] | None = None,
|
|
850
|
+
ttl_seconds: int | None = None,
|
|
851
|
+
) -> str:
|
|
852
|
+
"""Send a coordination signal to another agent (Pattern 2).
|
|
853
|
+
|
|
854
|
+
Args:
|
|
855
|
+
signal_type: Type of signal (e.g., "task_complete", "checkpoint", "error")
|
|
856
|
+
target_agent: Target agent ID (None for broadcast to all agents)
|
|
857
|
+
payload: Optional signal payload data
|
|
858
|
+
ttl_seconds: Optional TTL override (default 60 seconds)
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
Signal ID if coordination is enabled, empty string otherwise
|
|
862
|
+
|
|
863
|
+
Example:
|
|
864
|
+
>>> # Signal completion to orchestrator
|
|
865
|
+
>>> workflow.send_signal(
|
|
866
|
+
... signal_type="task_complete",
|
|
867
|
+
... target_agent="orchestrator",
|
|
868
|
+
... payload={"result": "success", "data": {...}}
|
|
869
|
+
... )
|
|
870
|
+
|
|
871
|
+
>>> # Broadcast abort to all agents
|
|
872
|
+
>>> workflow.send_signal(
|
|
873
|
+
... signal_type="abort",
|
|
874
|
+
... target_agent=None, # Broadcast
|
|
875
|
+
... payload={"reason": "user_cancelled"}
|
|
876
|
+
... )
|
|
877
|
+
"""
|
|
878
|
+
coordinator = self._get_coordination_signals()
|
|
879
|
+
if coordinator is None:
|
|
880
|
+
return ""
|
|
881
|
+
|
|
882
|
+
try:
|
|
883
|
+
return coordinator.signal(
|
|
884
|
+
signal_type=signal_type,
|
|
885
|
+
source_agent=self._agent_id,
|
|
886
|
+
target_agent=target_agent,
|
|
887
|
+
payload=payload or {},
|
|
888
|
+
ttl_seconds=ttl_seconds,
|
|
889
|
+
)
|
|
890
|
+
except Exception as e:
|
|
891
|
+
logger.warning(f"Failed to send coordination signal: {e}")
|
|
892
|
+
return ""
|
|
893
|
+
|
|
894
|
+
def wait_for_signal(
|
|
895
|
+
self,
|
|
896
|
+
signal_type: str,
|
|
897
|
+
source_agent: str | None = None,
|
|
898
|
+
timeout: float = 30.0,
|
|
899
|
+
poll_interval: float = 0.5,
|
|
900
|
+
) -> Any:
|
|
901
|
+
"""Wait for a coordination signal from another agent (Pattern 2).
|
|
902
|
+
|
|
903
|
+
Blocking call that polls for signals with timeout.
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
signal_type: Type of signal to wait for
|
|
907
|
+
source_agent: Optional source agent filter
|
|
908
|
+
timeout: Maximum wait time in seconds (default 30.0)
|
|
909
|
+
poll_interval: Poll interval in seconds (default 0.5)
|
|
910
|
+
|
|
911
|
+
Returns:
|
|
912
|
+
CoordinationSignal if received, None if timeout or coordination disabled
|
|
913
|
+
|
|
914
|
+
Example:
|
|
915
|
+
>>> # Wait for orchestrator approval
|
|
916
|
+
>>> signal = workflow.wait_for_signal(
|
|
917
|
+
... signal_type="approval",
|
|
918
|
+
... source_agent="orchestrator",
|
|
919
|
+
... timeout=60.0
|
|
920
|
+
... )
|
|
921
|
+
>>> if signal:
|
|
922
|
+
... proceed_with_deployment(signal.payload)
|
|
923
|
+
"""
|
|
924
|
+
coordinator = self._get_coordination_signals()
|
|
925
|
+
if coordinator is None:
|
|
926
|
+
return None
|
|
927
|
+
|
|
928
|
+
try:
|
|
929
|
+
return coordinator.wait_for_signal(
|
|
930
|
+
signal_type=signal_type,
|
|
931
|
+
source_agent=source_agent,
|
|
932
|
+
timeout=timeout,
|
|
933
|
+
poll_interval=poll_interval,
|
|
934
|
+
)
|
|
935
|
+
except Exception as e:
|
|
936
|
+
logger.warning(f"Failed to wait for coordination signal: {e}")
|
|
937
|
+
return None
|
|
938
|
+
|
|
939
|
+
def check_signal(
|
|
940
|
+
self,
|
|
941
|
+
signal_type: str,
|
|
942
|
+
source_agent: str | None = None,
|
|
943
|
+
consume: bool = True,
|
|
944
|
+
) -> Any:
|
|
945
|
+
"""Check for a coordination signal without blocking (Pattern 2).
|
|
946
|
+
|
|
947
|
+
Non-blocking check for pending signals.
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
signal_type: Type of signal to check for
|
|
951
|
+
source_agent: Optional source agent filter
|
|
952
|
+
consume: If True, remove signal after reading (default True)
|
|
953
|
+
|
|
954
|
+
Returns:
|
|
955
|
+
CoordinationSignal if available, None otherwise
|
|
956
|
+
|
|
957
|
+
Example:
|
|
958
|
+
>>> # Non-blocking check for abort signal
|
|
959
|
+
>>> signal = workflow.check_signal(signal_type="abort")
|
|
960
|
+
>>> if signal:
|
|
961
|
+
... raise WorkflowAbortedException(signal.payload["reason"])
|
|
962
|
+
"""
|
|
963
|
+
coordinator = self._get_coordination_signals()
|
|
964
|
+
if coordinator is None:
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
try:
|
|
968
|
+
return coordinator.check_signal(
|
|
969
|
+
signal_type=signal_type,
|
|
970
|
+
source_agent=source_agent,
|
|
971
|
+
consume=consume,
|
|
972
|
+
)
|
|
973
|
+
except Exception as e:
|
|
974
|
+
logger.warning(f"Failed to check coordination signal: {e}")
|
|
975
|
+
return None
|
|
976
|
+
|
|
977
|
+
def _get_tier_with_routing(
|
|
978
|
+
self,
|
|
979
|
+
stage_name: str,
|
|
980
|
+
input_data: dict[str, Any],
|
|
981
|
+
budget_remaining: float = 100.0,
|
|
982
|
+
) -> ModelTier:
|
|
983
|
+
"""Get tier for a stage using routing strategy or adaptive routing if available.
|
|
984
|
+
|
|
985
|
+
Priority order:
|
|
986
|
+
1. If routing_strategy configured, uses that for tier selection
|
|
987
|
+
2. Otherwise uses static tier_map
|
|
988
|
+
3. If adaptive routing enabled, checks for tier upgrade recommendations
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
stage_name: Name of the stage
|
|
992
|
+
input_data: Current workflow data (used to estimate input size)
|
|
993
|
+
budget_remaining: Remaining budget in USD for this execution
|
|
994
|
+
|
|
995
|
+
Returns:
|
|
996
|
+
ModelTier to use for this stage (potentially upgraded by adaptive routing)
|
|
997
|
+
"""
|
|
998
|
+
# Get base tier from routing strategy or static map
|
|
999
|
+
if self._routing_strategy is not None:
|
|
1000
|
+
from .routing import RoutingContext
|
|
1001
|
+
|
|
1002
|
+
# Estimate input size from data
|
|
1003
|
+
input_size = self._estimate_input_tokens(input_data)
|
|
1004
|
+
|
|
1005
|
+
# Assess complexity
|
|
1006
|
+
complexity = self._assess_complexity(input_data)
|
|
1007
|
+
|
|
1008
|
+
# Determine latency sensitivity based on stage position
|
|
1009
|
+
# First stages are more latency-sensitive (user waiting)
|
|
1010
|
+
stage_index = self.stages.index(stage_name) if stage_name in self.stages else 0
|
|
1011
|
+
if stage_index == 0:
|
|
1012
|
+
latency_sensitivity = "high"
|
|
1013
|
+
elif stage_index < len(self.stages) // 2:
|
|
1014
|
+
latency_sensitivity = "medium"
|
|
1015
|
+
else:
|
|
1016
|
+
latency_sensitivity = "low"
|
|
1017
|
+
|
|
1018
|
+
# Create routing context
|
|
1019
|
+
context = RoutingContext(
|
|
1020
|
+
task_type=f"{self.name}:{stage_name}",
|
|
1021
|
+
input_size=input_size,
|
|
1022
|
+
complexity=complexity,
|
|
1023
|
+
budget_remaining=budget_remaining,
|
|
1024
|
+
latency_sensitivity=latency_sensitivity,
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
# Delegate to routing strategy
|
|
1028
|
+
base_tier = self._routing_strategy.route(context)
|
|
1029
|
+
else:
|
|
1030
|
+
# Use static tier_map
|
|
1031
|
+
base_tier = self.get_tier_for_stage(stage_name)
|
|
1032
|
+
|
|
1033
|
+
# Check if adaptive routing recommends a tier upgrade
|
|
1034
|
+
# This uses telemetry history to detect high failure rates
|
|
1035
|
+
if self._enable_adaptive_routing:
|
|
1036
|
+
final_tier = self._check_adaptive_tier_upgrade(stage_name, base_tier)
|
|
1037
|
+
return final_tier
|
|
1038
|
+
|
|
1039
|
+
return base_tier
|
|
1040
|
+
|
|
1041
|
+
def _estimate_input_tokens(self, input_data: dict[str, Any]) -> int:
|
|
1042
|
+
"""Estimate input token count from data.
|
|
1043
|
+
|
|
1044
|
+
Simple heuristic: ~4 characters per token on average.
|
|
1045
|
+
|
|
1046
|
+
Args:
|
|
1047
|
+
input_data: Workflow input data
|
|
1048
|
+
|
|
1049
|
+
Returns:
|
|
1050
|
+
Estimated token count
|
|
1051
|
+
"""
|
|
1052
|
+
import json
|
|
1053
|
+
|
|
1054
|
+
try:
|
|
1055
|
+
# Serialize to estimate size
|
|
1056
|
+
data_str = json.dumps(input_data, default=str)
|
|
1057
|
+
return len(data_str) // 4
|
|
1058
|
+
except (TypeError, ValueError):
|
|
1059
|
+
return 1000 # Default estimate
|
|
1060
|
+
|
|
1061
|
+
def get_model_for_tier(self, tier: ModelTier) -> str:
|
|
1062
|
+
"""Get the model for a tier based on configured provider and config."""
|
|
1063
|
+
from .config import get_model
|
|
1064
|
+
|
|
1065
|
+
provider_str = getattr(self, "_provider_str", self.provider.value)
|
|
1066
|
+
|
|
1067
|
+
# Use config-aware model lookup
|
|
1068
|
+
model = get_model(provider_str, tier.value, self._config)
|
|
1069
|
+
return model
|
|
1070
|
+
|
|
1071
|
+
# Note: _maybe_setup_cache is inherited from CachingMixin
|
|
1072
|
+
|
|
1073
|
+
async def _call_llm(
|
|
1074
|
+
self,
|
|
1075
|
+
tier: ModelTier,
|
|
1076
|
+
system: str,
|
|
1077
|
+
user_message: str,
|
|
1078
|
+
max_tokens: int = 4096,
|
|
1079
|
+
stage_name: str | None = None,
|
|
1080
|
+
) -> tuple[str, int, int]:
|
|
1081
|
+
"""Provider-agnostic LLM call using the configured provider.
|
|
1082
|
+
|
|
1083
|
+
This method uses run_step_with_executor internally to make LLM calls
|
|
1084
|
+
that respect the configured provider (anthropic, openai, google, etc.).
|
|
1085
|
+
|
|
1086
|
+
Supports automatic caching to reduce API costs and latency.
|
|
1087
|
+
Tracks telemetry for usage analysis and cost savings measurement.
|
|
1088
|
+
|
|
1089
|
+
Args:
|
|
1090
|
+
tier: Model tier to use (CHEAP, CAPABLE, PREMIUM)
|
|
1091
|
+
system: System prompt
|
|
1092
|
+
user_message: User message/prompt
|
|
1093
|
+
max_tokens: Maximum tokens in response
|
|
1094
|
+
stage_name: Optional stage name for cache key (defaults to tier)
|
|
1095
|
+
|
|
1096
|
+
Returns:
|
|
1097
|
+
Tuple of (response_content, input_tokens, output_tokens)
|
|
1098
|
+
|
|
1099
|
+
"""
|
|
1100
|
+
from .step_config import WorkflowStepConfig
|
|
1101
|
+
|
|
1102
|
+
# Start timing for telemetry
|
|
1103
|
+
start_time = time.time()
|
|
1104
|
+
|
|
1105
|
+
# Determine stage name for cache key
|
|
1106
|
+
stage = stage_name or f"llm_call_{tier.value}"
|
|
1107
|
+
model = self.get_model_for_tier(tier)
|
|
1108
|
+
cache_type = None
|
|
1109
|
+
|
|
1110
|
+
# Try cache lookup using CachingMixin
|
|
1111
|
+
cached = self._try_cache_lookup(stage, system, user_message, model)
|
|
1112
|
+
if cached is not None:
|
|
1113
|
+
# Track telemetry for cache hit
|
|
1114
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
1115
|
+
cost = self._calculate_cost(tier, cached.input_tokens, cached.output_tokens)
|
|
1116
|
+
cache_type = self._get_cache_type()
|
|
1117
|
+
|
|
1118
|
+
self._track_telemetry(
|
|
1119
|
+
stage=stage,
|
|
1120
|
+
tier=tier,
|
|
1121
|
+
model=model,
|
|
1122
|
+
cost=cost,
|
|
1123
|
+
tokens={"input": cached.input_tokens, "output": cached.output_tokens},
|
|
1124
|
+
cache_hit=True,
|
|
1125
|
+
cache_type=cache_type,
|
|
1126
|
+
duration_ms=duration_ms,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
return (cached.content, cached.input_tokens, cached.output_tokens)
|
|
1130
|
+
|
|
1131
|
+
# Create a step config for this call
|
|
1132
|
+
step = WorkflowStepConfig(
|
|
1133
|
+
name=stage,
|
|
1134
|
+
task_type="general",
|
|
1135
|
+
tier_hint=tier.value,
|
|
1136
|
+
description="LLM call",
|
|
1137
|
+
max_tokens=max_tokens,
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
try:
|
|
1141
|
+
content, in_tokens, out_tokens, cost = await self.run_step_with_executor(
|
|
1142
|
+
step=step,
|
|
1143
|
+
prompt=user_message,
|
|
1144
|
+
system=system,
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
# Calculate duration
|
|
1148
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
1149
|
+
|
|
1150
|
+
# Track telemetry for actual LLM call
|
|
1151
|
+
self._track_telemetry(
|
|
1152
|
+
stage=stage,
|
|
1153
|
+
tier=tier,
|
|
1154
|
+
model=model,
|
|
1155
|
+
cost=cost,
|
|
1156
|
+
tokens={"input": in_tokens, "output": out_tokens},
|
|
1157
|
+
cache_hit=False,
|
|
1158
|
+
cache_type=None,
|
|
1159
|
+
duration_ms=duration_ms,
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
# Store in cache using CachingMixin
|
|
1163
|
+
self._store_in_cache(
|
|
1164
|
+
stage,
|
|
1165
|
+
system,
|
|
1166
|
+
user_message,
|
|
1167
|
+
model,
|
|
1168
|
+
CachedResponse(content=content, input_tokens=in_tokens, output_tokens=out_tokens),
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
return content, in_tokens, out_tokens
|
|
1172
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
1173
|
+
# Invalid input or configuration errors
|
|
1174
|
+
logger.warning(f"LLM call failed (invalid input): {e}")
|
|
1175
|
+
return f"Error calling LLM (invalid input): {e}", 0, 0
|
|
1176
|
+
except (TimeoutError, RuntimeError, ConnectionError) as e:
|
|
1177
|
+
# Timeout, API errors, or connection failures
|
|
1178
|
+
logger.warning(f"LLM call failed (timeout/API/connection error): {e}")
|
|
1179
|
+
return f"Error calling LLM (timeout/API error): {e}", 0, 0
|
|
1180
|
+
except (OSError, PermissionError) as e:
|
|
1181
|
+
# File system or permission errors
|
|
1182
|
+
logger.warning(f"LLM call failed (file system error): {e}")
|
|
1183
|
+
return f"Error calling LLM (file system error): {e}", 0, 0
|
|
1184
|
+
except Exception as e:
|
|
1185
|
+
# INTENTIONAL: Graceful degradation - return error message rather than crashing workflow
|
|
1186
|
+
logger.exception(f"Unexpected error calling LLM: {e}")
|
|
1187
|
+
return f"Error calling LLM: {type(e).__name__}", 0, 0
|
|
1188
|
+
|
|
1189
|
+
# Note: _track_telemetry is inherited from TelemetryMixin
|
|
1190
|
+
|
|
1191
|
+
def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
|
|
1192
|
+
"""Calculate cost for a stage."""
|
|
1193
|
+
tier_name = tier.value
|
|
1194
|
+
pricing = MODEL_PRICING.get(tier_name, MODEL_PRICING["capable"])
|
|
1195
|
+
input_cost = (input_tokens / 1_000_000) * pricing["input"]
|
|
1196
|
+
output_cost = (output_tokens / 1_000_000) * pricing["output"]
|
|
1197
|
+
return input_cost + output_cost
|
|
1198
|
+
|
|
1199
|
+
def _calculate_baseline_cost(self, input_tokens: int, output_tokens: int) -> float:
|
|
1200
|
+
"""Calculate what the cost would be using premium tier."""
|
|
1201
|
+
pricing = MODEL_PRICING["premium"]
|
|
1202
|
+
input_cost = (input_tokens / 1_000_000) * pricing["input"]
|
|
1203
|
+
output_cost = (output_tokens / 1_000_000) * pricing["output"]
|
|
1204
|
+
return input_cost + output_cost
|
|
1205
|
+
|
|
1206
|
+
def _generate_cost_report(self) -> CostReport:
|
|
1207
|
+
"""Generate cost report from completed stages."""
|
|
1208
|
+
total_cost = 0.0
|
|
1209
|
+
baseline_cost = 0.0
|
|
1210
|
+
by_stage: dict[str, float] = {}
|
|
1211
|
+
by_tier: dict[str, float] = {}
|
|
1212
|
+
|
|
1213
|
+
for stage in self._stages_run:
|
|
1214
|
+
if stage.skipped:
|
|
1215
|
+
continue
|
|
1216
|
+
|
|
1217
|
+
total_cost += stage.cost
|
|
1218
|
+
by_stage[stage.name] = stage.cost
|
|
1219
|
+
|
|
1220
|
+
tier_name = stage.tier.value
|
|
1221
|
+
by_tier[tier_name] = by_tier.get(tier_name, 0.0) + stage.cost
|
|
1222
|
+
|
|
1223
|
+
# Calculate what this would cost at premium tier
|
|
1224
|
+
baseline_cost += self._calculate_baseline_cost(stage.input_tokens, stage.output_tokens)
|
|
1225
|
+
|
|
1226
|
+
savings = baseline_cost - total_cost
|
|
1227
|
+
savings_percent = (savings / baseline_cost * 100) if baseline_cost > 0 else 0.0
|
|
1228
|
+
|
|
1229
|
+
# Calculate cache metrics using CachingMixin
|
|
1230
|
+
cache_stats = self._get_cache_stats()
|
|
1231
|
+
cache_hits = cache_stats["hits"]
|
|
1232
|
+
cache_misses = cache_stats["misses"]
|
|
1233
|
+
cache_hit_rate = cache_stats["hit_rate"]
|
|
1234
|
+
estimated_cost_without_cache = total_cost
|
|
1235
|
+
savings_from_cache = 0.0
|
|
1236
|
+
|
|
1237
|
+
# Estimate cost without cache (assumes cache hits would have incurred full cost)
|
|
1238
|
+
if cache_hits > 0:
|
|
1239
|
+
avg_cost_per_call = total_cost / cache_misses if cache_misses > 0 else 0.0
|
|
1240
|
+
estimated_additional_cost = cache_hits * avg_cost_per_call
|
|
1241
|
+
estimated_cost_without_cache = total_cost + estimated_additional_cost
|
|
1242
|
+
savings_from_cache = estimated_additional_cost
|
|
1243
|
+
|
|
1244
|
+
return CostReport(
|
|
1245
|
+
total_cost=total_cost,
|
|
1246
|
+
baseline_cost=baseline_cost,
|
|
1247
|
+
savings=savings,
|
|
1248
|
+
savings_percent=savings_percent,
|
|
1249
|
+
by_stage=by_stage,
|
|
1250
|
+
by_tier=by_tier,
|
|
1251
|
+
cache_hits=cache_hits,
|
|
1252
|
+
cache_misses=cache_misses,
|
|
1253
|
+
cache_hit_rate=cache_hit_rate,
|
|
1254
|
+
estimated_cost_without_cache=estimated_cost_without_cache,
|
|
1255
|
+
savings_from_cache=savings_from_cache,
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
@abstractmethod
|
|
1259
|
+
async def run_stage(
|
|
1260
|
+
self,
|
|
1261
|
+
stage_name: str,
|
|
1262
|
+
tier: ModelTier,
|
|
1263
|
+
input_data: Any,
|
|
1264
|
+
) -> tuple[Any, int, int]:
|
|
1265
|
+
"""Execute a single workflow stage.
|
|
1266
|
+
|
|
1267
|
+
Args:
|
|
1268
|
+
stage_name: Name of the stage to run
|
|
1269
|
+
tier: Model tier to use
|
|
1270
|
+
input_data: Input for this stage
|
|
1271
|
+
|
|
1272
|
+
Returns:
|
|
1273
|
+
Tuple of (output_data, input_tokens, output_tokens)
|
|
1274
|
+
|
|
1275
|
+
"""
|
|
1276
|
+
|
|
1277
|
+
def should_skip_stage(self, stage_name: str, input_data: Any) -> tuple[bool, str | None]:
|
|
1278
|
+
"""Determine if a stage should be skipped.
|
|
1279
|
+
|
|
1280
|
+
Override in subclasses for conditional stage execution.
|
|
1281
|
+
|
|
1282
|
+
Args:
|
|
1283
|
+
stage_name: Name of the stage
|
|
1284
|
+
input_data: Current workflow data
|
|
1285
|
+
|
|
1286
|
+
Returns:
|
|
1287
|
+
Tuple of (should_skip, reason)
|
|
1288
|
+
|
|
1289
|
+
"""
|
|
1290
|
+
return False, None
|
|
1291
|
+
|
|
1292
|
+
def validate_output(self, stage_output: dict) -> tuple[bool, str | None]:
|
|
1293
|
+
"""Validate stage output quality for tier fallback decisions.
|
|
1294
|
+
|
|
1295
|
+
This is called after each stage execution when tier fallback is enabled.
|
|
1296
|
+
Override in subclasses to add workflow-specific validation logic.
|
|
1297
|
+
|
|
1298
|
+
Default implementation checks:
|
|
1299
|
+
- No exceptions during execution (execution_succeeded)
|
|
1300
|
+
- Output is not empty (output_valid)
|
|
1301
|
+
- Required keys present if defined in stage config
|
|
1302
|
+
|
|
1303
|
+
Args:
|
|
1304
|
+
stage_output: Output dict from run_stage()
|
|
1305
|
+
|
|
1306
|
+
Returns:
|
|
1307
|
+
Tuple of (is_valid, failure_reason)
|
|
1308
|
+
- is_valid: True if output passes quality gates
|
|
1309
|
+
- failure_reason: Error code if validation failed (e.g., "output_empty",
|
|
1310
|
+
"health_score_low", "tests_failed")
|
|
1311
|
+
|
|
1312
|
+
Example:
|
|
1313
|
+
>>> def validate_output(self, stage_output):
|
|
1314
|
+
... # Check health score for health-check workflow
|
|
1315
|
+
... health_score = stage_output.get("health_score", 0)
|
|
1316
|
+
... if health_score < 80:
|
|
1317
|
+
... return False, "health_score_low"
|
|
1318
|
+
... return True, None
|
|
1319
|
+
|
|
1320
|
+
"""
|
|
1321
|
+
# Default validation: check output is not empty
|
|
1322
|
+
if not stage_output:
|
|
1323
|
+
return False, "output_empty"
|
|
1324
|
+
|
|
1325
|
+
# Check for error indicators in output
|
|
1326
|
+
if stage_output.get("error") is not None:
|
|
1327
|
+
return False, "execution_error"
|
|
1328
|
+
|
|
1329
|
+
# Output is valid by default
|
|
1330
|
+
return True, None
|
|
1331
|
+
|
|
1332
|
+
def _assess_complexity(self, input_data: dict[str, Any]) -> str:
|
|
1333
|
+
"""Assess task complexity based on workflow stages and input.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
input_data: Workflow input data
|
|
1337
|
+
|
|
1338
|
+
Returns:
|
|
1339
|
+
Complexity level: "simple", "moderate", or "complex"
|
|
1340
|
+
|
|
1341
|
+
"""
|
|
1342
|
+
# Simple heuristic: based on number of stages and tier requirements
|
|
1343
|
+
num_stages = len(self.stages)
|
|
1344
|
+
premium_stages = sum(
|
|
1345
|
+
1 for s in self.stages if self.get_tier_for_stage(s) == ModelTier.PREMIUM
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
if num_stages <= 2 and premium_stages == 0:
|
|
1349
|
+
return "simple"
|
|
1350
|
+
elif num_stages <= 4 and premium_stages <= 1:
|
|
1351
|
+
return "moderate"
|
|
1352
|
+
else:
|
|
1353
|
+
return "complex"
|
|
1354
|
+
|
|
1355
|
+
async def execute(self, **kwargs: Any) -> WorkflowResult:
|
|
1356
|
+
"""Execute the full workflow.
|
|
1357
|
+
|
|
1358
|
+
Args:
|
|
1359
|
+
**kwargs: Initial input data for the workflow
|
|
1360
|
+
|
|
1361
|
+
Returns:
|
|
1362
|
+
WorkflowResult with stages, output, and cost report
|
|
1363
|
+
|
|
1364
|
+
"""
|
|
1365
|
+
# Set up cache (one-time setup with user prompt if needed)
|
|
1366
|
+
self._maybe_setup_cache()
|
|
1367
|
+
|
|
1368
|
+
# Set run ID for telemetry correlation
|
|
1369
|
+
self._run_id = str(uuid.uuid4())
|
|
1370
|
+
|
|
1371
|
+
# Log task routing (Tier 1 automation monitoring)
|
|
1372
|
+
routing_id = f"routing-{self._run_id}"
|
|
1373
|
+
routing_record = TaskRoutingRecord(
|
|
1374
|
+
routing_id=routing_id,
|
|
1375
|
+
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
1376
|
+
task_description=f"{self.name}: {self.description}",
|
|
1377
|
+
task_type=self.name,
|
|
1378
|
+
task_complexity=self._assess_complexity(kwargs),
|
|
1379
|
+
assigned_agent=self.name,
|
|
1380
|
+
assigned_tier=getattr(self, "_provider_str", "unknown"),
|
|
1381
|
+
routing_strategy="rule_based",
|
|
1382
|
+
confidence_score=1.0,
|
|
1383
|
+
status="running",
|
|
1384
|
+
started_at=datetime.utcnow().isoformat() + "Z",
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
# Log routing start
|
|
1388
|
+
try:
|
|
1389
|
+
if self._telemetry_backend is not None:
|
|
1390
|
+
self._telemetry_backend.log_task_routing(routing_record)
|
|
1391
|
+
except Exception as e:
|
|
1392
|
+
logger.debug(f"Failed to log task routing: {e}")
|
|
1393
|
+
|
|
1394
|
+
# Auto tier recommendation
|
|
1395
|
+
if self._enable_tier_tracking:
|
|
1396
|
+
try:
|
|
1397
|
+
from .tier_tracking import WorkflowTierTracker
|
|
1398
|
+
|
|
1399
|
+
self._tier_tracker = WorkflowTierTracker(self.name, self.description)
|
|
1400
|
+
files_affected = kwargs.get("files_affected") or kwargs.get("path")
|
|
1401
|
+
if files_affected and not isinstance(files_affected, list):
|
|
1402
|
+
files_affected = [str(files_affected)]
|
|
1403
|
+
self._tier_tracker.show_recommendation(files_affected)
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
logger.debug(f"Tier tracking disabled: {e}")
|
|
1406
|
+
self._enable_tier_tracking = False
|
|
1407
|
+
|
|
1408
|
+
# Initialize agent ID for heartbeat/coordination (Pattern 1 & 2)
|
|
1409
|
+
if self._agent_id is None:
|
|
1410
|
+
# Auto-generate agent ID from workflow name and run ID
|
|
1411
|
+
self._agent_id = f"{self.name}-{self._run_id[:8]}"
|
|
1412
|
+
|
|
1413
|
+
# Start heartbeat tracking (Pattern 1)
|
|
1414
|
+
heartbeat_coordinator = self._get_heartbeat_coordinator()
|
|
1415
|
+
if heartbeat_coordinator:
|
|
1416
|
+
try:
|
|
1417
|
+
heartbeat_coordinator.start_heartbeat(
|
|
1418
|
+
agent_id=self._agent_id,
|
|
1419
|
+
metadata={
|
|
1420
|
+
"workflow": self.name,
|
|
1421
|
+
"run_id": self._run_id,
|
|
1422
|
+
"provider": getattr(self, "_provider_str", "unknown"),
|
|
1423
|
+
"stages": len(self.stages),
|
|
1424
|
+
}
|
|
1425
|
+
)
|
|
1426
|
+
logger.debug(
|
|
1427
|
+
"heartbeat_started",
|
|
1428
|
+
workflow=self.name,
|
|
1429
|
+
agent_id=self._agent_id,
|
|
1430
|
+
message="Agent heartbeat tracking started"
|
|
1431
|
+
)
|
|
1432
|
+
except Exception as e:
|
|
1433
|
+
logger.warning(f"Failed to start heartbeat tracking: {e}")
|
|
1434
|
+
self._enable_heartbeat_tracking = False
|
|
1435
|
+
|
|
1436
|
+
started_at = datetime.now()
|
|
1437
|
+
self._stages_run = []
|
|
1438
|
+
current_data = kwargs
|
|
1439
|
+
error = None
|
|
1440
|
+
|
|
1441
|
+
# Initialize progress tracker
|
|
1442
|
+
# Always show progress by default (IDE-friendly console output)
|
|
1443
|
+
# Rich live display only when explicitly enabled AND in TTY
|
|
1444
|
+
from .progress import ConsoleProgressReporter
|
|
1445
|
+
|
|
1446
|
+
self._progress_tracker = ProgressTracker(
|
|
1447
|
+
workflow_name=self.name,
|
|
1448
|
+
workflow_id=self._run_id,
|
|
1449
|
+
stage_names=self.stages,
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
# Add user's callback if provided
|
|
1453
|
+
if self._progress_callback:
|
|
1454
|
+
self._progress_tracker.add_callback(self._progress_callback)
|
|
1455
|
+
|
|
1456
|
+
# Rich progress: only when explicitly enabled AND in a TTY
|
|
1457
|
+
if self._enable_rich_progress and RICH_AVAILABLE and sys.stdout.isatty():
|
|
1458
|
+
try:
|
|
1459
|
+
self._rich_reporter = RichProgressReporter(self.name, self.stages)
|
|
1460
|
+
self._progress_tracker.add_callback(self._rich_reporter.report)
|
|
1461
|
+
self._rich_reporter.start()
|
|
1462
|
+
except Exception as e:
|
|
1463
|
+
# Fall back to console reporter
|
|
1464
|
+
logger.debug(f"Rich progress unavailable: {e}")
|
|
1465
|
+
self._rich_reporter = None
|
|
1466
|
+
console_reporter = ConsoleProgressReporter(verbose=False)
|
|
1467
|
+
self._progress_tracker.add_callback(console_reporter.report)
|
|
1468
|
+
else:
|
|
1469
|
+
# Default: use console reporter (works in IDEs, terminals, everywhere)
|
|
1470
|
+
console_reporter = ConsoleProgressReporter(verbose=False)
|
|
1471
|
+
self._progress_tracker.add_callback(console_reporter.report)
|
|
1472
|
+
|
|
1473
|
+
self._progress_tracker.start_workflow()
|
|
1474
|
+
|
|
1475
|
+
try:
|
|
1476
|
+
# Tier fallback mode: try CHEAP → CAPABLE → PREMIUM with validation
|
|
1477
|
+
if self._enable_tier_fallback:
|
|
1478
|
+
tier_chain = [ModelTier.CHEAP, ModelTier.CAPABLE, ModelTier.PREMIUM]
|
|
1479
|
+
|
|
1480
|
+
for stage_name in self.stages:
|
|
1481
|
+
# Check if stage should be skipped
|
|
1482
|
+
should_skip, skip_reason = self.should_skip_stage(stage_name, current_data)
|
|
1483
|
+
|
|
1484
|
+
if should_skip:
|
|
1485
|
+
tier = self.get_tier_for_stage(stage_name)
|
|
1486
|
+
stage = WorkflowStage(
|
|
1487
|
+
name=stage_name,
|
|
1488
|
+
tier=tier,
|
|
1489
|
+
description=f"Stage: {stage_name}",
|
|
1490
|
+
skipped=True,
|
|
1491
|
+
skip_reason=skip_reason,
|
|
1492
|
+
)
|
|
1493
|
+
self._stages_run.append(stage)
|
|
1494
|
+
|
|
1495
|
+
# Report skip to progress tracker
|
|
1496
|
+
if self._progress_tracker:
|
|
1497
|
+
self._progress_tracker.skip_stage(stage_name, skip_reason or "")
|
|
1498
|
+
|
|
1499
|
+
continue
|
|
1500
|
+
|
|
1501
|
+
# Try each tier in fallback chain
|
|
1502
|
+
stage_succeeded = False
|
|
1503
|
+
tier_index = 0
|
|
1504
|
+
|
|
1505
|
+
for tier in tier_chain:
|
|
1506
|
+
stage_start = datetime.now()
|
|
1507
|
+
|
|
1508
|
+
# Report stage start to progress tracker with current tier
|
|
1509
|
+
model_id = self.get_model_for_tier(tier)
|
|
1510
|
+
if self._progress_tracker:
|
|
1511
|
+
# On first attempt, start stage. On retry, update tier.
|
|
1512
|
+
if tier_index == 0:
|
|
1513
|
+
self._progress_tracker.start_stage(stage_name, tier.value, model_id)
|
|
1514
|
+
else:
|
|
1515
|
+
# Show tier upgrade (e.g., CHEAP → CAPABLE)
|
|
1516
|
+
prev_tier = tier_chain[tier_index - 1].value
|
|
1517
|
+
self._progress_tracker.update_tier(
|
|
1518
|
+
stage_name, tier.value, f"{prev_tier}_failed"
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
# Update heartbeat at stage start (Pattern 1)
|
|
1522
|
+
if heartbeat_coordinator:
|
|
1523
|
+
try:
|
|
1524
|
+
stage_index = self.stages.index(stage_name)
|
|
1525
|
+
progress = stage_index / len(self.stages)
|
|
1526
|
+
heartbeat_coordinator.beat(
|
|
1527
|
+
status="running",
|
|
1528
|
+
progress=progress,
|
|
1529
|
+
current_task=f"Running stage: {stage_name} ({tier.value})"
|
|
1530
|
+
)
|
|
1531
|
+
except Exception as e:
|
|
1532
|
+
logger.debug(f"Heartbeat update failed: {e}")
|
|
1533
|
+
|
|
1534
|
+
try:
|
|
1535
|
+
# Run the stage at current tier
|
|
1536
|
+
output, input_tokens, output_tokens = await self.run_stage(
|
|
1537
|
+
stage_name,
|
|
1538
|
+
tier,
|
|
1539
|
+
current_data,
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
stage_end = datetime.now()
|
|
1543
|
+
duration_ms = int((stage_end - stage_start).total_seconds() * 1000)
|
|
1544
|
+
cost = self._calculate_cost(tier, input_tokens, output_tokens)
|
|
1545
|
+
|
|
1546
|
+
# Create stage output dict for validation
|
|
1547
|
+
stage_output = (
|
|
1548
|
+
output if isinstance(output, dict) else {"result": output}
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1551
|
+
# Validate output quality
|
|
1552
|
+
is_valid, failure_reason = self.validate_output(stage_output)
|
|
1553
|
+
|
|
1554
|
+
if is_valid:
|
|
1555
|
+
# Success - record stage and move to next
|
|
1556
|
+
stage = WorkflowStage(
|
|
1557
|
+
name=stage_name,
|
|
1558
|
+
tier=tier,
|
|
1559
|
+
description=f"Stage: {stage_name}",
|
|
1560
|
+
input_tokens=input_tokens,
|
|
1561
|
+
output_tokens=output_tokens,
|
|
1562
|
+
cost=cost,
|
|
1563
|
+
result=output,
|
|
1564
|
+
duration_ms=duration_ms,
|
|
1565
|
+
)
|
|
1566
|
+
self._stages_run.append(stage)
|
|
1567
|
+
|
|
1568
|
+
# Report stage completion to progress tracker
|
|
1569
|
+
if self._progress_tracker:
|
|
1570
|
+
self._progress_tracker.complete_stage(
|
|
1571
|
+
stage_name,
|
|
1572
|
+
cost=cost,
|
|
1573
|
+
tokens_in=input_tokens,
|
|
1574
|
+
tokens_out=output_tokens,
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
# Update heartbeat after stage completion (Pattern 1)
|
|
1578
|
+
if heartbeat_coordinator:
|
|
1579
|
+
try:
|
|
1580
|
+
stage_index = self.stages.index(stage_name) + 1
|
|
1581
|
+
progress = stage_index / len(self.stages)
|
|
1582
|
+
heartbeat_coordinator.beat(
|
|
1583
|
+
status="running",
|
|
1584
|
+
progress=progress,
|
|
1585
|
+
current_task=f"Completed stage: {stage_name}"
|
|
1586
|
+
)
|
|
1587
|
+
except Exception as e:
|
|
1588
|
+
logger.debug(f"Heartbeat update failed: {e}")
|
|
1589
|
+
|
|
1590
|
+
# Log to cost tracker
|
|
1591
|
+
self.cost_tracker.log_request(
|
|
1592
|
+
model=model_id,
|
|
1593
|
+
input_tokens=input_tokens,
|
|
1594
|
+
output_tokens=output_tokens,
|
|
1595
|
+
task_type=f"workflow:{self.name}:{stage_name}",
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
# Track telemetry for this stage
|
|
1599
|
+
self._track_telemetry(
|
|
1600
|
+
stage=stage_name,
|
|
1601
|
+
tier=tier,
|
|
1602
|
+
model=model_id,
|
|
1603
|
+
cost=cost,
|
|
1604
|
+
tokens={"input": input_tokens, "output": output_tokens},
|
|
1605
|
+
cache_hit=False,
|
|
1606
|
+
cache_type=None,
|
|
1607
|
+
duration_ms=duration_ms,
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
# Record successful tier usage
|
|
1611
|
+
self._tier_progression.append((stage_name, tier.value, True))
|
|
1612
|
+
stage_succeeded = True
|
|
1613
|
+
|
|
1614
|
+
# Pass output to next stage
|
|
1615
|
+
current_data = stage_output
|
|
1616
|
+
break # Success - move to next stage
|
|
1617
|
+
|
|
1618
|
+
else:
|
|
1619
|
+
# Quality gate failed - try next tier
|
|
1620
|
+
self._tier_progression.append((stage_name, tier.value, False))
|
|
1621
|
+
logger.info(
|
|
1622
|
+
f"Stage {stage_name} failed quality validation with {tier.value}: "
|
|
1623
|
+
f"{failure_reason}"
|
|
1624
|
+
)
|
|
1625
|
+
|
|
1626
|
+
# Check if more tiers available
|
|
1627
|
+
if tier_index < len(tier_chain) - 1:
|
|
1628
|
+
logger.info("Retrying with higher tier...")
|
|
1629
|
+
else:
|
|
1630
|
+
logger.error(f"All tiers exhausted for {stage_name}")
|
|
1631
|
+
|
|
1632
|
+
except Exception as e:
|
|
1633
|
+
# Exception during stage execution - try next tier
|
|
1634
|
+
self._tier_progression.append((stage_name, tier.value, False))
|
|
1635
|
+
logger.warning(
|
|
1636
|
+
f"Stage {stage_name} error with {tier.value}: {type(e).__name__}: {e}"
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
# Check if more tiers available
|
|
1640
|
+
if tier_index < len(tier_chain) - 1:
|
|
1641
|
+
logger.info("Retrying with higher tier...")
|
|
1642
|
+
else:
|
|
1643
|
+
logger.error(f"All tiers exhausted for {stage_name}")
|
|
1644
|
+
|
|
1645
|
+
tier_index += 1
|
|
1646
|
+
|
|
1647
|
+
# Check if stage succeeded with any tier
|
|
1648
|
+
if not stage_succeeded:
|
|
1649
|
+
error_msg = (
|
|
1650
|
+
f"Stage {stage_name} failed with all tiers: CHEAP, CAPABLE, PREMIUM"
|
|
1651
|
+
)
|
|
1652
|
+
if self._progress_tracker:
|
|
1653
|
+
self._progress_tracker.fail_stage(stage_name, error_msg)
|
|
1654
|
+
raise ValueError(error_msg)
|
|
1655
|
+
|
|
1656
|
+
# Standard mode: use routing strategy or tier_map (backward compatible)
|
|
1657
|
+
else:
|
|
1658
|
+
# Track budget for routing decisions
|
|
1659
|
+
total_budget = 100.0 # Default budget in USD
|
|
1660
|
+
budget_spent = 0.0
|
|
1661
|
+
|
|
1662
|
+
for stage_name in self.stages:
|
|
1663
|
+
# Use routing strategy if available, otherwise fall back to tier_map
|
|
1664
|
+
budget_remaining = total_budget - budget_spent
|
|
1665
|
+
tier = self._get_tier_with_routing(
|
|
1666
|
+
stage_name,
|
|
1667
|
+
current_data if isinstance(current_data, dict) else {},
|
|
1668
|
+
budget_remaining,
|
|
1669
|
+
)
|
|
1670
|
+
stage_start = datetime.now()
|
|
1671
|
+
|
|
1672
|
+
# Check if stage should be skipped
|
|
1673
|
+
should_skip, skip_reason = self.should_skip_stage(stage_name, current_data)
|
|
1674
|
+
|
|
1675
|
+
if should_skip:
|
|
1676
|
+
stage = WorkflowStage(
|
|
1677
|
+
name=stage_name,
|
|
1678
|
+
tier=tier,
|
|
1679
|
+
description=f"Stage: {stage_name}",
|
|
1680
|
+
skipped=True,
|
|
1681
|
+
skip_reason=skip_reason,
|
|
1682
|
+
)
|
|
1683
|
+
self._stages_run.append(stage)
|
|
1684
|
+
|
|
1685
|
+
# Report skip to progress tracker
|
|
1686
|
+
if self._progress_tracker:
|
|
1687
|
+
self._progress_tracker.skip_stage(stage_name, skip_reason or "")
|
|
1688
|
+
|
|
1689
|
+
continue
|
|
1690
|
+
|
|
1691
|
+
# Report stage start to progress tracker
|
|
1692
|
+
model_id = self.get_model_for_tier(tier)
|
|
1693
|
+
if self._progress_tracker:
|
|
1694
|
+
self._progress_tracker.start_stage(stage_name, tier.value, model_id)
|
|
1695
|
+
|
|
1696
|
+
# Run the stage
|
|
1697
|
+
output, input_tokens, output_tokens = await self.run_stage(
|
|
1698
|
+
stage_name,
|
|
1699
|
+
tier,
|
|
1700
|
+
current_data,
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
stage_end = datetime.now()
|
|
1704
|
+
duration_ms = int((stage_end - stage_start).total_seconds() * 1000)
|
|
1705
|
+
cost = self._calculate_cost(tier, input_tokens, output_tokens)
|
|
1706
|
+
|
|
1707
|
+
# Update budget spent for routing decisions
|
|
1708
|
+
budget_spent += cost
|
|
1709
|
+
|
|
1710
|
+
stage = WorkflowStage(
|
|
1711
|
+
name=stage_name,
|
|
1712
|
+
tier=tier,
|
|
1713
|
+
description=f"Stage: {stage_name}",
|
|
1714
|
+
input_tokens=input_tokens,
|
|
1715
|
+
output_tokens=output_tokens,
|
|
1716
|
+
cost=cost,
|
|
1717
|
+
result=output,
|
|
1718
|
+
duration_ms=duration_ms,
|
|
1719
|
+
)
|
|
1720
|
+
self._stages_run.append(stage)
|
|
1721
|
+
|
|
1722
|
+
# Report stage completion to progress tracker
|
|
1723
|
+
if self._progress_tracker:
|
|
1724
|
+
self._progress_tracker.complete_stage(
|
|
1725
|
+
stage_name,
|
|
1726
|
+
cost=cost,
|
|
1727
|
+
tokens_in=input_tokens,
|
|
1728
|
+
tokens_out=output_tokens,
|
|
1729
|
+
)
|
|
1730
|
+
|
|
1731
|
+
# Log to cost tracker
|
|
1732
|
+
self.cost_tracker.log_request(
|
|
1733
|
+
model=model_id,
|
|
1734
|
+
input_tokens=input_tokens,
|
|
1735
|
+
output_tokens=output_tokens,
|
|
1736
|
+
task_type=f"workflow:{self.name}:{stage_name}",
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
# Track telemetry for this stage
|
|
1740
|
+
self._track_telemetry(
|
|
1741
|
+
stage=stage_name,
|
|
1742
|
+
tier=tier,
|
|
1743
|
+
model=model_id,
|
|
1744
|
+
cost=cost,
|
|
1745
|
+
tokens={"input": input_tokens, "output": output_tokens},
|
|
1746
|
+
cache_hit=False,
|
|
1747
|
+
cache_type=None,
|
|
1748
|
+
duration_ms=duration_ms,
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
# Pass output to next stage
|
|
1752
|
+
current_data = output if isinstance(output, dict) else {"result": output}
|
|
1753
|
+
|
|
1754
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
1755
|
+
# Data validation or configuration errors
|
|
1756
|
+
error = f"Workflow execution error (data/config): {e}"
|
|
1757
|
+
logger.error(error)
|
|
1758
|
+
if self._progress_tracker:
|
|
1759
|
+
self._progress_tracker.fail_workflow(error)
|
|
1760
|
+
except (TimeoutError, RuntimeError, ConnectionError) as e:
|
|
1761
|
+
# Timeout, API errors, or connection failures
|
|
1762
|
+
error = f"Workflow execution error (timeout/API/connection): {e}"
|
|
1763
|
+
logger.error(error)
|
|
1764
|
+
if self._progress_tracker:
|
|
1765
|
+
self._progress_tracker.fail_workflow(error)
|
|
1766
|
+
except (OSError, PermissionError) as e:
|
|
1767
|
+
# File system or permission errors
|
|
1768
|
+
error = f"Workflow execution error (file system): {e}"
|
|
1769
|
+
logger.error(error)
|
|
1770
|
+
if self._progress_tracker:
|
|
1771
|
+
self._progress_tracker.fail_workflow(error)
|
|
1772
|
+
except Exception as e:
|
|
1773
|
+
# INTENTIONAL: Workflow orchestration - catch all errors to report failure gracefully
|
|
1774
|
+
logger.exception(f"Unexpected error in workflow execution: {type(e).__name__}")
|
|
1775
|
+
error = f"Workflow execution failed: {type(e).__name__}"
|
|
1776
|
+
if self._progress_tracker:
|
|
1777
|
+
self._progress_tracker.fail_workflow(error)
|
|
1778
|
+
|
|
1779
|
+
completed_at = datetime.now()
|
|
1780
|
+
total_duration_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
1781
|
+
|
|
1782
|
+
# Get final output from last non-skipped stage
|
|
1783
|
+
final_output = None
|
|
1784
|
+
for stage in reversed(self._stages_run):
|
|
1785
|
+
if not stage.skipped and stage.result is not None:
|
|
1786
|
+
final_output = stage.result
|
|
1787
|
+
break
|
|
1788
|
+
|
|
1789
|
+
# Classify error type and transient status
|
|
1790
|
+
error_type = None
|
|
1791
|
+
transient = False
|
|
1792
|
+
if error:
|
|
1793
|
+
error_lower = error.lower()
|
|
1794
|
+
if "timeout" in error_lower or "timed out" in error_lower:
|
|
1795
|
+
error_type = "timeout"
|
|
1796
|
+
transient = True
|
|
1797
|
+
elif "config" in error_lower or "configuration" in error_lower:
|
|
1798
|
+
error_type = "config"
|
|
1799
|
+
transient = False
|
|
1800
|
+
elif "api" in error_lower or "rate limit" in error_lower or "quota" in error_lower:
|
|
1801
|
+
error_type = "provider"
|
|
1802
|
+
transient = True
|
|
1803
|
+
elif "validation" in error_lower or "invalid" in error_lower:
|
|
1804
|
+
error_type = "validation"
|
|
1805
|
+
transient = False
|
|
1806
|
+
else:
|
|
1807
|
+
error_type = "runtime"
|
|
1808
|
+
transient = False
|
|
1809
|
+
|
|
1810
|
+
provider_str = getattr(self, "_provider_str", "unknown")
|
|
1811
|
+
result = WorkflowResult(
|
|
1812
|
+
success=error is None,
|
|
1813
|
+
stages=self._stages_run,
|
|
1814
|
+
final_output=final_output,
|
|
1815
|
+
cost_report=self._generate_cost_report(),
|
|
1816
|
+
started_at=started_at,
|
|
1817
|
+
completed_at=completed_at,
|
|
1818
|
+
total_duration_ms=total_duration_ms,
|
|
1819
|
+
provider=provider_str,
|
|
1820
|
+
error=error,
|
|
1821
|
+
error_type=error_type,
|
|
1822
|
+
transient=transient,
|
|
1823
|
+
)
|
|
1824
|
+
|
|
1825
|
+
# Report workflow completion to progress tracker
|
|
1826
|
+
if self._progress_tracker and error is None:
|
|
1827
|
+
self._progress_tracker.complete_workflow()
|
|
1828
|
+
|
|
1829
|
+
# Stop Rich progress display if active
|
|
1830
|
+
if self._rich_reporter:
|
|
1831
|
+
try:
|
|
1832
|
+
self._rich_reporter.stop()
|
|
1833
|
+
except Exception:
|
|
1834
|
+
pass # Best effort cleanup
|
|
1835
|
+
self._rich_reporter = None
|
|
1836
|
+
|
|
1837
|
+
# Save to workflow history for dashboard
|
|
1838
|
+
try:
|
|
1839
|
+
_save_workflow_run(self.name, provider_str, result)
|
|
1840
|
+
except (OSError, PermissionError):
|
|
1841
|
+
# File system errors saving history - log but don't crash workflow
|
|
1842
|
+
logger.warning("Failed to save workflow history (file system error)")
|
|
1843
|
+
except (ValueError, TypeError, KeyError):
|
|
1844
|
+
# Data serialization errors - log but don't crash workflow
|
|
1845
|
+
logger.warning("Failed to save workflow history (serialization error)")
|
|
1846
|
+
except Exception:
|
|
1847
|
+
# INTENTIONAL: History save is optional diagnostics - never crash workflow
|
|
1848
|
+
logger.exception("Unexpected error saving workflow history")
|
|
1849
|
+
|
|
1850
|
+
# Emit workflow telemetry to backend
|
|
1851
|
+
self._emit_workflow_telemetry(result)
|
|
1852
|
+
|
|
1853
|
+
# Stop heartbeat tracking (Pattern 1)
|
|
1854
|
+
if heartbeat_coordinator:
|
|
1855
|
+
try:
|
|
1856
|
+
final_status = "completed" if result.success else "failed"
|
|
1857
|
+
heartbeat_coordinator.stop_heartbeat(final_status=final_status)
|
|
1858
|
+
logger.debug(
|
|
1859
|
+
"heartbeat_stopped",
|
|
1860
|
+
workflow=self.name,
|
|
1861
|
+
agent_id=self._agent_id,
|
|
1862
|
+
status=final_status,
|
|
1863
|
+
message="Agent heartbeat tracking stopped"
|
|
1864
|
+
)
|
|
1865
|
+
except Exception as e:
|
|
1866
|
+
logger.warning(f"Failed to stop heartbeat tracking: {e}")
|
|
1867
|
+
|
|
1868
|
+
# Auto-save tier progression
|
|
1869
|
+
if self._enable_tier_tracking and self._tier_tracker:
|
|
1870
|
+
try:
|
|
1871
|
+
files_affected = kwargs.get("files_affected") or kwargs.get("path")
|
|
1872
|
+
if files_affected and not isinstance(files_affected, list):
|
|
1873
|
+
files_affected = [str(files_affected)]
|
|
1874
|
+
|
|
1875
|
+
# Determine bug type from workflow name
|
|
1876
|
+
bug_type_map = {
|
|
1877
|
+
"code-review": "code_quality",
|
|
1878
|
+
"bug-predict": "bug_prediction",
|
|
1879
|
+
"security-audit": "security_issue",
|
|
1880
|
+
"test-gen": "test_coverage",
|
|
1881
|
+
"refactor-plan": "refactoring",
|
|
1882
|
+
"health-check": "health_check",
|
|
1883
|
+
}
|
|
1884
|
+
bug_type = bug_type_map.get(self.name, "workflow_run")
|
|
1885
|
+
|
|
1886
|
+
# Pass tier_progression data if tier fallback was enabled
|
|
1887
|
+
tier_progression_data = (
|
|
1888
|
+
self._tier_progression if self._enable_tier_fallback else None
|
|
1889
|
+
)
|
|
1890
|
+
|
|
1891
|
+
self._tier_tracker.save_progression(
|
|
1892
|
+
workflow_result=result,
|
|
1893
|
+
files_affected=files_affected,
|
|
1894
|
+
bug_type=bug_type,
|
|
1895
|
+
tier_progression=tier_progression_data,
|
|
1896
|
+
)
|
|
1897
|
+
except Exception as e:
|
|
1898
|
+
logger.debug(f"Failed to save tier progression: {e}")
|
|
1899
|
+
|
|
1900
|
+
# Update routing record with completion status (Tier 1 automation monitoring)
|
|
1901
|
+
routing_record.status = "completed" if result.success else "failed"
|
|
1902
|
+
routing_record.completed_at = datetime.utcnow().isoformat() + "Z"
|
|
1903
|
+
routing_record.success = result.success
|
|
1904
|
+
routing_record.actual_cost = sum(s.cost for s in result.stages)
|
|
1905
|
+
|
|
1906
|
+
if not result.success and result.error:
|
|
1907
|
+
routing_record.error_type = result.error_type or "unknown"
|
|
1908
|
+
routing_record.error_message = result.error
|
|
1909
|
+
|
|
1910
|
+
# Log routing completion
|
|
1911
|
+
try:
|
|
1912
|
+
if self._telemetry_backend is not None:
|
|
1913
|
+
self._telemetry_backend.log_task_routing(routing_record)
|
|
1914
|
+
except Exception as e:
|
|
1915
|
+
logger.debug(f"Failed to log task routing completion: {e}")
|
|
1916
|
+
|
|
1917
|
+
return result
|
|
1918
|
+
|
|
1919
|
+
def describe(self) -> str:
|
|
1920
|
+
"""Get a human-readable description of the workflow."""
|
|
1921
|
+
lines = [
|
|
1922
|
+
f"Workflow: {self.name}",
|
|
1923
|
+
f"Description: {self.description}",
|
|
1924
|
+
"",
|
|
1925
|
+
"Stages:",
|
|
1926
|
+
]
|
|
1927
|
+
|
|
1928
|
+
for stage_name in self.stages:
|
|
1929
|
+
tier = self.get_tier_for_stage(stage_name)
|
|
1930
|
+
model = self.get_model_for_tier(tier)
|
|
1931
|
+
lines.append(f" {stage_name}: {tier.value} ({model})")
|
|
1932
|
+
|
|
1933
|
+
return "\n".join(lines)
|
|
1934
|
+
|
|
1935
|
+
def _build_cached_system_prompt(
|
|
1936
|
+
self,
|
|
1937
|
+
role: str,
|
|
1938
|
+
guidelines: list[str] | None = None,
|
|
1939
|
+
documentation: str | None = None,
|
|
1940
|
+
examples: list[dict[str, str]] | None = None,
|
|
1941
|
+
) -> str:
|
|
1942
|
+
"""Build system prompt optimized for Anthropic prompt caching.
|
|
1943
|
+
|
|
1944
|
+
Prompt caching works best with:
|
|
1945
|
+
- Static content (guidelines, docs, coding standards)
|
|
1946
|
+
- Frequent reuse (>3 requests within 5 min)
|
|
1947
|
+
- Large context (>1024 tokens)
|
|
1948
|
+
|
|
1949
|
+
Structure: Static content goes first (cacheable), dynamic content
|
|
1950
|
+
goes in user messages (not cached).
|
|
1951
|
+
|
|
1952
|
+
Args:
|
|
1953
|
+
role: The role for the AI (e.g., "expert code reviewer")
|
|
1954
|
+
guidelines: List of static guidelines/rules
|
|
1955
|
+
documentation: Static documentation or reference material
|
|
1956
|
+
examples: Static examples for few-shot learning
|
|
1957
|
+
|
|
1958
|
+
Returns:
|
|
1959
|
+
System prompt with static content first for optimal caching
|
|
1960
|
+
|
|
1961
|
+
Example:
|
|
1962
|
+
>>> prompt = workflow._build_cached_system_prompt(
|
|
1963
|
+
... role="code reviewer",
|
|
1964
|
+
... guidelines=[
|
|
1965
|
+
... "Follow PEP 8 style guide",
|
|
1966
|
+
... "Check for security vulnerabilities",
|
|
1967
|
+
... ],
|
|
1968
|
+
... documentation="Coding standards:\\n- Use type hints\\n- Add docstrings",
|
|
1969
|
+
... )
|
|
1970
|
+
>>> # This prompt will be cached by Anthropic for 5 minutes
|
|
1971
|
+
>>> # Subsequent calls with same prompt read from cache (90% cost reduction)
|
|
1972
|
+
"""
|
|
1973
|
+
parts = []
|
|
1974
|
+
|
|
1975
|
+
# 1. Role definition (static)
|
|
1976
|
+
parts.append(f"You are a {role}.")
|
|
1977
|
+
|
|
1978
|
+
# 2. Guidelines (static - most important for caching)
|
|
1979
|
+
if guidelines:
|
|
1980
|
+
parts.append("\n# Guidelines\n")
|
|
1981
|
+
for i, guideline in enumerate(guidelines, 1):
|
|
1982
|
+
parts.append(f"{i}. {guideline}")
|
|
1983
|
+
|
|
1984
|
+
# 3. Documentation (static - good caching candidate)
|
|
1985
|
+
if documentation:
|
|
1986
|
+
parts.append("\n# Reference Documentation\n")
|
|
1987
|
+
parts.append(documentation)
|
|
1988
|
+
|
|
1989
|
+
# 4. Examples (static - excellent for few-shot learning)
|
|
1990
|
+
if examples:
|
|
1991
|
+
parts.append("\n# Examples\n")
|
|
1992
|
+
for i, example in enumerate(examples, 1):
|
|
1993
|
+
input_text = example.get("input", "")
|
|
1994
|
+
output_text = example.get("output", "")
|
|
1995
|
+
parts.append(f"\nExample {i}:")
|
|
1996
|
+
parts.append(f"Input: {input_text}")
|
|
1997
|
+
parts.append(f"Output: {output_text}")
|
|
1998
|
+
|
|
1999
|
+
# Dynamic content (user-specific context, current task) should go
|
|
2000
|
+
# in the user message, NOT in system prompt
|
|
2001
|
+
parts.append(
|
|
2002
|
+
"\n# Instructions\n"
|
|
2003
|
+
"The user will provide the specific task context in their message. "
|
|
2004
|
+
"Apply the above guidelines and reference documentation to their request."
|
|
2005
|
+
)
|
|
2006
|
+
|
|
2007
|
+
return "\n".join(parts)
|
|
2008
|
+
|
|
2009
|
+
# =========================================================================
|
|
2010
|
+
# New infrastructure methods (Phase 4)
|
|
2011
|
+
# =========================================================================
|
|
2012
|
+
|
|
2013
|
+
def _create_execution_context(
|
|
2014
|
+
self,
|
|
2015
|
+
step_name: str,
|
|
2016
|
+
task_type: str,
|
|
2017
|
+
user_id: str | None = None,
|
|
2018
|
+
session_id: str | None = None,
|
|
2019
|
+
) -> ExecutionContext:
|
|
2020
|
+
"""Create an ExecutionContext for a step execution.
|
|
2021
|
+
|
|
2022
|
+
Args:
|
|
2023
|
+
step_name: Name of the workflow step
|
|
2024
|
+
task_type: Task type for routing
|
|
2025
|
+
user_id: Optional user ID
|
|
2026
|
+
session_id: Optional session ID
|
|
2027
|
+
|
|
2028
|
+
Returns:
|
|
2029
|
+
ExecutionContext populated with workflow info
|
|
2030
|
+
|
|
2031
|
+
"""
|
|
2032
|
+
return ExecutionContext(
|
|
2033
|
+
workflow_name=self.name,
|
|
2034
|
+
step_name=step_name,
|
|
2035
|
+
user_id=user_id,
|
|
2036
|
+
session_id=session_id,
|
|
2037
|
+
metadata={
|
|
2038
|
+
"task_type": task_type,
|
|
2039
|
+
"run_id": self._run_id,
|
|
2040
|
+
"provider": self._provider_str,
|
|
2041
|
+
},
|
|
2042
|
+
)
|
|
2043
|
+
|
|
2044
|
+
def _create_default_executor(self) -> LLMExecutor:
|
|
2045
|
+
"""Create a default EmpathyLLMExecutor with optional resilience wrapper.
|
|
2046
|
+
|
|
2047
|
+
This method is called lazily when run_step_with_executor is used
|
|
2048
|
+
without a pre-configured executor.
|
|
2049
|
+
|
|
2050
|
+
When tier fallback is enabled (enable_tier_fallback=True), the base
|
|
2051
|
+
executor is returned without the ResilientExecutor wrapper to avoid
|
|
2052
|
+
double fallback (tier-level + LLM-level).
|
|
2053
|
+
|
|
2054
|
+
When tier fallback is disabled (default), the executor is wrapped with
|
|
2055
|
+
resilience features (retry, fallback, circuit breaker).
|
|
2056
|
+
|
|
2057
|
+
Returns:
|
|
2058
|
+
LLMExecutor instance (optionally wrapped with ResilientExecutor)
|
|
2059
|
+
|
|
2060
|
+
"""
|
|
2061
|
+
from attune.models.empathy_executor import EmpathyLLMExecutor
|
|
2062
|
+
from attune.models.fallback import ResilientExecutor
|
|
2063
|
+
|
|
2064
|
+
# Create the base executor
|
|
2065
|
+
base_executor = EmpathyLLMExecutor(
|
|
2066
|
+
provider=self._provider_str,
|
|
2067
|
+
api_key=self._api_key,
|
|
2068
|
+
telemetry_store=self._telemetry_backend,
|
|
2069
|
+
)
|
|
2070
|
+
|
|
2071
|
+
# When tier fallback is enabled, skip LLM-level fallback
|
|
2072
|
+
# to avoid double fallback (tier-level + LLM-level)
|
|
2073
|
+
if self._enable_tier_fallback:
|
|
2074
|
+
return base_executor
|
|
2075
|
+
|
|
2076
|
+
# Standard mode: wrap with resilience layer (retry, fallback, circuit breaker)
|
|
2077
|
+
return ResilientExecutor(executor=base_executor)
|
|
2078
|
+
|
|
2079
|
+
def _get_executor(self) -> LLMExecutor:
|
|
2080
|
+
"""Get or create the LLM executor.
|
|
2081
|
+
|
|
2082
|
+
Returns the configured executor or creates a default one.
|
|
2083
|
+
|
|
2084
|
+
Returns:
|
|
2085
|
+
LLMExecutor instance
|
|
2086
|
+
|
|
2087
|
+
"""
|
|
2088
|
+
if self._executor is None:
|
|
2089
|
+
self._executor = self._create_default_executor()
|
|
2090
|
+
return self._executor
|
|
2091
|
+
|
|
2092
|
+
# Note: _emit_call_telemetry and _emit_workflow_telemetry are inherited from TelemetryMixin
|
|
2093
|
+
|
|
2094
|
+
async def run_step_with_executor(
|
|
2095
|
+
self,
|
|
2096
|
+
step: WorkflowStepConfig,
|
|
2097
|
+
prompt: str,
|
|
2098
|
+
system: str | None = None,
|
|
2099
|
+
**kwargs: Any,
|
|
2100
|
+
) -> tuple[str, int, int, float]:
|
|
2101
|
+
"""Run a workflow step using the LLMExecutor.
|
|
2102
|
+
|
|
2103
|
+
This method provides a unified interface for executing steps with
|
|
2104
|
+
automatic routing, telemetry, and cost tracking. If no executor
|
|
2105
|
+
was provided at construction, a default EmpathyLLMExecutor is created.
|
|
2106
|
+
|
|
2107
|
+
Args:
|
|
2108
|
+
step: WorkflowStepConfig defining the step
|
|
2109
|
+
prompt: The prompt to send
|
|
2110
|
+
system: Optional system prompt
|
|
2111
|
+
**kwargs: Additional arguments passed to executor
|
|
2112
|
+
|
|
2113
|
+
Returns:
|
|
2114
|
+
Tuple of (content, input_tokens, output_tokens, cost)
|
|
2115
|
+
|
|
2116
|
+
"""
|
|
2117
|
+
executor = self._get_executor()
|
|
2118
|
+
|
|
2119
|
+
context = self._create_execution_context(
|
|
2120
|
+
step_name=step.name,
|
|
2121
|
+
task_type=step.task_type,
|
|
2122
|
+
)
|
|
2123
|
+
|
|
2124
|
+
start_time = datetime.now()
|
|
2125
|
+
response = await executor.run(
|
|
2126
|
+
task_type=step.task_type,
|
|
2127
|
+
prompt=prompt,
|
|
2128
|
+
system=system,
|
|
2129
|
+
context=context,
|
|
2130
|
+
**kwargs,
|
|
2131
|
+
)
|
|
2132
|
+
end_time = datetime.now()
|
|
2133
|
+
latency_ms = int((end_time - start_time).total_seconds() * 1000)
|
|
2134
|
+
|
|
2135
|
+
# Emit telemetry
|
|
2136
|
+
self._emit_call_telemetry(
|
|
2137
|
+
step_name=step.name,
|
|
2138
|
+
task_type=step.task_type,
|
|
2139
|
+
tier=response.tier,
|
|
2140
|
+
model_id=response.model_id,
|
|
2141
|
+
input_tokens=response.tokens_input,
|
|
2142
|
+
output_tokens=response.tokens_output,
|
|
2143
|
+
cost=response.cost_estimate,
|
|
2144
|
+
latency_ms=latency_ms,
|
|
2145
|
+
success=True,
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
return (
|
|
2149
|
+
response.content,
|
|
2150
|
+
response.tokens_input,
|
|
2151
|
+
response.tokens_output,
|
|
2152
|
+
response.cost_estimate,
|
|
2153
|
+
)
|
|
2154
|
+
|
|
2155
|
+
# =========================================================================
|
|
2156
|
+
# XML Prompt Integration (Phase 4)
|
|
2157
|
+
# =========================================================================
|
|
2158
|
+
|
|
2159
|
+
def _get_xml_config(self) -> dict[str, Any]:
|
|
2160
|
+
"""Get XML prompt configuration for this workflow.
|
|
2161
|
+
|
|
2162
|
+
Returns:
|
|
2163
|
+
Dictionary with XML configuration settings.
|
|
2164
|
+
|
|
2165
|
+
"""
|
|
2166
|
+
if self._config is None:
|
|
2167
|
+
return {}
|
|
2168
|
+
return self._config.get_xml_config_for_workflow(self.name)
|
|
2169
|
+
|
|
2170
|
+
def _is_xml_enabled(self) -> bool:
|
|
2171
|
+
"""Check if XML prompts are enabled for this workflow."""
|
|
2172
|
+
config = self._get_xml_config()
|
|
2173
|
+
return bool(config.get("enabled", False))
|
|
2174
|
+
|
|
2175
|
+
def _render_xml_prompt(
|
|
2176
|
+
self,
|
|
2177
|
+
role: str,
|
|
2178
|
+
goal: str,
|
|
2179
|
+
instructions: list[str],
|
|
2180
|
+
constraints: list[str],
|
|
2181
|
+
input_type: str,
|
|
2182
|
+
input_payload: str,
|
|
2183
|
+
extra: dict[str, Any] | None = None,
|
|
2184
|
+
) -> str:
|
|
2185
|
+
"""Render a prompt using XML template if enabled.
|
|
2186
|
+
|
|
2187
|
+
Args:
|
|
2188
|
+
role: The role for the AI (e.g., "security analyst").
|
|
2189
|
+
goal: The primary objective.
|
|
2190
|
+
instructions: Step-by-step instructions.
|
|
2191
|
+
constraints: Rules and guidelines.
|
|
2192
|
+
input_type: Type of input ("code", "diff", "document").
|
|
2193
|
+
input_payload: The content to process.
|
|
2194
|
+
extra: Additional context data.
|
|
2195
|
+
|
|
2196
|
+
Returns:
|
|
2197
|
+
Rendered prompt string (XML if enabled, plain text otherwise).
|
|
2198
|
+
|
|
2199
|
+
"""
|
|
2200
|
+
from attune.prompts import PromptContext, XmlPromptTemplate, get_template
|
|
2201
|
+
|
|
2202
|
+
config = self._get_xml_config()
|
|
2203
|
+
|
|
2204
|
+
if not config.get("enabled", False):
|
|
2205
|
+
# Fall back to plain text
|
|
2206
|
+
return self._render_plain_prompt(
|
|
2207
|
+
role,
|
|
2208
|
+
goal,
|
|
2209
|
+
instructions,
|
|
2210
|
+
constraints,
|
|
2211
|
+
input_type,
|
|
2212
|
+
input_payload,
|
|
2213
|
+
)
|
|
2214
|
+
|
|
2215
|
+
# Create context
|
|
2216
|
+
context = PromptContext(
|
|
2217
|
+
role=role,
|
|
2218
|
+
goal=goal,
|
|
2219
|
+
instructions=instructions,
|
|
2220
|
+
constraints=constraints,
|
|
2221
|
+
input_type=input_type,
|
|
2222
|
+
input_payload=input_payload,
|
|
2223
|
+
extra=extra or {},
|
|
2224
|
+
)
|
|
2225
|
+
|
|
2226
|
+
# Get template
|
|
2227
|
+
template_name = config.get("template_name", self.name)
|
|
2228
|
+
template = get_template(template_name)
|
|
2229
|
+
|
|
2230
|
+
if template is None:
|
|
2231
|
+
# Create a basic XML template if no built-in found
|
|
2232
|
+
template = XmlPromptTemplate(
|
|
2233
|
+
name=self.name,
|
|
2234
|
+
schema_version=config.get("schema_version", "1.0"),
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
return template.render(context)
|
|
2238
|
+
|
|
2239
|
+
def _render_plain_prompt(
|
|
2240
|
+
self,
|
|
2241
|
+
role: str,
|
|
2242
|
+
goal: str,
|
|
2243
|
+
instructions: list[str],
|
|
2244
|
+
constraints: list[str],
|
|
2245
|
+
input_type: str,
|
|
2246
|
+
input_payload: str,
|
|
2247
|
+
) -> str:
|
|
2248
|
+
"""Render a plain text prompt (fallback when XML is disabled)."""
|
|
2249
|
+
parts = [f"You are a {role}.", "", f"Goal: {goal}", ""]
|
|
2250
|
+
|
|
2251
|
+
if instructions:
|
|
2252
|
+
parts.append("Instructions:")
|
|
2253
|
+
for i, inst in enumerate(instructions, 1):
|
|
2254
|
+
parts.append(f"{i}. {inst}")
|
|
2255
|
+
parts.append("")
|
|
2256
|
+
|
|
2257
|
+
if constraints:
|
|
2258
|
+
parts.append("Guidelines:")
|
|
2259
|
+
for constraint in constraints:
|
|
2260
|
+
parts.append(f"- {constraint}")
|
|
2261
|
+
parts.append("")
|
|
2262
|
+
|
|
2263
|
+
if input_payload:
|
|
2264
|
+
parts.append(f"Input ({input_type}):")
|
|
2265
|
+
parts.append(input_payload)
|
|
2266
|
+
|
|
2267
|
+
return "\n".join(parts)
|
|
2268
|
+
|
|
2269
|
+
def _parse_xml_response(self, response: str) -> dict[str, Any]:
|
|
2270
|
+
"""Parse an XML response if XML enforcement is enabled.
|
|
2271
|
+
|
|
2272
|
+
Args:
|
|
2273
|
+
response: The LLM response text.
|
|
2274
|
+
|
|
2275
|
+
Returns:
|
|
2276
|
+
Dictionary with parsed fields or raw response data.
|
|
2277
|
+
|
|
2278
|
+
"""
|
|
2279
|
+
from attune.prompts import XmlResponseParser
|
|
2280
|
+
|
|
2281
|
+
config = self._get_xml_config()
|
|
2282
|
+
|
|
2283
|
+
if not config.get("enforce_response_xml", False):
|
|
2284
|
+
# No parsing needed, return as-is
|
|
2285
|
+
return {
|
|
2286
|
+
"_parsed_response": None,
|
|
2287
|
+
"_raw": response,
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
fallback = config.get("fallback_on_parse_error", True)
|
|
2291
|
+
parser = XmlResponseParser(fallback_on_error=fallback)
|
|
2292
|
+
parsed = parser.parse(response)
|
|
2293
|
+
|
|
2294
|
+
return {
|
|
2295
|
+
"_parsed_response": parsed,
|
|
2296
|
+
"_raw": response,
|
|
2297
|
+
"summary": parsed.summary,
|
|
2298
|
+
"findings": [f.to_dict() for f in parsed.findings],
|
|
2299
|
+
"checklist": parsed.checklist,
|
|
2300
|
+
"xml_parsed": parsed.success,
|
|
2301
|
+
"parse_errors": parsed.errors,
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
def _extract_findings_from_response(
|
|
2305
|
+
self,
|
|
2306
|
+
response: str,
|
|
2307
|
+
files_changed: list[str],
|
|
2308
|
+
code_context: str = "",
|
|
2309
|
+
) -> list[dict[str, Any]]:
|
|
2310
|
+
"""Extract structured findings from LLM response.
|
|
2311
|
+
|
|
2312
|
+
Tries multiple strategies in order:
|
|
2313
|
+
1. XML parsing (if XML tags present)
|
|
2314
|
+
2. Regex-based extraction for file:line patterns
|
|
2315
|
+
3. Returns empty list if no findings extractable
|
|
2316
|
+
|
|
2317
|
+
Args:
|
|
2318
|
+
response: Raw LLM response text
|
|
2319
|
+
files_changed: List of files being analyzed (for context)
|
|
2320
|
+
code_context: Original code being reviewed (optional)
|
|
2321
|
+
|
|
2322
|
+
Returns:
|
|
2323
|
+
List of findings matching WorkflowFinding schema:
|
|
2324
|
+
[
|
|
2325
|
+
{
|
|
2326
|
+
"id": "unique-id",
|
|
2327
|
+
"file": "relative/path.py",
|
|
2328
|
+
"line": 42,
|
|
2329
|
+
"column": 10,
|
|
2330
|
+
"severity": "high",
|
|
2331
|
+
"category": "security",
|
|
2332
|
+
"message": "Brief message",
|
|
2333
|
+
"details": "Extended explanation",
|
|
2334
|
+
"recommendation": "Fix suggestion"
|
|
2335
|
+
}
|
|
2336
|
+
]
|
|
2337
|
+
|
|
2338
|
+
"""
|
|
2339
|
+
import re
|
|
2340
|
+
import uuid
|
|
2341
|
+
|
|
2342
|
+
findings: list[dict[str, Any]] = []
|
|
2343
|
+
|
|
2344
|
+
# Strategy 1: Try XML parsing first
|
|
2345
|
+
response_lower = response.lower()
|
|
2346
|
+
if (
|
|
2347
|
+
"<finding>" in response_lower
|
|
2348
|
+
or "<issue>" in response_lower
|
|
2349
|
+
or "<findings>" in response_lower
|
|
2350
|
+
):
|
|
2351
|
+
# Parse XML directly (bypass config checks)
|
|
2352
|
+
from attune.prompts import XmlResponseParser
|
|
2353
|
+
|
|
2354
|
+
parser = XmlResponseParser(fallback_on_error=True)
|
|
2355
|
+
parsed = parser.parse(response)
|
|
2356
|
+
|
|
2357
|
+
if parsed.success and parsed.findings:
|
|
2358
|
+
for raw_finding in parsed.findings:
|
|
2359
|
+
enriched = self._enrich_finding_with_location(
|
|
2360
|
+
raw_finding.to_dict(),
|
|
2361
|
+
files_changed,
|
|
2362
|
+
)
|
|
2363
|
+
findings.append(enriched)
|
|
2364
|
+
return findings
|
|
2365
|
+
|
|
2366
|
+
# Strategy 2: Regex-based extraction for common patterns
|
|
2367
|
+
# Match patterns like:
|
|
2368
|
+
# - "src/auth.py:42: SQL injection found"
|
|
2369
|
+
# - "In file src/auth.py line 42"
|
|
2370
|
+
# - "auth.py (line 42, column 10)"
|
|
2371
|
+
patterns = [
|
|
2372
|
+
# Pattern 1: file.py:line:column: message
|
|
2373
|
+
r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):(\d+):\s*(.+)",
|
|
2374
|
+
# Pattern 2: file.py:line: message
|
|
2375
|
+
r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+):\s*(.+)",
|
|
2376
|
+
# Pattern 3: in file X line Y
|
|
2377
|
+
r"(?:in file|file)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
|
|
2378
|
+
# Pattern 4: file.py (line X)
|
|
2379
|
+
r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s*\(line\s+(\d+)(?:,\s*col(?:umn)?\s+(\d+))?\)",
|
|
2380
|
+
]
|
|
2381
|
+
|
|
2382
|
+
for pattern in patterns:
|
|
2383
|
+
matches = re.findall(pattern, response, re.IGNORECASE)
|
|
2384
|
+
for match in matches:
|
|
2385
|
+
if len(match) >= 2:
|
|
2386
|
+
file_path = match[0]
|
|
2387
|
+
line = int(match[1])
|
|
2388
|
+
|
|
2389
|
+
# Handle different pattern formats
|
|
2390
|
+
if len(match) == 4 and match[2].isdigit():
|
|
2391
|
+
# Pattern 1: file:line:col:message
|
|
2392
|
+
column = int(match[2])
|
|
2393
|
+
message = match[3]
|
|
2394
|
+
elif len(match) == 3 and match[2] and not match[2].isdigit():
|
|
2395
|
+
# Pattern 2: file:line:message
|
|
2396
|
+
column = 1
|
|
2397
|
+
message = match[2]
|
|
2398
|
+
elif len(match) == 3 and match[2].isdigit():
|
|
2399
|
+
# Pattern 4: file (line col)
|
|
2400
|
+
column = int(match[2])
|
|
2401
|
+
message = ""
|
|
2402
|
+
else:
|
|
2403
|
+
# Pattern 3: in file X line Y (no message)
|
|
2404
|
+
column = 1
|
|
2405
|
+
message = ""
|
|
2406
|
+
|
|
2407
|
+
# Determine severity from keywords in message
|
|
2408
|
+
severity = self._infer_severity(message)
|
|
2409
|
+
category = self._infer_category(message)
|
|
2410
|
+
|
|
2411
|
+
findings.append(
|
|
2412
|
+
{
|
|
2413
|
+
"id": str(uuid.uuid4())[:8],
|
|
2414
|
+
"file": file_path,
|
|
2415
|
+
"line": line,
|
|
2416
|
+
"column": column,
|
|
2417
|
+
"severity": severity,
|
|
2418
|
+
"category": category,
|
|
2419
|
+
"message": message.strip() if message else "",
|
|
2420
|
+
"details": "",
|
|
2421
|
+
"recommendation": "",
|
|
2422
|
+
},
|
|
2423
|
+
)
|
|
2424
|
+
|
|
2425
|
+
# Deduplicate by file:line
|
|
2426
|
+
seen = set()
|
|
2427
|
+
unique_findings = []
|
|
2428
|
+
for finding in findings:
|
|
2429
|
+
key = (finding["file"], finding["line"])
|
|
2430
|
+
if key not in seen:
|
|
2431
|
+
seen.add(key)
|
|
2432
|
+
unique_findings.append(finding)
|
|
2433
|
+
|
|
2434
|
+
return unique_findings
|
|
2435
|
+
|
|
2436
|
+
def _enrich_finding_with_location(
|
|
2437
|
+
self,
|
|
2438
|
+
raw_finding: dict[str, Any],
|
|
2439
|
+
files_changed: list[str],
|
|
2440
|
+
) -> dict[str, Any]:
|
|
2441
|
+
"""Enrich a finding from XML parser with file/line/column fields.
|
|
2442
|
+
|
|
2443
|
+
Args:
|
|
2444
|
+
raw_finding: Finding dict from XML parser (has 'location' string field)
|
|
2445
|
+
files_changed: List of files being analyzed
|
|
2446
|
+
|
|
2447
|
+
Returns:
|
|
2448
|
+
Enriched finding dict with file, line, column fields
|
|
2449
|
+
|
|
2450
|
+
"""
|
|
2451
|
+
import uuid
|
|
2452
|
+
|
|
2453
|
+
location_str = raw_finding.get("location", "")
|
|
2454
|
+
file_path, line, column = self._parse_location_string(location_str, files_changed)
|
|
2455
|
+
|
|
2456
|
+
# Map category from severity or title keywords
|
|
2457
|
+
category = self._infer_category(
|
|
2458
|
+
raw_finding.get("title", "") + " " + raw_finding.get("details", ""),
|
|
2459
|
+
)
|
|
2460
|
+
|
|
2461
|
+
return {
|
|
2462
|
+
"id": str(uuid.uuid4())[:8],
|
|
2463
|
+
"file": file_path,
|
|
2464
|
+
"line": line,
|
|
2465
|
+
"column": column,
|
|
2466
|
+
"severity": raw_finding.get("severity", "medium"),
|
|
2467
|
+
"category": category,
|
|
2468
|
+
"message": raw_finding.get("title", ""),
|
|
2469
|
+
"details": raw_finding.get("details", ""),
|
|
2470
|
+
"recommendation": raw_finding.get("fix", ""),
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
def _parse_location_string(
|
|
2474
|
+
self,
|
|
2475
|
+
location: str,
|
|
2476
|
+
files_changed: list[str],
|
|
2477
|
+
) -> tuple[str, int, int]:
|
|
2478
|
+
"""Parse a location string to extract file, line, column.
|
|
2479
|
+
|
|
2480
|
+
Handles formats like:
|
|
2481
|
+
- "src/auth.py:42:10"
|
|
2482
|
+
- "src/auth.py:42"
|
|
2483
|
+
- "auth.py line 42"
|
|
2484
|
+
- "line 42 in auth.py"
|
|
2485
|
+
|
|
2486
|
+
Args:
|
|
2487
|
+
location: Location string from finding
|
|
2488
|
+
files_changed: List of files being analyzed (for fallback)
|
|
2489
|
+
|
|
2490
|
+
Returns:
|
|
2491
|
+
Tuple of (file_path, line_number, column_number)
|
|
2492
|
+
Defaults: ("", 1, 1) if parsing fails
|
|
2493
|
+
|
|
2494
|
+
"""
|
|
2495
|
+
import re
|
|
2496
|
+
|
|
2497
|
+
if not location:
|
|
2498
|
+
# Fallback: use first file if available
|
|
2499
|
+
return (files_changed[0] if files_changed else "", 1, 1)
|
|
2500
|
+
|
|
2501
|
+
# Try colon-separated format: file.py:line:col
|
|
2502
|
+
match = re.search(
|
|
2503
|
+
r"([^\s:]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php)):(\d+)(?::(\d+))?",
|
|
2504
|
+
location,
|
|
2505
|
+
)
|
|
2506
|
+
if match:
|
|
2507
|
+
file_path = match.group(1)
|
|
2508
|
+
line = int(match.group(2))
|
|
2509
|
+
column = int(match.group(3)) if match.group(3) else 1
|
|
2510
|
+
return (file_path, line, column)
|
|
2511
|
+
|
|
2512
|
+
# Try "line X in file.py" format
|
|
2513
|
+
match = re.search(
|
|
2514
|
+
r"line\s+(\d+)\s+(?:in|of)\s+([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))",
|
|
2515
|
+
location,
|
|
2516
|
+
re.IGNORECASE,
|
|
2517
|
+
)
|
|
2518
|
+
if match:
|
|
2519
|
+
line = int(match.group(1))
|
|
2520
|
+
file_path = match.group(2)
|
|
2521
|
+
return (file_path, line, 1)
|
|
2522
|
+
|
|
2523
|
+
# Try "file.py line X" format
|
|
2524
|
+
match = re.search(
|
|
2525
|
+
r"([^\s]+\.(?:py|ts|tsx|js|jsx|java|go|rb|php))\s+line\s+(\d+)",
|
|
2526
|
+
location,
|
|
2527
|
+
re.IGNORECASE,
|
|
2528
|
+
)
|
|
2529
|
+
if match:
|
|
2530
|
+
file_path = match.group(1)
|
|
2531
|
+
line = int(match.group(2))
|
|
2532
|
+
return (file_path, line, 1)
|
|
2533
|
+
|
|
2534
|
+
# Extract just line number if present
|
|
2535
|
+
match = re.search(r"line\s+(\d+)", location, re.IGNORECASE)
|
|
2536
|
+
if match:
|
|
2537
|
+
line = int(match.group(1))
|
|
2538
|
+
# Use first file from files_changed as fallback
|
|
2539
|
+
file_path = files_changed[0] if files_changed else ""
|
|
2540
|
+
return (file_path, line, 1)
|
|
2541
|
+
|
|
2542
|
+
# Couldn't parse - return defaults
|
|
2543
|
+
return (files_changed[0] if files_changed else "", 1, 1)
|
|
2544
|
+
|
|
2545
|
+
def _infer_severity(self, text: str) -> str:
|
|
2546
|
+
"""Infer severity from keywords in text.
|
|
2547
|
+
|
|
2548
|
+
Args:
|
|
2549
|
+
text: Message or title text
|
|
2550
|
+
|
|
2551
|
+
Returns:
|
|
2552
|
+
Severity level: critical, high, medium, low, or info
|
|
2553
|
+
|
|
2554
|
+
"""
|
|
2555
|
+
text_lower = text.lower()
|
|
2556
|
+
|
|
2557
|
+
if any(
|
|
2558
|
+
word in text_lower
|
|
2559
|
+
for word in [
|
|
2560
|
+
"critical",
|
|
2561
|
+
"severe",
|
|
2562
|
+
"exploit",
|
|
2563
|
+
"vulnerability",
|
|
2564
|
+
"injection",
|
|
2565
|
+
"remote code execution",
|
|
2566
|
+
"rce",
|
|
2567
|
+
]
|
|
2568
|
+
):
|
|
2569
|
+
return "critical"
|
|
2570
|
+
|
|
2571
|
+
if any(
|
|
2572
|
+
word in text_lower
|
|
2573
|
+
for word in [
|
|
2574
|
+
"high",
|
|
2575
|
+
"security",
|
|
2576
|
+
"unsafe",
|
|
2577
|
+
"dangerous",
|
|
2578
|
+
"xss",
|
|
2579
|
+
"csrf",
|
|
2580
|
+
"auth",
|
|
2581
|
+
"password",
|
|
2582
|
+
"secret",
|
|
2583
|
+
]
|
|
2584
|
+
):
|
|
2585
|
+
return "high"
|
|
2586
|
+
|
|
2587
|
+
if any(
|
|
2588
|
+
word in text_lower
|
|
2589
|
+
for word in [
|
|
2590
|
+
"warning",
|
|
2591
|
+
"issue",
|
|
2592
|
+
"problem",
|
|
2593
|
+
"bug",
|
|
2594
|
+
"error",
|
|
2595
|
+
"deprecated",
|
|
2596
|
+
"leak",
|
|
2597
|
+
]
|
|
2598
|
+
):
|
|
2599
|
+
return "medium"
|
|
2600
|
+
|
|
2601
|
+
if any(word in text_lower for word in ["low", "minor", "style", "format", "typo"]):
|
|
2602
|
+
return "low"
|
|
2603
|
+
|
|
2604
|
+
return "info"
|
|
2605
|
+
|
|
2606
|
+
def _infer_category(self, text: str) -> str:
|
|
2607
|
+
"""Infer finding category from keywords.
|
|
2608
|
+
|
|
2609
|
+
Args:
|
|
2610
|
+
text: Message or title text
|
|
2611
|
+
|
|
2612
|
+
Returns:
|
|
2613
|
+
Category: security, performance, maintainability, style, or correctness
|
|
2614
|
+
|
|
2615
|
+
"""
|
|
2616
|
+
text_lower = text.lower()
|
|
2617
|
+
|
|
2618
|
+
if any(
|
|
2619
|
+
word in text_lower
|
|
2620
|
+
for word in [
|
|
2621
|
+
"security",
|
|
2622
|
+
"vulnerability",
|
|
2623
|
+
"injection",
|
|
2624
|
+
"xss",
|
|
2625
|
+
"csrf",
|
|
2626
|
+
"auth",
|
|
2627
|
+
"encrypt",
|
|
2628
|
+
"password",
|
|
2629
|
+
"secret",
|
|
2630
|
+
"unsafe",
|
|
2631
|
+
]
|
|
2632
|
+
):
|
|
2633
|
+
return "security"
|
|
2634
|
+
|
|
2635
|
+
if any(
|
|
2636
|
+
word in text_lower
|
|
2637
|
+
for word in [
|
|
2638
|
+
"performance",
|
|
2639
|
+
"slow",
|
|
2640
|
+
"memory",
|
|
2641
|
+
"leak",
|
|
2642
|
+
"inefficient",
|
|
2643
|
+
"optimization",
|
|
2644
|
+
"cache",
|
|
2645
|
+
]
|
|
2646
|
+
):
|
|
2647
|
+
return "performance"
|
|
2648
|
+
|
|
2649
|
+
if any(
|
|
2650
|
+
word in text_lower
|
|
2651
|
+
for word in [
|
|
2652
|
+
"complex",
|
|
2653
|
+
"refactor",
|
|
2654
|
+
"duplicate",
|
|
2655
|
+
"maintainability",
|
|
2656
|
+
"readability",
|
|
2657
|
+
"documentation",
|
|
2658
|
+
]
|
|
2659
|
+
):
|
|
2660
|
+
return "maintainability"
|
|
2661
|
+
|
|
2662
|
+
if any(
|
|
2663
|
+
word in text_lower for word in ["style", "format", "lint", "convention", "whitespace"]
|
|
2664
|
+
):
|
|
2665
|
+
return "style"
|
|
2666
|
+
|
|
2667
|
+
return "correctness"
|