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.
Files changed (457) hide show
  1. attune/__init__.py +358 -0
  2. attune/adaptive/__init__.py +13 -0
  3. attune/adaptive/task_complexity.py +127 -0
  4. attune/agent_monitoring.py +414 -0
  5. attune/cache/__init__.py +117 -0
  6. attune/cache/base.py +166 -0
  7. attune/cache/dependency_manager.py +256 -0
  8. attune/cache/hash_only.py +251 -0
  9. attune/cache/hybrid.py +457 -0
  10. attune/cache/storage.py +285 -0
  11. attune/cache_monitor.py +356 -0
  12. attune/cache_stats.py +298 -0
  13. attune/cli/__init__.py +152 -0
  14. attune/cli/__main__.py +12 -0
  15. attune/cli/commands/__init__.py +1 -0
  16. attune/cli/commands/batch.py +264 -0
  17. attune/cli/commands/cache.py +248 -0
  18. attune/cli/commands/help.py +331 -0
  19. attune/cli/commands/info.py +140 -0
  20. attune/cli/commands/inspect.py +436 -0
  21. attune/cli/commands/inspection.py +57 -0
  22. attune/cli/commands/memory.py +48 -0
  23. attune/cli/commands/metrics.py +92 -0
  24. attune/cli/commands/orchestrate.py +184 -0
  25. attune/cli/commands/patterns.py +207 -0
  26. attune/cli/commands/profiling.py +202 -0
  27. attune/cli/commands/provider.py +98 -0
  28. attune/cli/commands/routing.py +285 -0
  29. attune/cli/commands/setup.py +96 -0
  30. attune/cli/commands/status.py +235 -0
  31. attune/cli/commands/sync.py +166 -0
  32. attune/cli/commands/tier.py +121 -0
  33. attune/cli/commands/utilities.py +114 -0
  34. attune/cli/commands/workflow.py +579 -0
  35. attune/cli/core.py +32 -0
  36. attune/cli/parsers/__init__.py +68 -0
  37. attune/cli/parsers/batch.py +118 -0
  38. attune/cli/parsers/cache.py +65 -0
  39. attune/cli/parsers/help.py +41 -0
  40. attune/cli/parsers/info.py +26 -0
  41. attune/cli/parsers/inspect.py +66 -0
  42. attune/cli/parsers/metrics.py +42 -0
  43. attune/cli/parsers/orchestrate.py +61 -0
  44. attune/cli/parsers/patterns.py +54 -0
  45. attune/cli/parsers/provider.py +40 -0
  46. attune/cli/parsers/routing.py +110 -0
  47. attune/cli/parsers/setup.py +42 -0
  48. attune/cli/parsers/status.py +47 -0
  49. attune/cli/parsers/sync.py +31 -0
  50. attune/cli/parsers/tier.py +33 -0
  51. attune/cli/parsers/workflow.py +77 -0
  52. attune/cli/utils/__init__.py +1 -0
  53. attune/cli/utils/data.py +242 -0
  54. attune/cli/utils/helpers.py +68 -0
  55. attune/cli_legacy.py +3957 -0
  56. attune/cli_minimal.py +1159 -0
  57. attune/cli_router.py +437 -0
  58. attune/cli_unified.py +814 -0
  59. attune/config/__init__.py +66 -0
  60. attune/config/xml_config.py +286 -0
  61. attune/config.py +545 -0
  62. attune/coordination.py +870 -0
  63. attune/core.py +1511 -0
  64. attune/core_modules/__init__.py +15 -0
  65. attune/cost_tracker.py +626 -0
  66. attune/dashboard/__init__.py +41 -0
  67. attune/dashboard/app.py +512 -0
  68. attune/dashboard/simple_server.py +435 -0
  69. attune/dashboard/standalone_server.py +547 -0
  70. attune/discovery.py +306 -0
  71. attune/emergence.py +306 -0
  72. attune/exceptions.py +123 -0
  73. attune/feedback_loops.py +373 -0
  74. attune/hot_reload/README.md +473 -0
  75. attune/hot_reload/__init__.py +62 -0
  76. attune/hot_reload/config.py +83 -0
  77. attune/hot_reload/integration.py +229 -0
  78. attune/hot_reload/reloader.py +298 -0
  79. attune/hot_reload/watcher.py +183 -0
  80. attune/hot_reload/websocket.py +177 -0
  81. attune/levels.py +577 -0
  82. attune/leverage_points.py +441 -0
  83. attune/logging_config.py +261 -0
  84. attune/mcp/__init__.py +10 -0
  85. attune/mcp/server.py +506 -0
  86. attune/memory/__init__.py +237 -0
  87. attune/memory/claude_memory.py +469 -0
  88. attune/memory/config.py +224 -0
  89. attune/memory/control_panel.py +1290 -0
  90. attune/memory/control_panel_support.py +145 -0
  91. attune/memory/cross_session.py +845 -0
  92. attune/memory/edges.py +179 -0
  93. attune/memory/encryption.py +159 -0
  94. attune/memory/file_session.py +770 -0
  95. attune/memory/graph.py +570 -0
  96. attune/memory/long_term.py +913 -0
  97. attune/memory/long_term_types.py +99 -0
  98. attune/memory/mixins/__init__.py +25 -0
  99. attune/memory/mixins/backend_init_mixin.py +249 -0
  100. attune/memory/mixins/capabilities_mixin.py +208 -0
  101. attune/memory/mixins/handoff_mixin.py +208 -0
  102. attune/memory/mixins/lifecycle_mixin.py +49 -0
  103. attune/memory/mixins/long_term_mixin.py +352 -0
  104. attune/memory/mixins/promotion_mixin.py +109 -0
  105. attune/memory/mixins/short_term_mixin.py +182 -0
  106. attune/memory/nodes.py +179 -0
  107. attune/memory/redis_bootstrap.py +540 -0
  108. attune/memory/security/__init__.py +31 -0
  109. attune/memory/security/audit_logger.py +932 -0
  110. attune/memory/security/pii_scrubber.py +640 -0
  111. attune/memory/security/secrets_detector.py +678 -0
  112. attune/memory/short_term.py +2192 -0
  113. attune/memory/simple_storage.py +302 -0
  114. attune/memory/storage/__init__.py +15 -0
  115. attune/memory/storage_backend.py +167 -0
  116. attune/memory/summary_index.py +583 -0
  117. attune/memory/types.py +446 -0
  118. attune/memory/unified.py +182 -0
  119. attune/meta_workflows/__init__.py +74 -0
  120. attune/meta_workflows/agent_creator.py +248 -0
  121. attune/meta_workflows/builtin_templates.py +567 -0
  122. attune/meta_workflows/cli_commands/__init__.py +56 -0
  123. attune/meta_workflows/cli_commands/agent_commands.py +321 -0
  124. attune/meta_workflows/cli_commands/analytics_commands.py +442 -0
  125. attune/meta_workflows/cli_commands/config_commands.py +232 -0
  126. attune/meta_workflows/cli_commands/memory_commands.py +182 -0
  127. attune/meta_workflows/cli_commands/template_commands.py +354 -0
  128. attune/meta_workflows/cli_commands/workflow_commands.py +382 -0
  129. attune/meta_workflows/cli_meta_workflows.py +59 -0
  130. attune/meta_workflows/form_engine.py +292 -0
  131. attune/meta_workflows/intent_detector.py +409 -0
  132. attune/meta_workflows/models.py +569 -0
  133. attune/meta_workflows/pattern_learner.py +738 -0
  134. attune/meta_workflows/plan_generator.py +384 -0
  135. attune/meta_workflows/session_context.py +397 -0
  136. attune/meta_workflows/template_registry.py +229 -0
  137. attune/meta_workflows/workflow.py +984 -0
  138. attune/metrics/__init__.py +12 -0
  139. attune/metrics/collector.py +31 -0
  140. attune/metrics/prompt_metrics.py +194 -0
  141. attune/models/__init__.py +172 -0
  142. attune/models/__main__.py +13 -0
  143. attune/models/adaptive_routing.py +437 -0
  144. attune/models/auth_cli.py +444 -0
  145. attune/models/auth_strategy.py +450 -0
  146. attune/models/cli.py +655 -0
  147. attune/models/empathy_executor.py +354 -0
  148. attune/models/executor.py +257 -0
  149. attune/models/fallback.py +762 -0
  150. attune/models/provider_config.py +282 -0
  151. attune/models/registry.py +472 -0
  152. attune/models/tasks.py +359 -0
  153. attune/models/telemetry/__init__.py +71 -0
  154. attune/models/telemetry/analytics.py +594 -0
  155. attune/models/telemetry/backend.py +196 -0
  156. attune/models/telemetry/data_models.py +431 -0
  157. attune/models/telemetry/storage.py +489 -0
  158. attune/models/token_estimator.py +420 -0
  159. attune/models/validation.py +280 -0
  160. attune/monitoring/__init__.py +52 -0
  161. attune/monitoring/alerts.py +946 -0
  162. attune/monitoring/alerts_cli.py +448 -0
  163. attune/monitoring/multi_backend.py +271 -0
  164. attune/monitoring/otel_backend.py +362 -0
  165. attune/optimization/__init__.py +19 -0
  166. attune/optimization/context_optimizer.py +272 -0
  167. attune/orchestration/__init__.py +67 -0
  168. attune/orchestration/agent_templates.py +707 -0
  169. attune/orchestration/config_store.py +499 -0
  170. attune/orchestration/execution_strategies.py +2111 -0
  171. attune/orchestration/meta_orchestrator.py +1168 -0
  172. attune/orchestration/pattern_learner.py +696 -0
  173. attune/orchestration/real_tools.py +931 -0
  174. attune/pattern_cache.py +187 -0
  175. attune/pattern_library.py +542 -0
  176. attune/patterns/debugging/all_patterns.json +81 -0
  177. attune/patterns/debugging/workflow_20260107_1770825e.json +77 -0
  178. attune/patterns/refactoring_memory.json +89 -0
  179. attune/persistence.py +564 -0
  180. attune/platform_utils.py +265 -0
  181. attune/plugins/__init__.py +28 -0
  182. attune/plugins/base.py +361 -0
  183. attune/plugins/registry.py +268 -0
  184. attune/project_index/__init__.py +32 -0
  185. attune/project_index/cli.py +335 -0
  186. attune/project_index/index.py +667 -0
  187. attune/project_index/models.py +504 -0
  188. attune/project_index/reports.py +474 -0
  189. attune/project_index/scanner.py +777 -0
  190. attune/project_index/scanner_parallel.py +291 -0
  191. attune/prompts/__init__.py +61 -0
  192. attune/prompts/config.py +77 -0
  193. attune/prompts/context.py +177 -0
  194. attune/prompts/parser.py +285 -0
  195. attune/prompts/registry.py +313 -0
  196. attune/prompts/templates.py +208 -0
  197. attune/redis_config.py +302 -0
  198. attune/redis_memory.py +799 -0
  199. attune/resilience/__init__.py +56 -0
  200. attune/resilience/circuit_breaker.py +256 -0
  201. attune/resilience/fallback.py +179 -0
  202. attune/resilience/health.py +300 -0
  203. attune/resilience/retry.py +209 -0
  204. attune/resilience/timeout.py +135 -0
  205. attune/routing/__init__.py +43 -0
  206. attune/routing/chain_executor.py +433 -0
  207. attune/routing/classifier.py +217 -0
  208. attune/routing/smart_router.py +234 -0
  209. attune/routing/workflow_registry.py +343 -0
  210. attune/scaffolding/README.md +589 -0
  211. attune/scaffolding/__init__.py +35 -0
  212. attune/scaffolding/__main__.py +14 -0
  213. attune/scaffolding/cli.py +240 -0
  214. attune/scaffolding/templates/base_wizard.py.jinja2 +121 -0
  215. attune/scaffolding/templates/coach_wizard.py.jinja2 +321 -0
  216. attune/scaffolding/templates/domain_wizard.py.jinja2 +408 -0
  217. attune/scaffolding/templates/linear_flow_wizard.py.jinja2 +203 -0
  218. attune/socratic/__init__.py +256 -0
  219. attune/socratic/ab_testing.py +958 -0
  220. attune/socratic/blueprint.py +533 -0
  221. attune/socratic/cli.py +703 -0
  222. attune/socratic/collaboration.py +1114 -0
  223. attune/socratic/domain_templates.py +924 -0
  224. attune/socratic/embeddings.py +738 -0
  225. attune/socratic/engine.py +794 -0
  226. attune/socratic/explainer.py +682 -0
  227. attune/socratic/feedback.py +772 -0
  228. attune/socratic/forms.py +629 -0
  229. attune/socratic/generator.py +732 -0
  230. attune/socratic/llm_analyzer.py +637 -0
  231. attune/socratic/mcp_server.py +702 -0
  232. attune/socratic/session.py +312 -0
  233. attune/socratic/storage.py +667 -0
  234. attune/socratic/success.py +730 -0
  235. attune/socratic/visual_editor.py +860 -0
  236. attune/socratic/web_ui.py +958 -0
  237. attune/telemetry/__init__.py +39 -0
  238. attune/telemetry/agent_coordination.py +475 -0
  239. attune/telemetry/agent_tracking.py +367 -0
  240. attune/telemetry/approval_gates.py +545 -0
  241. attune/telemetry/cli.py +1231 -0
  242. attune/telemetry/commands/__init__.py +14 -0
  243. attune/telemetry/commands/dashboard_commands.py +696 -0
  244. attune/telemetry/event_streaming.py +409 -0
  245. attune/telemetry/feedback_loop.py +567 -0
  246. attune/telemetry/usage_tracker.py +591 -0
  247. attune/templates.py +754 -0
  248. attune/test_generator/__init__.py +38 -0
  249. attune/test_generator/__main__.py +14 -0
  250. attune/test_generator/cli.py +234 -0
  251. attune/test_generator/generator.py +355 -0
  252. attune/test_generator/risk_analyzer.py +216 -0
  253. attune/test_generator/templates/unit_test.py.jinja2 +272 -0
  254. attune/tier_recommender.py +384 -0
  255. attune/tools.py +183 -0
  256. attune/trust/__init__.py +28 -0
  257. attune/trust/circuit_breaker.py +579 -0
  258. attune/trust_building.py +527 -0
  259. attune/validation/__init__.py +19 -0
  260. attune/validation/xml_validator.py +281 -0
  261. attune/vscode_bridge.py +173 -0
  262. attune/workflow_commands.py +780 -0
  263. attune/workflow_patterns/__init__.py +33 -0
  264. attune/workflow_patterns/behavior.py +249 -0
  265. attune/workflow_patterns/core.py +76 -0
  266. attune/workflow_patterns/output.py +99 -0
  267. attune/workflow_patterns/registry.py +255 -0
  268. attune/workflow_patterns/structural.py +288 -0
  269. attune/workflows/__init__.py +539 -0
  270. attune/workflows/autonomous_test_gen.py +1268 -0
  271. attune/workflows/base.py +2667 -0
  272. attune/workflows/batch_processing.py +342 -0
  273. attune/workflows/bug_predict.py +1084 -0
  274. attune/workflows/builder.py +273 -0
  275. attune/workflows/caching.py +253 -0
  276. attune/workflows/code_review.py +1048 -0
  277. attune/workflows/code_review_adapters.py +312 -0
  278. attune/workflows/code_review_pipeline.py +722 -0
  279. attune/workflows/config.py +645 -0
  280. attune/workflows/dependency_check.py +644 -0
  281. attune/workflows/document_gen/__init__.py +25 -0
  282. attune/workflows/document_gen/config.py +30 -0
  283. attune/workflows/document_gen/report_formatter.py +162 -0
  284. attune/workflows/document_gen/workflow.py +1426 -0
  285. attune/workflows/document_manager.py +216 -0
  286. attune/workflows/document_manager_README.md +134 -0
  287. attune/workflows/documentation_orchestrator.py +1205 -0
  288. attune/workflows/history.py +510 -0
  289. attune/workflows/keyboard_shortcuts/__init__.py +39 -0
  290. attune/workflows/keyboard_shortcuts/generators.py +391 -0
  291. attune/workflows/keyboard_shortcuts/parsers.py +416 -0
  292. attune/workflows/keyboard_shortcuts/prompts.py +295 -0
  293. attune/workflows/keyboard_shortcuts/schema.py +193 -0
  294. attune/workflows/keyboard_shortcuts/workflow.py +509 -0
  295. attune/workflows/llm_base.py +363 -0
  296. attune/workflows/manage_docs.py +87 -0
  297. attune/workflows/manage_docs_README.md +134 -0
  298. attune/workflows/manage_documentation.py +821 -0
  299. attune/workflows/new_sample_workflow1.py +149 -0
  300. attune/workflows/new_sample_workflow1_README.md +150 -0
  301. attune/workflows/orchestrated_health_check.py +849 -0
  302. attune/workflows/orchestrated_release_prep.py +600 -0
  303. attune/workflows/output.py +413 -0
  304. attune/workflows/perf_audit.py +863 -0
  305. attune/workflows/pr_review.py +762 -0
  306. attune/workflows/progress.py +785 -0
  307. attune/workflows/progress_server.py +322 -0
  308. attune/workflows/progressive/README 2.md +454 -0
  309. attune/workflows/progressive/README.md +454 -0
  310. attune/workflows/progressive/__init__.py +82 -0
  311. attune/workflows/progressive/cli.py +219 -0
  312. attune/workflows/progressive/core.py +488 -0
  313. attune/workflows/progressive/orchestrator.py +723 -0
  314. attune/workflows/progressive/reports.py +520 -0
  315. attune/workflows/progressive/telemetry.py +274 -0
  316. attune/workflows/progressive/test_gen.py +495 -0
  317. attune/workflows/progressive/workflow.py +589 -0
  318. attune/workflows/refactor_plan.py +694 -0
  319. attune/workflows/release_prep.py +895 -0
  320. attune/workflows/release_prep_crew.py +969 -0
  321. attune/workflows/research_synthesis.py +404 -0
  322. attune/workflows/routing.py +168 -0
  323. attune/workflows/secure_release.py +593 -0
  324. attune/workflows/security_adapters.py +297 -0
  325. attune/workflows/security_audit.py +1329 -0
  326. attune/workflows/security_audit_phase3.py +355 -0
  327. attune/workflows/seo_optimization.py +633 -0
  328. attune/workflows/step_config.py +234 -0
  329. attune/workflows/telemetry_mixin.py +269 -0
  330. attune/workflows/test5.py +125 -0
  331. attune/workflows/test5_README.md +158 -0
  332. attune/workflows/test_coverage_boost_crew.py +849 -0
  333. attune/workflows/test_gen/__init__.py +52 -0
  334. attune/workflows/test_gen/ast_analyzer.py +249 -0
  335. attune/workflows/test_gen/config.py +88 -0
  336. attune/workflows/test_gen/data_models.py +38 -0
  337. attune/workflows/test_gen/report_formatter.py +289 -0
  338. attune/workflows/test_gen/test_templates.py +381 -0
  339. attune/workflows/test_gen/workflow.py +655 -0
  340. attune/workflows/test_gen.py +54 -0
  341. attune/workflows/test_gen_behavioral.py +477 -0
  342. attune/workflows/test_gen_parallel.py +341 -0
  343. attune/workflows/test_lifecycle.py +526 -0
  344. attune/workflows/test_maintenance.py +627 -0
  345. attune/workflows/test_maintenance_cli.py +590 -0
  346. attune/workflows/test_maintenance_crew.py +840 -0
  347. attune/workflows/test_runner.py +622 -0
  348. attune/workflows/tier_tracking.py +531 -0
  349. attune/workflows/xml_enhanced_crew.py +285 -0
  350. attune_ai-2.0.0.dist-info/METADATA +1026 -0
  351. attune_ai-2.0.0.dist-info/RECORD +457 -0
  352. attune_ai-2.0.0.dist-info/WHEEL +5 -0
  353. attune_ai-2.0.0.dist-info/entry_points.txt +26 -0
  354. attune_ai-2.0.0.dist-info/licenses/LICENSE +201 -0
  355. attune_ai-2.0.0.dist-info/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +101 -0
  356. attune_ai-2.0.0.dist-info/top_level.txt +5 -0
  357. attune_healthcare/__init__.py +13 -0
  358. attune_healthcare/monitors/__init__.py +9 -0
  359. attune_healthcare/monitors/clinical_protocol_monitor.py +315 -0
  360. attune_healthcare/monitors/monitoring/__init__.py +44 -0
  361. attune_healthcare/monitors/monitoring/protocol_checker.py +300 -0
  362. attune_healthcare/monitors/monitoring/protocol_loader.py +214 -0
  363. attune_healthcare/monitors/monitoring/sensor_parsers.py +306 -0
  364. attune_healthcare/monitors/monitoring/trajectory_analyzer.py +389 -0
  365. attune_llm/README.md +553 -0
  366. attune_llm/__init__.py +28 -0
  367. attune_llm/agent_factory/__init__.py +53 -0
  368. attune_llm/agent_factory/adapters/__init__.py +85 -0
  369. attune_llm/agent_factory/adapters/autogen_adapter.py +312 -0
  370. attune_llm/agent_factory/adapters/crewai_adapter.py +483 -0
  371. attune_llm/agent_factory/adapters/haystack_adapter.py +298 -0
  372. attune_llm/agent_factory/adapters/langchain_adapter.py +362 -0
  373. attune_llm/agent_factory/adapters/langgraph_adapter.py +333 -0
  374. attune_llm/agent_factory/adapters/native.py +228 -0
  375. attune_llm/agent_factory/adapters/wizard_adapter.py +423 -0
  376. attune_llm/agent_factory/base.py +305 -0
  377. attune_llm/agent_factory/crews/__init__.py +67 -0
  378. attune_llm/agent_factory/crews/code_review.py +1113 -0
  379. attune_llm/agent_factory/crews/health_check.py +1262 -0
  380. attune_llm/agent_factory/crews/refactoring.py +1128 -0
  381. attune_llm/agent_factory/crews/security_audit.py +1018 -0
  382. attune_llm/agent_factory/decorators.py +287 -0
  383. attune_llm/agent_factory/factory.py +558 -0
  384. attune_llm/agent_factory/framework.py +193 -0
  385. attune_llm/agent_factory/memory_integration.py +328 -0
  386. attune_llm/agent_factory/resilient.py +320 -0
  387. attune_llm/agents_md/__init__.py +22 -0
  388. attune_llm/agents_md/loader.py +218 -0
  389. attune_llm/agents_md/parser.py +271 -0
  390. attune_llm/agents_md/registry.py +307 -0
  391. attune_llm/claude_memory.py +466 -0
  392. attune_llm/cli/__init__.py +8 -0
  393. attune_llm/cli/sync_claude.py +487 -0
  394. attune_llm/code_health.py +1313 -0
  395. attune_llm/commands/__init__.py +51 -0
  396. attune_llm/commands/context.py +375 -0
  397. attune_llm/commands/loader.py +301 -0
  398. attune_llm/commands/models.py +231 -0
  399. attune_llm/commands/parser.py +371 -0
  400. attune_llm/commands/registry.py +429 -0
  401. attune_llm/config/__init__.py +29 -0
  402. attune_llm/config/unified.py +291 -0
  403. attune_llm/context/__init__.py +22 -0
  404. attune_llm/context/compaction.py +455 -0
  405. attune_llm/context/manager.py +434 -0
  406. attune_llm/contextual_patterns.py +361 -0
  407. attune_llm/core.py +907 -0
  408. attune_llm/git_pattern_extractor.py +435 -0
  409. attune_llm/hooks/__init__.py +24 -0
  410. attune_llm/hooks/config.py +306 -0
  411. attune_llm/hooks/executor.py +289 -0
  412. attune_llm/hooks/registry.py +302 -0
  413. attune_llm/hooks/scripts/__init__.py +39 -0
  414. attune_llm/hooks/scripts/evaluate_session.py +201 -0
  415. attune_llm/hooks/scripts/first_time_init.py +285 -0
  416. attune_llm/hooks/scripts/pre_compact.py +207 -0
  417. attune_llm/hooks/scripts/session_end.py +183 -0
  418. attune_llm/hooks/scripts/session_start.py +163 -0
  419. attune_llm/hooks/scripts/suggest_compact.py +225 -0
  420. attune_llm/learning/__init__.py +30 -0
  421. attune_llm/learning/evaluator.py +438 -0
  422. attune_llm/learning/extractor.py +514 -0
  423. attune_llm/learning/storage.py +560 -0
  424. attune_llm/levels.py +227 -0
  425. attune_llm/pattern_confidence.py +414 -0
  426. attune_llm/pattern_resolver.py +272 -0
  427. attune_llm/pattern_summary.py +350 -0
  428. attune_llm/providers.py +967 -0
  429. attune_llm/routing/__init__.py +32 -0
  430. attune_llm/routing/model_router.py +362 -0
  431. attune_llm/security/IMPLEMENTATION_SUMMARY.md +413 -0
  432. attune_llm/security/PHASE2_COMPLETE.md +384 -0
  433. attune_llm/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
  434. attune_llm/security/QUICK_REFERENCE.md +316 -0
  435. attune_llm/security/README.md +262 -0
  436. attune_llm/security/__init__.py +62 -0
  437. attune_llm/security/audit_logger.py +929 -0
  438. attune_llm/security/audit_logger_example.py +152 -0
  439. attune_llm/security/pii_scrubber.py +640 -0
  440. attune_llm/security/secrets_detector.py +678 -0
  441. attune_llm/security/secrets_detector_example.py +304 -0
  442. attune_llm/security/secure_memdocs.py +1192 -0
  443. attune_llm/security/secure_memdocs_example.py +278 -0
  444. attune_llm/session_status.py +745 -0
  445. attune_llm/state.py +246 -0
  446. attune_llm/utils/__init__.py +5 -0
  447. attune_llm/utils/tokens.py +349 -0
  448. attune_software/SOFTWARE_PLUGIN_README.md +57 -0
  449. attune_software/__init__.py +13 -0
  450. attune_software/cli/__init__.py +120 -0
  451. attune_software/cli/inspect.py +362 -0
  452. attune_software/cli.py +574 -0
  453. attune_software/plugin.py +188 -0
  454. workflow_scaffolding/__init__.py +11 -0
  455. workflow_scaffolding/__main__.py +12 -0
  456. workflow_scaffolding/cli.py +206 -0
  457. workflow_scaffolding/generator.py +265 -0
@@ -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"