contextsynapse 1.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 (776) hide show
  1. contextsynapse/__init__.py +107 -0
  2. contextsynapse/__main__.py +9 -0
  3. contextsynapse/a2a/__init__.py +28 -0
  4. contextsynapse/a2a/client.py +120 -0
  5. contextsynapse/a2a/discovery.py +128 -0
  6. contextsynapse/a2a/handlers.py +164 -0
  7. contextsynapse/a2a/models.py +246 -0
  8. contextsynapse/a2a/streaming.py +92 -0
  9. contextsynapse/a2a/task_manager.py +275 -0
  10. contextsynapse/adapters/__init__.py +98 -0
  11. contextsynapse/adapters/_base.py +298 -0
  12. contextsynapse/adapters/autogen/__init__.py +4 -0
  13. contextsynapse/adapters/autogen/tools.py +34 -0
  14. contextsynapse/adapters/crewai/__init__.py +4 -0
  15. contextsynapse/adapters/crewai/tools.py +63 -0
  16. contextsynapse/adapters/langchain/__init__.py +19 -0
  17. contextsynapse/adapters/langchain/message_history.py +17 -0
  18. contextsynapse/adapters/langchain/retriever.py +158 -0
  19. contextsynapse/adapters/langchain/tools.py +43 -0
  20. contextsynapse/adapters/langgraph/__init__.py +21 -0
  21. contextsynapse/adapters/langgraph/checkpoint.py +223 -0
  22. contextsynapse/adapters/langgraph/context_tools.py +341 -0
  23. contextsynapse/adapters/langgraph/message_history.py +138 -0
  24. contextsynapse/adapters/langgraph/tools.py +43 -0
  25. contextsynapse/adapters/llamaindex/__init__.py +4 -0
  26. contextsynapse/adapters/llamaindex/tools.py +64 -0
  27. contextsynapse/adapters/openai/__init__.py +24 -0
  28. contextsynapse/adapters/openai/tools.py +206 -0
  29. contextsynapse/adapters/pydantic_ai/__init__.py +4 -0
  30. contextsynapse/adapters/pydantic_ai/tools.py +36 -0
  31. contextsynapse/adapters/swarm/__init__.py +4 -0
  32. contextsynapse/adapters/swarm/tools.py +44 -0
  33. contextsynapse/agents/__init__.py +6 -0
  34. contextsynapse/agents/auto_capture.py +80 -0
  35. contextsynapse/agents/dispatcher.py +299 -0
  36. contextsynapse/agents/notifications.py +112 -0
  37. contextsynapse/aiql/__init__.py +71 -0
  38. contextsynapse/aiql/cache.py +341 -0
  39. contextsynapse/aiql/compiler/__init__.py +62 -0
  40. contextsynapse/aiql/compiler/optimizer.py +126 -0
  41. contextsynapse/aiql/compiler/planner.py +366 -0
  42. contextsynapse/aiql/compiler/statistics.py +235 -0
  43. contextsynapse/aiql/compiler/validator.py +425 -0
  44. contextsynapse/aiql/engine/__init__.py +11 -0
  45. contextsynapse/aiql/engine/chunk_storage.py +2584 -0
  46. contextsynapse/aiql/engine/executor.py +7737 -0
  47. contextsynapse/aiql/engine/extraction_integration.py +442 -0
  48. contextsynapse/aiql/engine/graph_builder.py +405 -0
  49. contextsynapse/aiql/engine/node_creation_from_normalized.py +691 -0
  50. contextsynapse/aiql/engine/performance_monitor.py +289 -0
  51. contextsynapse/aiql/engine/postprocessing.py +365 -0
  52. contextsynapse/aiql/engine/ray_stage_executors.py +918 -0
  53. contextsynapse/aiql/engine/redis_cache.py +106 -0
  54. contextsynapse/aiql/engine/streaming_json.py +366 -0
  55. contextsynapse/aiql/engine/streaming_parquet.py +568 -0
  56. contextsynapse/aiql/engine/streaming_ray_chunking.py +419 -0
  57. contextsynapse/aiql/grammar/__init__.py +14 -0
  58. contextsynapse/aiql/grammar/aiql_grammar.py +1822 -0
  59. contextsynapse/aiql/grammar/base.py +71 -0
  60. contextsynapse/aiql/parser/__init__.py +46 -0
  61. contextsynapse/aiql/parser/aiql_parser.py +11330 -0
  62. contextsynapse/aiql/parser/base_parser.py +47 -0
  63. contextsynapse/api/__init__.py +5 -0
  64. contextsynapse/api/a2a_router.py +233 -0
  65. contextsynapse/api/admin_router.py +140 -0
  66. contextsynapse/api/agent_worker_router.py +978 -0
  67. contextsynapse/api/algorithms_router.py +317 -0
  68. contextsynapse/api/api.py +4144 -0
  69. contextsynapse/api/audit.py +204 -0
  70. contextsynapse/api/auth.py +441 -0
  71. contextsynapse/api/auth_router.py +537 -0
  72. contextsynapse/api/billing.py +261 -0
  73. contextsynapse/api/billing_router.py +275 -0
  74. contextsynapse/api/boundary_router.py +452 -0
  75. contextsynapse/api/cognition_router.py +116 -0
  76. contextsynapse/api/connector_router.py +237 -0
  77. contextsynapse/api/context_router.py +1619 -0
  78. contextsynapse/api/dashboard_router.py +11367 -0
  79. contextsynapse/api/errors.py +183 -0
  80. contextsynapse/api/events.py +88 -0
  81. contextsynapse/api/experiment_bootstrap.py +321 -0
  82. contextsynapse/api/experiment_orchestrator.py +1078 -0
  83. contextsynapse/api/experiment_router.py +2089 -0
  84. contextsynapse/api/feature_gate.py +89 -0
  85. contextsynapse/api/federation_router.py +108 -0
  86. contextsynapse/api/integrations.py +567 -0
  87. contextsynapse/api/integrations_router.py +461 -0
  88. contextsynapse/api/intelligence_router.py +1028 -0
  89. contextsynapse/api/metering.py +155 -0
  90. contextsynapse/api/metrics.py +112 -0
  91. contextsynapse/api/models.py +38 -0
  92. contextsynapse/api/monitoring_router.py +174 -0
  93. contextsynapse/api/pipeline_router.py +1437 -0
  94. contextsynapse/api/playground_conn.py +116 -0
  95. contextsynapse/api/projects_router.py +131 -0
  96. contextsynapse/api/queries_router.py +282 -0
  97. contextsynapse/api/quota.py +95 -0
  98. contextsynapse/api/rate_limit.py +64 -0
  99. contextsynapse/api/search_router.py +261 -0
  100. contextsynapse/api/shield_router.py +447 -0
  101. contextsynapse/api/tenant_guard.py +103 -0
  102. contextsynapse/api/tenant_quotas.py +231 -0
  103. contextsynapse/api/tenants.py +305 -0
  104. contextsynapse/api/users.py +646 -0
  105. contextsynapse/api/v1.py +167 -0
  106. contextsynapse/api/vertical_builder_router.py +387 -0
  107. contextsynapse/benchmark/__init__.py +15 -0
  108. contextsynapse/benchmark/__main__.py +64 -0
  109. contextsynapse/benchmark/agents.py +338 -0
  110. contextsynapse/benchmark/live_ab.py +338 -0
  111. contextsynapse/benchmark/reporter.py +149 -0
  112. contextsynapse/benchmark/runner.py +264 -0
  113. contextsynapse/benchmark/savings.py +266 -0
  114. contextsynapse/benchmark/token_counter.py +212 -0
  115. contextsynapse/benchmark/workloads.py +289 -0
  116. contextsynapse/buffer/__init__.py +154 -0
  117. contextsynapse/buffer/base.py +257 -0
  118. contextsynapse/buffer/duckdb_buffer.py +246 -0
  119. contextsynapse/buffer/file_buffer.py +229 -0
  120. contextsynapse/buffer/lmdb_buffer.py +286 -0
  121. contextsynapse/buffer/local_buffer.py +154 -0
  122. contextsynapse/buffer/redis_buffer.py +211 -0
  123. contextsynapse/buffer/sqlite_buffer.py +266 -0
  124. contextsynapse/cache.py +2 -0
  125. contextsynapse/cli/__init__.py +40 -0
  126. contextsynapse/cli/cli.py +475 -0
  127. contextsynapse/cli/config_cli.py +177 -0
  128. contextsynapse/cli/scaffold.py +465 -0
  129. contextsynapse/cognition/__init__.py +31 -0
  130. contextsynapse/cognition/derivation.py +55 -0
  131. contextsynapse/cognition/events.py +135 -0
  132. contextsynapse/cognition/feedback.py +52 -0
  133. contextsynapse/cognition/hallucination.py +90 -0
  134. contextsynapse/cognition/invalidation.py +70 -0
  135. contextsynapse/cognition/read_tracker.py +50 -0
  136. contextsynapse/collaboration.py +2 -0
  137. contextsynapse/comparison.py +2 -0
  138. contextsynapse/config/__init__.py +10 -0
  139. contextsynapse/config/connectors/amfi_nav.yaml +18 -0
  140. contextsynapse/config/connectors/auto_sector.yaml +20 -0
  141. contextsynapse/config/connectors/banking_sector.yaml +20 -0
  142. contextsynapse/config/connectors/energy_sector.yaml +20 -0
  143. contextsynapse/config/connectors/fmcg_sector.yaml +20 -0
  144. contextsynapse/config/connectors/nsdl_fpi.yaml +9 -0
  145. contextsynapse/config/connectors/nse_price.yaml +61 -0
  146. contextsynapse/config/connectors/pharma_sector.yaml +20 -0
  147. contextsynapse/config/connectors/rbi_dbie.yaml +12 -0
  148. contextsynapse/config/context_presets/ai_research.json +25 -0
  149. contextsynapse/config/context_presets/chatgpt_conversations.json +19 -0
  150. contextsynapse/config/context_presets/claude_conversations.json +19 -0
  151. contextsynapse/config/context_presets/legal_compliance.json +21 -0
  152. contextsynapse/config/context_presets/meeting_notes.json +21 -0
  153. contextsynapse/config/context_presets/slack_engineering.json +21 -0
  154. contextsynapse/config/context_presets/tech_climate.json +24 -0
  155. contextsynapse/config/context_presets/toi_politics.json +25 -0
  156. contextsynapse/config/database_config.py +453 -0
  157. contextsynapse/config/deployment.py +221 -0
  158. contextsynapse/config/gpt_action_schema.json +164 -0
  159. contextsynapse/config/indicators/india_macro.yaml +213 -0
  160. contextsynapse/config/logging_config.py +88 -0
  161. contextsynapse/config/mode_config.py +223 -0
  162. contextsynapse/config/schemas/api_spec.yaml +48 -0
  163. contextsynapse/config/schemas/architecture_doc.yaml +51 -0
  164. contextsynapse/config/schemas/chat_history.yaml +90 -0
  165. contextsynapse/config/schemas/chatgpt_conversation.yaml +101 -0
  166. contextsynapse/config/schemas/claude_conversation.yaml +148 -0
  167. contextsynapse/config/schemas/contract.yaml +48 -0
  168. contextsynapse/config/schemas/conversation.yaml +167 -0
  169. contextsynapse/config/schemas/database.yaml +74 -0
  170. contextsynapse/config/schemas/decision.yaml +72 -0
  171. contextsynapse/config/schemas/ecommerce.yaml +56 -0
  172. contextsynapse/config/schemas/education.yaml +42 -0
  173. contextsynapse/config/schemas/healthcare.yaml +133 -0
  174. contextsynapse/config/schemas/hr_document.yaml +48 -0
  175. contextsynapse/config/schemas/invoice.yaml +40 -0
  176. contextsynapse/config/schemas/knowledge_base.yaml +75 -0
  177. contextsynapse/config/schemas/legal.yaml +42 -0
  178. contextsynapse/config/schemas/marketing_content.yaml +48 -0
  179. contextsynapse/config/schemas/meeting_notes.yaml +51 -0
  180. contextsynapse/config/schemas/news_article.yaml +61 -0
  181. contextsynapse/config/schemas/real_estate.yaml +49 -0
  182. contextsynapse/config/schemas/requirements_doc.yaml +48 -0
  183. contextsynapse/config/schemas/research_paper.yaml +56 -0
  184. contextsynapse/config/schemas/resume.yaml +49 -0
  185. contextsynapse/config/schemas/retail.yaml +42 -0
  186. contextsynapse/config/schemas/rule_meta.yaml +173 -0
  187. contextsynapse/config/schemas/rules.yaml +72 -0
  188. contextsynapse/config/schemas/screener_meta.yaml +236 -0
  189. contextsynapse/config/schemas/sdlc.yaml +103 -0
  190. contextsynapse/config/schemas/session_graph.yaml +172 -0
  191. contextsynapse/config/schemas/support_ticket.yaml +48 -0
  192. contextsynapse/config/schemas/web.yaml +64 -0
  193. contextsynapse/config/sensors/earnings_calendar.yaml +52 -0
  194. contextsynapse/config/sensors/global_risk.yaml +53 -0
  195. contextsynapse/config/sensors/government_policy.yaml +54 -0
  196. contextsynapse/config/sensors/supply_chain.yaml +50 -0
  197. contextsynapse/config/sensors/templates/behavioral.yaml +56 -0
  198. contextsynapse/config/sensors/templates/equity_sentiment.yaml +48 -0
  199. contextsynapse/config/sensors/templates/global_macro.yaml +47 -0
  200. contextsynapse/config/sensors/templates/market_data.yaml +118 -0
  201. contextsynapse/config/sensors/templates/sector_technical.yaml +47 -0
  202. contextsynapse/config/sensors/weather.yaml +52 -0
  203. contextsynapse/config/storage_strategy.py +202 -0
  204. contextsynapse/config/templates/competitive_intel.yaml +39 -0
  205. contextsynapse/config/templates/geopolitical_risk.yaml +46 -0
  206. contextsynapse/config/templates/market_analysis.yaml +58 -0
  207. contextsynapse/connectors/__init__.py +15 -0
  208. contextsynapse/connectors/api_router.py +121 -0
  209. contextsynapse/connectors/base.py +357 -0
  210. contextsynapse/connectors/financial/audio.py +170 -0
  211. contextsynapse/connectors/financial/youtube.py +272 -0
  212. contextsynapse/connectors/github.py +164 -0
  213. contextsynapse/connectors/jira.py +120 -0
  214. contextsynapse/connectors/kafka_connector.py +124 -0
  215. contextsynapse/connectors/pipeline_connector.py +747 -0
  216. contextsynapse/connectors/registry.py +116 -0
  217. contextsynapse/connectors/scheduler.py +87 -0
  218. contextsynapse/connectors/universal.py +360 -0
  219. contextsynapse/context/__init__.py +96 -0
  220. contextsynapse/context/__main__.py +4 -0
  221. contextsynapse/context/acl.py +125 -0
  222. contextsynapse/context/agent_card.py +201 -0
  223. contextsynapse/context/agent_memory.py +814 -0
  224. contextsynapse/context/agents.py +472 -0
  225. contextsynapse/context/agents_redis.py +391 -0
  226. contextsynapse/context/assembled.py +204 -0
  227. contextsynapse/context/attribution.py +113 -0
  228. contextsynapse/context/blob.py +184 -0
  229. contextsynapse/context/boundaries.py +187 -0
  230. contextsynapse/context/boundary.py +507 -0
  231. contextsynapse/context/bundle.py +155 -0
  232. contextsynapse/context/cli.py +519 -0
  233. contextsynapse/context/collaboration.py +165 -0
  234. contextsynapse/context/compiler.py +604 -0
  235. contextsynapse/context/composite.py +536 -0
  236. contextsynapse/context/compression.py +147 -0
  237. contextsynapse/context/context_manager.py +888 -0
  238. contextsynapse/context/context_schema.py +90 -0
  239. contextsynapse/context/context_schema.yaml +105 -0
  240. contextsynapse/context/context_units.py +891 -0
  241. contextsynapse/context/conversation.py +488 -0
  242. contextsynapse/context/dedup.py +207 -0
  243. contextsynapse/context/document_processor.py +698 -0
  244. contextsynapse/context/embedding_hooks.py +338 -0
  245. contextsynapse/context/execution_schema.py +274 -0
  246. contextsynapse/context/fan_out.py +138 -0
  247. contextsynapse/context/frozen.py +80 -0
  248. contextsynapse/context/gravity.py +227 -0
  249. contextsynapse/context/hub.py +1401 -0
  250. contextsynapse/context/ingest.py +391 -0
  251. contextsynapse/context/injection.py +226 -0
  252. contextsynapse/context/interaction_graphifier.py +562 -0
  253. contextsynapse/context/layers.py +574 -0
  254. contextsynapse/context/projection.py +1195 -0
  255. contextsynapse/context/promotion.py +634 -0
  256. contextsynapse/context/proof.py +131 -0
  257. contextsynapse/context/propagation.py +577 -0
  258. contextsynapse/context/pubsub.py +135 -0
  259. contextsynapse/context/quality.py +1054 -0
  260. contextsynapse/context/quality_tracker.py +270 -0
  261. contextsynapse/context/scoping.py +370 -0
  262. contextsynapse/context/seed.py +992 -0
  263. contextsynapse/context/session.py +1142 -0
  264. contextsynapse/context/session_graph.py +593 -0
  265. contextsynapse/context/session_memory.py +309 -0
  266. contextsynapse/context/session_resolver.py +111 -0
  267. contextsynapse/context/skill_learning.py +237 -0
  268. contextsynapse/context/smart_budget.py +129 -0
  269. contextsynapse/context/source_policy.py +242 -0
  270. contextsynapse/context/store_factory.py +53 -0
  271. contextsynapse/context/sync.py +648 -0
  272. contextsynapse/context/token.py +236 -0
  273. contextsynapse/context/vector_integration.py +297 -0
  274. contextsynapse/context/webhooks.py +194 -0
  275. contextsynapse/context/working_memory.py +224 -0
  276. contextsynapse/core/__init__.py +18 -0
  277. contextsynapse/core/archiver.py +237 -0
  278. contextsynapse/core/change_stream.py +282 -0
  279. contextsynapse/core/checkpoint.py +310 -0
  280. contextsynapse/core/cloud_storage.py +387 -0
  281. contextsynapse/core/context_state.py +651 -0
  282. contextsynapse/core/cron.py +324 -0
  283. contextsynapse/core/datasource.py +643 -0
  284. contextsynapse/core/db.py +289 -0
  285. contextsynapse/core/federation.py +283 -0
  286. contextsynapse/core/graph_coordinator.py +201 -0
  287. contextsynapse/core/graph_intelligence.py +388 -0
  288. contextsynapse/core/graph_structures.py +106 -0
  289. contextsynapse/core/graph_sync.py +157 -0
  290. contextsynapse/core/hybrid_graph_storage.py +2998 -0
  291. contextsynapse/core/lifecycle.py +691 -0
  292. contextsynapse/core/multiworker.py +378 -0
  293. contextsynapse/core/pruning.py +312 -0
  294. contextsynapse/core/redis_registry.py +524 -0
  295. contextsynapse/core/registry.py +1043 -0
  296. contextsynapse/core/registry_factory.py +51 -0
  297. contextsynapse/core/registry_metadata.py +294 -0
  298. contextsynapse/core/replay.py +354 -0
  299. contextsynapse/core/replication.py +339 -0
  300. contextsynapse/core/time_travel.py +260 -0
  301. contextsynapse/core/write_behind.py +152 -0
  302. contextsynapse/core/write_context.py +39 -0
  303. contextsynapse/db/__init__.py +1 -0
  304. contextsynapse/db/postgres.py +267 -0
  305. contextsynapse/db/profile.py +236 -0
  306. contextsynapse/db/rules.py +328 -0
  307. contextsynapse/demo/__init__.py +0 -0
  308. contextsynapse/demo/report.py +147 -0
  309. contextsynapse/demo/runner.py +520 -0
  310. contextsynapse/demo/scenario.py +165 -0
  311. contextsynapse/demo/session_demo.py +280 -0
  312. contextsynapse/demo/session_report.py +143 -0
  313. contextsynapse/demo/universal_runner.py +257 -0
  314. contextsynapse/engine/__init__.py +15 -0
  315. contextsynapse/engine/core.py +555 -0
  316. contextsynapse/extraction/__init__.py +69 -0
  317. contextsynapse/extraction/cli.py +274 -0
  318. contextsynapse/extraction/config.py +107 -0
  319. contextsynapse/extraction/examples/example_usage.py +118 -0
  320. contextsynapse/extraction/examples/sample_manifest.json +53 -0
  321. contextsynapse/extraction/examples/sample_normalized.json +185 -0
  322. contextsynapse/extraction/extract_engine.py +440 -0
  323. contextsynapse/extraction/extractors/__init__.py +37 -0
  324. contextsynapse/extraction/extractors/chat_export_extractor.py +189 -0
  325. contextsynapse/extraction/extractors/csv_extractor.py +83 -0
  326. contextsynapse/extraction/extractors/docx_extractor.py +129 -0
  327. contextsynapse/extraction/extractors/excel_extractor.py +152 -0
  328. contextsynapse/extraction/extractors/html_extractor.py +146 -0
  329. contextsynapse/extraction/extractors/pdf_extractor.py +635 -0
  330. contextsynapse/extraction/extractors/pdf_extractor_layoutparser.py +408 -0
  331. contextsynapse/extraction/extractors/register_extractors.py +63 -0
  332. contextsynapse/extraction/extractors/text_extractor.py +65 -0
  333. contextsynapse/extraction/extractors/txt_extractor.py +120 -0
  334. contextsynapse/extraction/extractors/website_extractor.py +246 -0
  335. contextsynapse/extraction/fact_extractor.py +236 -0
  336. contextsynapse/extraction/file_manager.py +353 -0
  337. contextsynapse/extraction/hierarchy.py +239 -0
  338. contextsynapse/extraction/id_generator.py +450 -0
  339. contextsynapse/extraction/layout_detector.py +409 -0
  340. contextsynapse/extraction/llm_entity_extractor.py +301 -0
  341. contextsynapse/extraction/normalized_store.py +480 -0
  342. contextsynapse/extraction/normalizer.py +594 -0
  343. contextsynapse/extraction/parquet_writer.py +598 -0
  344. contextsynapse/extraction/post_processors/__init__.py +40 -0
  345. contextsynapse/extraction/post_processors/column_reorganizer.py +507 -0
  346. contextsynapse/extraction/ray_normalization.py +233 -0
  347. contextsynapse/extraction/ray_runner.py +207 -0
  348. contextsynapse/extraction/redis_schema_registry.py +222 -0
  349. contextsynapse/extraction/registry.py +204 -0
  350. contextsynapse/extraction/schema_loader.py +663 -0
  351. contextsynapse/extraction/schema_registry.py +230 -0
  352. contextsynapse/extraction/semantic_normalizer.py +245 -0
  353. contextsynapse/extraction/version_manager.py +314 -0
  354. contextsynapse/gateway/__init__.py +24 -0
  355. contextsynapse/gateway/action_emitter.py +212 -0
  356. contextsynapse/gateway/cost_tracker.py +508 -0
  357. contextsynapse/gateway/gateway.py +377 -0
  358. contextsynapse/gateway/policy.py +303 -0
  359. contextsynapse/governance/__init__.py +13 -0
  360. contextsynapse/governance/layer.py +513 -0
  361. contextsynapse/ingestion/__init__.py +15 -0
  362. contextsynapse/ingestion/amplifier.py +288 -0
  363. contextsynapse/ingestion/async_ingest.py +463 -0
  364. contextsynapse/ingestion/chunker.py +303 -0
  365. contextsynapse/ingestion/chunking.py +58 -0
  366. contextsynapse/ingestion/cleaner.py +190 -0
  367. contextsynapse/ingestion/cleanse.py +252 -0
  368. contextsynapse/ingestion/connectors/__init__.py +38 -0
  369. contextsynapse/ingestion/connectors/base.py +300 -0
  370. contextsynapse/ingestion/connectors/chatgpt_memory.py +247 -0
  371. contextsynapse/ingestion/connectors/claude_memory.py +274 -0
  372. contextsynapse/ingestion/connectors/sap_ingestor.py +208 -0
  373. contextsynapse/ingestion/content_detector.py +80 -0
  374. contextsynapse/ingestion/content_monitor.py +502 -0
  375. contextsynapse/ingestion/dedup.py +106 -0
  376. contextsynapse/ingestion/feed.py +640 -0
  377. contextsynapse/ingestion/filters.py +461 -0
  378. contextsynapse/ingestion/graph_builder.py +705 -0
  379. contextsynapse/ingestion/input_classifier.py +307 -0
  380. contextsynapse/ingestion/job_manager.py +571 -0
  381. contextsynapse/ingestion/llm_extractor.py +610 -0
  382. contextsynapse/ingestion/orchestrator.py +116 -0
  383. contextsynapse/ingestion/parsers/__init__.py +19 -0
  384. contextsynapse/ingestion/parsers/graph_parser_registry.py +103 -0
  385. contextsynapse/ingestion/parsers/graphml_parser.py +141 -0
  386. contextsynapse/ingestion/parsers/jsonld_parser.py +171 -0
  387. contextsynapse/ingestion/parsers/rdf_parser.py +150 -0
  388. contextsynapse/ingestion/pipeline_context.py +228 -0
  389. contextsynapse/ingestion/pipeline_store.py +1195 -0
  390. contextsynapse/ingestion/queue.py +133 -0
  391. contextsynapse/ingestion/reprocess.py +179 -0
  392. contextsynapse/ingestion/scenario_router.py +224 -0
  393. contextsynapse/ingestion/schema_extractor.py +759 -0
  394. contextsynapse/ingestion/schema_validator.py +274 -0
  395. contextsynapse/ingestion/sdlc_ingest.py +87 -0
  396. contextsynapse/ingestion/smart_ingest.py +2451 -0
  397. contextsynapse/ingestion/source_connector.py +171 -0
  398. contextsynapse/ingestion/stage_executor.py +2095 -0
  399. contextsynapse/ingestion/stages/__init__.py +9 -0
  400. contextsynapse/ingestion/stages/tabular_to_graph.py +289 -0
  401. contextsynapse/ingestion/strategies.py +386 -0
  402. contextsynapse/ingestion/universal/__init__.py +4 -0
  403. contextsynapse/ingestion/universal/_operator_registry.py +59 -0
  404. contextsynapse/ingestion/universal/ingest_content.py +32 -0
  405. contextsynapse/ingestion/universal/operators/__init__.py +1 -0
  406. contextsynapse/ingestion/universal/operators/base.py +19 -0
  407. contextsynapse/ingestion/universal/operators/build_edges.py +75 -0
  408. contextsynapse/ingestion/universal/operators/cluster_topics.py +397 -0
  409. contextsynapse/ingestion/universal/operators/deduplicate.py +45 -0
  410. contextsynapse/ingestion/universal/operators/detect_signals.py +90 -0
  411. contextsynapse/ingestion/universal/operators/embed.py +80 -0
  412. contextsynapse/ingestion/universal/operators/extract_entities.py +253 -0
  413. contextsynapse/ingestion/universal/operators/extract_preferences.py +117 -0
  414. contextsynapse/ingestion/universal/operators/filter_content.py +69 -0
  415. contextsynapse/ingestion/universal/operators/index_bm25.py +52 -0
  416. contextsynapse/ingestion/universal/operators/infer_domains.py +69 -0
  417. contextsynapse/ingestion/universal/operators/link_cross_reference.py +210 -0
  418. contextsynapse/ingestion/universal/operators/parse_records.py +66 -0
  419. contextsynapse/ingestion/universal/operators/resolve_entities.py +261 -0
  420. contextsynapse/ingestion/universal/operators/scanners/__init__.py +16 -0
  421. contextsynapse/ingestion/universal/operators/scanners/edges.py +173 -0
  422. contextsynapse/ingestion/universal/operators/scanners/git.py +187 -0
  423. contextsynapse/ingestion/universal/operators/scanners/github.py +135 -0
  424. contextsynapse/ingestion/universal/operators/scanners/github_api.py +509 -0
  425. contextsynapse/ingestion/universal/operators/scanners/helpers.py +67 -0
  426. contextsynapse/ingestion/universal/operators/scanners/jira.py +65 -0
  427. contextsynapse/ingestion/universal/operators/scanners/llm_enrichment.py +82 -0
  428. contextsynapse/ingestion/universal/operators/scanners/repo.py +149 -0
  429. contextsynapse/ingestion/universal/operators/sdlc_scan.py +213 -0
  430. contextsynapse/ingestion/universal/operators/spec_scanner.py +149 -0
  431. contextsynapse/ingestion/universal/operators/store_documents.py +72 -0
  432. contextsynapse/ingestion/universal/operators/synthesize_cu.py +180 -0
  433. contextsynapse/ingestion/universal/operators/validate_gate.py +65 -0
  434. contextsynapse/ingestion/universal/parsers/__init__.py +5 -0
  435. contextsynapse/ingestion/universal/parsers/record_parser.py +203 -0
  436. contextsynapse/ingestion/universal/parsers/turn_parser.py +329 -0
  437. contextsynapse/ingestion/universal/shared_signals.py +161 -0
  438. contextsynapse/ingestion/universal/stage_executor.py +219 -0
  439. contextsynapse/ingestion/web_crawler.py +265 -0
  440. contextsynapse/intelligence/__init__.py +53 -0
  441. contextsynapse/intelligence/alerts.py +198 -0
  442. contextsynapse/intelligence/collector.py +655 -0
  443. contextsynapse/intelligence/comparison.py +198 -0
  444. contextsynapse/intelligence/config.py +56 -0
  445. contextsynapse/intelligence/conflict_detector.py +182 -0
  446. contextsynapse/intelligence/context_gaps.py +192 -0
  447. contextsynapse/intelligence/context_radar.py +135 -0
  448. contextsynapse/intelligence/correlation_engine.py +416 -0
  449. contextsynapse/intelligence/correlation_template.py +120 -0
  450. contextsynapse/intelligence/dedup.py +296 -0
  451. contextsynapse/intelligence/event_bus.py +91 -0
  452. contextsynapse/intelligence/feedback_loop.py +101 -0
  453. contextsynapse/intelligence/freshness.py +307 -0
  454. contextsynapse/intelligence/fundamental_feed.py +551 -0
  455. contextsynapse/intelligence/geo_data.py +170 -0
  456. contextsynapse/intelligence/geo_reference.py +112 -0
  457. contextsynapse/intelligence/graph_fusion.py +465 -0
  458. contextsynapse/intelligence/heuristics.py +176 -0
  459. contextsynapse/intelligence/impact_tracker.py +336 -0
  460. contextsynapse/intelligence/macro_indicators.py +319 -0
  461. contextsynapse/intelligence/ocr.py +104 -0
  462. contextsynapse/intelligence/persistence.py +267 -0
  463. contextsynapse/intelligence/pipeline_health.py +130 -0
  464. contextsynapse/intelligence/price_feed.py +221 -0
  465. contextsynapse/intelligence/rate_limiter.py +130 -0
  466. contextsynapse/intelligence/reactive.py +411 -0
  467. contextsynapse/intelligence/runtime_context.py +390 -0
  468. contextsynapse/intelligence/sentiment_decay.py +426 -0
  469. contextsynapse/intelligence/session.py +277 -0
  470. contextsynapse/intelligence/signal_hierarchy.py +438 -0
  471. contextsynapse/intelligence/signals.py +185 -0
  472. contextsynapse/intelligence/source_watcher.py +132 -0
  473. contextsynapse/intelligence/table_enricher.py +256 -0
  474. contextsynapse/intelligence/tagger.py +319 -0
  475. contextsynapse/intelligence/timeseries.py +209 -0
  476. contextsynapse/intelligence/tracker.py +318 -0
  477. contextsynapse/intelligence/watchdog.py +392 -0
  478. contextsynapse/intelligence/watchdog_manager.py +263 -0
  479. contextsynapse/intelligence/webhook_receiver.py +165 -0
  480. contextsynapse/intelligence/weight_learner.py +234 -0
  481. contextsynapse/llm/__init__.py +8 -0
  482. contextsynapse/llm/client.py +348 -0
  483. contextsynapse/llm/rate_limiter.py +166 -0
  484. contextsynapse/logging_config.py +2 -0
  485. contextsynapse/marketplace/__init__.py +7 -0
  486. contextsynapse/marketplace/registry.py +173 -0
  487. contextsynapse/mcp/__init__.py +11 -0
  488. contextsynapse/mcp/__main__.py +4 -0
  489. contextsynapse/mcp/auth_middleware.py +212 -0
  490. contextsynapse/mcp/connection_pool.py +128 -0
  491. contextsynapse/mcp/server.py +986 -0
  492. contextsynapse/mcp/session_context.py +67 -0
  493. contextsynapse/memory/__init__.py +34 -0
  494. contextsynapse/memory/engine.py +475 -0
  495. contextsynapse/memory/temporal.py +327 -0
  496. contextsynapse/metadata/__init__.py +12 -0
  497. contextsynapse/metadata/metadata_db.py +272 -0
  498. contextsynapse/metadata/metadata_query_tool.py +300 -0
  499. contextsynapse/metadata/metadata_tracker.py +425 -0
  500. contextsynapse/models/__init__.py +27 -0
  501. contextsynapse/models/embedding_service.py +360 -0
  502. contextsynapse/models/model_registry.py +228 -0
  503. contextsynapse/multiworker.py +2 -0
  504. contextsynapse/pipelines/__init__.py +1 -0
  505. contextsynapse/pipelines/executor.py +851 -0
  506. contextsynapse/pipelines/filters.py +189 -0
  507. contextsynapse/pipelines/models.py +176 -0
  508. contextsynapse/pipelines/scheduler.py +268 -0
  509. contextsynapse/playground/__init__.py +1 -0
  510. contextsynapse/playground/templates.py +85 -0
  511. contextsynapse/plugins/__init__.py +43 -0
  512. contextsynapse/plugins/api.py +66 -0
  513. contextsynapse/plugins/base.py +350 -0
  514. contextsynapse/plugins/domain_interface.py +508 -0
  515. contextsynapse/plugins/domain_loader.py +241 -0
  516. contextsynapse/plugins/loader.py +148 -0
  517. contextsynapse/plugins/registry.py +265 -0
  518. contextsynapse/project/__init__.py +11 -0
  519. contextsynapse/project/code_context.py +807 -0
  520. contextsynapse/project/connectors/__init__.py +4 -0
  521. contextsynapse/project/connectors/github_connector.py +113 -0
  522. contextsynapse/project/connectors/jira_connector.py +160 -0
  523. contextsynapse/project/graph.py +1023 -0
  524. contextsynapse/project/project_context.py +545 -0
  525. contextsynapse/project/scanner_registry.py +61 -0
  526. contextsynapse/project/schema_manager.py +322 -0
  527. contextsynapse/project/schemas/knowledge.yaml +59 -0
  528. contextsynapse/project/schemas/sdlc.yaml +369 -0
  529. contextsynapse/project/sdlc_schema.py +172 -0
  530. contextsynapse/project/spec_parser.py +85 -0
  531. contextsynapse/project/task_lock.py +181 -0
  532. contextsynapse/project/task_stream.py +203 -0
  533. contextsynapse/project/team_tools.py +537 -0
  534. contextsynapse/py.typed +0 -0
  535. contextsynapse/realtime/__init__.py +6 -0
  536. contextsynapse/realtime/collaboration.py +147 -0
  537. contextsynapse/rules/__init__.py +10 -0
  538. contextsynapse/rules/engine.py +116 -0
  539. contextsynapse/rules/evaluators/__init__.py +6 -0
  540. contextsynapse/rules/evaluators/aggregate.py +205 -0
  541. contextsynapse/rules/evaluators/custom.py +170 -0
  542. contextsynapse/rules/evaluators/pre_action.py +125 -0
  543. contextsynapse/rules/evaluators/temporal.py +129 -0
  544. contextsynapse/rules/loader.py +52 -0
  545. contextsynapse/rules/models.py +124 -0
  546. contextsynapse/rules/universal.py +393 -0
  547. contextsynapse/scheduler.py +229 -0
  548. contextsynapse/schema/__init__.py +42 -0
  549. contextsynapse/schema/compiler.py +252 -0
  550. contextsynapse/schema/composer.py +158 -0
  551. contextsynapse/schema/dedup.py +73 -0
  552. contextsynapse/schema/derivation.py +172 -0
  553. contextsynapse/schema/sdl.py +326 -0
  554. contextsynapse/schema/validation_gate.py +124 -0
  555. contextsynapse/sdk/__init__.py +63 -0
  556. contextsynapse/sdk/agent.py +214 -0
  557. contextsynapse/sdk/client.py +269 -0
  558. contextsynapse/sdk/exceptions.py +23 -0
  559. contextsynapse/sdk/models.py +78 -0
  560. contextsynapse/sdk/session.py +408 -0
  561. contextsynapse/sdk/tracked.py +218 -0
  562. contextsynapse/sdk/worker.py +266 -0
  563. contextsynapse/sdk/workspace_client.py +98 -0
  564. contextsynapse/sdk/ws.py +41 -0
  565. contextsynapse/search/__init__.py +20 -0
  566. contextsynapse/search/embedding_cache.py +140 -0
  567. contextsynapse/search/enhanced_search.py +152 -0
  568. contextsynapse/search/fulltext.py +130 -0
  569. contextsynapse/search/graph_search.py +634 -0
  570. contextsynapse/search/lmdb_index.py +1218 -0
  571. contextsynapse/search/nl_to_aiql.py +199 -0
  572. contextsynapse/search/rag.py +1346 -0
  573. contextsynapse/search/rag_cache.py +167 -0
  574. contextsynapse/search/reasoning_chain.py +231 -0
  575. contextsynapse/search/redis_search.py +207 -0
  576. contextsynapse/search/retrieval_quality.py +314 -0
  577. contextsynapse/search/semantic_query.py +455 -0
  578. contextsynapse/search/text_resolver.py +159 -0
  579. contextsynapse/search/whoosh_search.py +373 -0
  580. contextsynapse/security/__init__.py +17 -0
  581. contextsynapse/security/audit.py +297 -0
  582. contextsynapse/security/audit_logger.py +97 -0
  583. contextsynapse/security/audit_trail.py +270 -0
  584. contextsynapse/security/auth_unified.py +284 -0
  585. contextsynapse/security/auto_tagger.py +230 -0
  586. contextsynapse/security/data_security.py +218 -0
  587. contextsynapse/security/encryption.py +207 -0
  588. contextsynapse/security/identity.py +447 -0
  589. contextsynapse/security/jwt_identity.py +59 -0
  590. contextsynapse/security/middleware.py +336 -0
  591. contextsynapse/security/pii.py +233 -0
  592. contextsynapse/security/rbac.py +96 -0
  593. contextsynapse/security/rls.py +205 -0
  594. contextsynapse/security/sanitize.py +233 -0
  595. contextsynapse/security/scoped_encryption.py +131 -0
  596. contextsynapse/security/tenant.py +401 -0
  597. contextsynapse/shield/__init__.py +21 -0
  598. contextsynapse/shield/anomaly.py +56 -0
  599. contextsynapse/shield/permissions.py +99 -0
  600. contextsynapse/shield/profile.py +103 -0
  601. contextsynapse/shield/shield.py +108 -0
  602. contextsynapse/shield/trust_engine.py +69 -0
  603. contextsynapse/skills/__init__.py +14 -0
  604. contextsynapse/skills/engine.py +233 -0
  605. contextsynapse/skills/loader.py +59 -0
  606. contextsynapse/skills/models.py +139 -0
  607. contextsynapse/storage/__init__.py +7 -0
  608. contextsynapse/storage/cache.py +208 -0
  609. contextsynapse/storage/columnar_store_v2.py +178 -0
  610. contextsynapse/storage/csr_graph_storage.py +587 -0
  611. contextsynapse/storage/csr_store.py +388 -0
  612. contextsynapse/storage/document_store.py +491 -0
  613. contextsynapse/storage/hnsw_index.py +294 -0
  614. contextsynapse/storage/hybrid_store_v2.py +278 -0
  615. contextsynapse/storage/lmdb_graph_storage.py +366 -0
  616. contextsynapse/storage/migration.py +279 -0
  617. contextsynapse/storage/namespace_store.py +346 -0
  618. contextsynapse/storage/pure_graph_storage.py +185 -0
  619. contextsynapse/storage/redis_graph_adapter.py +186 -0
  620. contextsynapse/storage/redis_graph_storage.py +375 -0
  621. contextsynapse/storage/router/__init__.py +29 -0
  622. contextsynapse/storage/router/duckdb_store.py +398 -0
  623. contextsynapse/storage/router/factory.py +83 -0
  624. contextsynapse/storage/router/interface.py +125 -0
  625. contextsynapse/storage/router/postgres_store.py +339 -0
  626. contextsynapse/storage/router/resolver.py +192 -0
  627. contextsynapse/storage/router/router.py +300 -0
  628. contextsynapse/storage/single_file_storage.py +391 -0
  629. contextsynapse/storage/storage_manager.py +214 -0
  630. contextsynapse/storage/storage_query_optimizer.py +314 -0
  631. contextsynapse/storage/wal.py +359 -0
  632. contextsynapse/temporal/__init__.py +0 -0
  633. contextsynapse/temporal/storage.py +106 -0
  634. contextsynapse/tests/__init__.py +11 -0
  635. contextsynapse/tests/regression/__init__.py +1 -0
  636. contextsynapse/tests/regression/add_metadata.py +395 -0
  637. contextsynapse/tests/regression/add_skip_flags.py +85 -0
  638. contextsynapse/tests/regression/cases/test_basic_create_node.yaml +20 -0
  639. contextsynapse/tests/regression/cases/test_basic_select.yaml +21 -0
  640. contextsynapse/tests/regression/cases/test_blockchain_audit_trail.yaml +37 -0
  641. contextsynapse/tests/regression/cases/test_blockchain_audit_trail_filtered.yaml +39 -0
  642. contextsynapse/tests/regression/cases/test_blockchain_blocks_range.yaml +39 -0
  643. contextsynapse/tests/regression/cases/test_blockchain_get_block.yaml +35 -0
  644. contextsynapse/tests/regression/cases/test_blockchain_latest_block.yaml +35 -0
  645. contextsynapse/tests/regression/cases/test_blockchain_length.yaml +37 -0
  646. contextsynapse/tests/regression/cases/test_blockchain_merkle_root.yaml +37 -0
  647. contextsynapse/tests/regression/cases/test_blockchain_verify.yaml +35 -0
  648. contextsynapse/tests/regression/cases/test_blockchain_verify_block.yaml +35 -0
  649. contextsynapse/tests/regression/cases/test_create_edge.yaml +28 -0
  650. contextsynapse/tests/regression/cases/test_delete_edge_basic.yaml +29 -0
  651. contextsynapse/tests/regression/cases/test_delete_node_basic.yaml +29 -0
  652. contextsynapse/tests/regression/cases/test_evaluation_mrr.yaml +40 -0
  653. contextsynapse/tests/regression/cases/test_evaluation_ndcg.yaml +40 -0
  654. contextsynapse/tests/regression/cases/test_evaluation_precision.yaml +40 -0
  655. contextsynapse/tests/regression/cases/test_evaluation_recall.yaml +40 -0
  656. contextsynapse/tests/regression/cases/test_group_by_basic.yaml +56 -0
  657. contextsynapse/tests/regression/cases/test_match_node_basic.yaml +29 -0
  658. contextsynapse/tests/regression/cases/test_match_node_with_order_limit.yaml +31 -0
  659. contextsynapse/tests/regression/cases/test_namespace_switch.yaml +18 -0
  660. contextsynapse/tests/regression/cases/test_pipeline_cascaded.yaml +61 -0
  661. contextsynapse/tests/regression/cases/test_pipeline_fixed_size_chunking.yaml +48 -0
  662. contextsynapse/tests/regression/cases/test_pipeline_gpt4_chunking.yaml +54 -0
  663. contextsynapse/tests/regression/cases/test_pipeline_hybrid_indexing.yaml +57 -0
  664. contextsynapse/tests/regression/cases/test_pipeline_large_embedding.yaml +52 -0
  665. contextsynapse/tests/regression/cases/test_pipeline_paragraph_chunking.yaml +49 -0
  666. contextsynapse/tests/regression/cases/test_pipeline_recursive_chunking.yaml +47 -0
  667. contextsynapse/tests/regression/cases/test_pipeline_run_pipeline.yaml +56 -0
  668. contextsynapse/tests/regression/cases/test_pipeline_section_based_chunking.yaml +53 -0
  669. contextsynapse/tests/regression/cases/test_pipeline_semantic_chunking.yaml +51 -0
  670. contextsynapse/tests/regression/cases/test_pipeline_with_entity_extraction.yaml +57 -0
  671. contextsynapse/tests/regression/cases/test_pipeline_with_relationships.yaml +62 -0
  672. contextsynapse/tests/regression/cases/test_rag_evaluation.yaml +38 -0
  673. contextsynapse/tests/regression/cases/test_rag_graph_bfs_with_eval.yaml +48 -0
  674. contextsynapse/tests/regression/cases/test_rag_graph_centrality.yaml +40 -0
  675. contextsynapse/tests/regression/cases/test_rag_graph_centrality_eval.yaml +35 -0
  676. contextsynapse/tests/regression/cases/test_rag_graph_community.yaml +40 -0
  677. contextsynapse/tests/regression/cases/test_rag_graph_community_eval.yaml +35 -0
  678. contextsynapse/tests/regression/cases/test_rag_graph_dfs_with_eval.yaml +35 -0
  679. contextsynapse/tests/regression/cases/test_rag_graph_multi_hop_eval.yaml +35 -0
  680. contextsynapse/tests/regression/cases/test_rag_graph_path.yaml +40 -0
  681. contextsynapse/tests/regression/cases/test_rag_graph_search_basic.yaml +38 -0
  682. contextsynapse/tests/regression/cases/test_rag_graph_search_bfs.yaml +52 -0
  683. contextsynapse/tests/regression/cases/test_rag_graph_search_dfs.yaml +41 -0
  684. contextsynapse/tests/regression/cases/test_rag_graph_search_multi_hop.yaml +43 -0
  685. contextsynapse/tests/regression/cases/test_rag_graph_search_shortest_path.yaml +41 -0
  686. contextsynapse/tests/regression/cases/test_rag_graph_search_weighted.yaml +43 -0
  687. contextsynapse/tests/regression/cases/test_rag_graph_shortest_path_eval.yaml +35 -0
  688. contextsynapse/tests/regression/cases/test_rag_graph_traversal.yaml +30 -0
  689. contextsynapse/tests/regression/cases/test_rag_graph_weighted_eval.yaml +35 -0
  690. contextsynapse/tests/regression/cases/test_rag_hybrid_search.yaml +33 -0
  691. contextsynapse/tests/regression/cases/test_rag_hybrid_with_prompt_embedding.yaml +44 -0
  692. contextsynapse/tests/regression/cases/test_rag_keyword_search.yaml +32 -0
  693. contextsynapse/tests/regression/cases/test_rag_multi_hop_graph.yaml +32 -0
  694. contextsynapse/tests/regression/cases/test_rag_path_based.yaml +34 -0
  695. contextsynapse/tests/regression/cases/test_rag_profile_based.yaml +32 -0
  696. contextsynapse/tests/regression/cases/test_rag_vector_search.yaml +32 -0
  697. contextsynapse/tests/regression/cases/test_rag_weighted_graph.yaml +34 -0
  698. contextsynapse/tests/regression/cases/test_rag_weighted_hybrid.yaml +31 -0
  699. contextsynapse/tests/regression/cases/test_semantic_boundary_chunking.yaml +57 -0
  700. contextsynapse/tests/regression/cases/test_semantic_boundary_detection.yaml +33 -0
  701. contextsynapse/tests/regression/cases/test_semantic_hash_function.yaml +30 -0
  702. contextsynapse/tests/regression/cases/test_semantic_hash_with_model.yaml +31 -0
  703. contextsynapse/tests/regression/cases/test_tcs_ingestion_fixed_size.yaml +44 -0
  704. contextsynapse/tests/regression/cases/test_tcs_ingestion_recursive.yaml +46 -0
  705. contextsynapse/tests/regression/cases/test_tcs_ingestion_section.yaml +46 -0
  706. contextsynapse/tests/regression/cases/test_tcs_ingestion_semantic.yaml +44 -0
  707. contextsynapse/tests/regression/cases/test_time_travel_as_of.yaml +35 -0
  708. contextsynapse/tests/regression/cases/test_time_travel_at_commit.yaml +33 -0
  709. contextsynapse/tests/regression/cases/test_time_travel_at_timestamp.yaml +33 -0
  710. contextsynapse/tests/regression/cases/test_time_travel_for_system_time_all.yaml +37 -0
  711. contextsynapse/tests/regression/cases/test_time_travel_versions_between.yaml +39 -0
  712. contextsynapse/tests/regression/cases/test_traverse_basic.yaml +31 -0
  713. contextsynapse/tests/regression/cases/test_traverse_max_depth.yaml +34 -0
  714. contextsynapse/tests/regression/cases/test_update_edge_basic.yaml +29 -0
  715. contextsynapse/tests/regression/evaluation_metrics.py +227 -0
  716. contextsynapse/tests/regression/regression_runner.py +1081 -0
  717. contextsynapse/tests/regression/setup_regression_data.py +212 -0
  718. contextsynapse/tests/regression/test_basic_only.py +84 -0
  719. contextsynapse/tests/regression/test_namespace_creation.py +29 -0
  720. contextsynapse/tests/regression/test_single_fix.py +61 -0
  721. contextsynapse/tests/regression/test_single_query.py +20 -0
  722. contextsynapse/tests/regression/validate_test_syntax.py +179 -0
  723. contextsynapse/tests/test_advanced_features.py +347 -0
  724. contextsynapse/tests/test_aiql_summary.py +283 -0
  725. contextsynapse/tests/test_all_aiql_queries.py +948 -0
  726. contextsynapse/tests/test_all_create_queries.py +213 -0
  727. contextsynapse/tests/test_api_vs_native.py +280 -0
  728. contextsynapse/tests/test_chunking_dedup.py +199 -0
  729. contextsynapse/tests/test_connectors.py +171 -0
  730. contextsynapse/tests/test_edges_and_match.py +202 -0
  731. contextsynapse/tests/test_extraction_pipeline_integration.py +581 -0
  732. contextsynapse/tests/test_name_property.py +224 -0
  733. contextsynapse/tests/test_node_edge_metadata.py +250 -0
  734. contextsynapse/tests/test_playground_conn.py +114 -0
  735. contextsynapse/tests/test_query_cache.py +213 -0
  736. contextsynapse/tests/test_source_policy_auth.py +348 -0
  737. contextsynapse/tests/test_temporal_and_domain.py +224 -0
  738. contextsynapse/tests/test_time_travel_e2e.py +571 -0
  739. contextsynapse/tests/test_traversal_and_aggregation.py +275 -0
  740. contextsynapse/tools/__init__.py +36 -0
  741. contextsynapse/tools/a2a_tools.py +106 -0
  742. contextsynapse/tools/cognition_tools.py +118 -0
  743. contextsynapse/tools/context.py +905 -0
  744. contextsynapse/tools/formatting.py +92 -0
  745. contextsynapse/tools/gateway_tools.py +186 -0
  746. contextsynapse/tools/graph.py +1700 -0
  747. contextsynapse/tools/intelligence.py +235 -0
  748. contextsynapse/tools/memory.py +118 -0
  749. contextsynapse/tools/registry.py +373 -0
  750. contextsynapse/tools/shield_tools.py +159 -0
  751. contextsynapse/tools/tasks.py +341 -0
  752. contextsynapse/tools/workspace_tools.py +124 -0
  753. contextsynapse/utils/__init__.py +15 -0
  754. contextsynapse/utils/config_loader.py +198 -0
  755. contextsynapse/utils/env_loader.py +156 -0
  756. contextsynapse/utils/file_utils.py +288 -0
  757. contextsynapse/utils/logging_optimizer.py +119 -0
  758. contextsynapse/vector/__init__.py +30 -0
  759. contextsynapse/vector/chroma_vector_store.py +152 -0
  760. contextsynapse/vector/custom_vector_store.py +537 -0
  761. contextsynapse/vector/faiss_vector_store.py +296 -0
  762. contextsynapse/vector/qdrant_vector_store.py +163 -0
  763. contextsynapse/vector/vector_db.py +953 -0
  764. contextsynapse/vector/vector_db_manager.py +197 -0
  765. contextsynapse/workspace/__init__.py +27 -0
  766. contextsynapse/workspace/base.py +94 -0
  767. contextsynapse/workspace/git.py +228 -0
  768. contextsynapse/workspace/github.py +78 -0
  769. contextsynapse/workspace/local.py +73 -0
  770. contextsynapse/workspace/tools.py +222 -0
  771. contextsynapse-1.0.0.dist-info/METADATA +847 -0
  772. contextsynapse-1.0.0.dist-info/RECORD +776 -0
  773. contextsynapse-1.0.0.dist-info/WHEEL +5 -0
  774. contextsynapse-1.0.0.dist-info/entry_points.txt +4 -0
  775. contextsynapse-1.0.0.dist-info/licenses/LICENSE +200 -0
  776. contextsynapse-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2089 @@
1
+ """
2
+ Experiment Router — multi-agent simulation experiments.
3
+
4
+ Provides CRUD for experiments and execution of multi-agent workflows.
5
+ Each experiment has:
6
+ - A name and description
7
+ - A target graph/context
8
+ - Multiple agents (each with name, role, framework, prompt, API key)
9
+ - Execution order (sequential or dependency-based)
10
+ - Persisted results for replay and comparison
11
+
12
+ Endpoints:
13
+ POST /experiments — Create experiment
14
+ GET /experiments — List experiments
15
+ GET /experiments/{id} — Get experiment details
16
+ PUT /experiments/{id} — Update experiment
17
+ DELETE /experiments/{id} — Delete experiment
18
+ POST /experiments/{id}/agents — Add agent to experiment
19
+ DELETE /experiments/{id}/agents/{agent_id} — Remove agent
20
+ POST /experiments/{id}/run — Execute experiment
21
+ GET /experiments/{id}/runs — List past runs
22
+ GET /experiments/{id}/runs/{run_id} — Get run details
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import time
30
+ import uuid
31
+ from datetime import datetime, timezone
32
+ from typing import Any, Callable, Dict, List, Optional
33
+
34
+ from fastapi import APIRouter, Body, Depends, HTTPException, Query
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ def create_experiment_router(
40
+ graph_registry,
41
+ agent_registry,
42
+ user_auth,
43
+ require_member,
44
+ session_manager=None,
45
+ ):
46
+ router = APIRouter(prefix="/experiments", tags=["experiments"])
47
+
48
+ # In-memory store + Redis persistence
49
+ _experiments: Dict[str, Dict] = {}
50
+ _runs: Dict[str, Dict] = {}
51
+
52
+ # Run control signals: run_id → {"signal": "running"|"pause"|"stop"}
53
+ _run_controls: Dict[str, Dict] = {}
54
+ import threading
55
+ _run_threads: Dict[str, threading.Thread] = {}
56
+
57
+ # SSE step callbacks: run_id → callable(event_type, data)
58
+ _run_step_callbacks: Dict[str, Callable] = {}
59
+
60
+ # Cache Redis client at router creation (not per-call)
61
+ _redis_client = None
62
+ try:
63
+ import os as _os
64
+ _redis_url = _os.environ.get("CONTEXTSYNAPSE_REDIS_URL") or os.environ.get("AICONTEXTDB_REDIS_URL")
65
+ if _redis_url:
66
+ import redis as _redis_mod
67
+ _redis_client = _redis_mod.from_url(_redis_url, decode_responses=True)
68
+ _redis_client.ping()
69
+ logger.info("[EXPERIMENTS] Redis connected for persistence")
70
+ except Exception as _e:
71
+ logger.warning("[EXPERIMENTS] Redis not available: %s", _e)
72
+ _redis_client = None
73
+
74
+ def _get_redis():
75
+ return _redis_client
76
+
77
+ def _save_experiment(exp: Dict):
78
+ """Persist experiment to Redis AND in-memory."""
79
+ _experiments[exp["id"]] = exp
80
+ r = _get_redis()
81
+ if r:
82
+ try:
83
+ data = json.dumps(exp, default=str)
84
+ r.hset("experiments", exp["id"], data)
85
+ except Exception as e:
86
+ logger.error("[EXPERIMENTS] Redis save failed for %s: %s", exp["id"], e)
87
+
88
+ def _load_experiments():
89
+ """Sync experiments from Redis into in-memory dict."""
90
+ r = _get_redis()
91
+ if r:
92
+ try:
93
+ raw = r.hgetall("experiments")
94
+ for eid, data in raw.items():
95
+ _experiments[eid] = json.loads(data)
96
+ if raw:
97
+ logger.debug("[EXPERIMENTS] Loaded %d experiments from Redis", len(raw))
98
+ except Exception as e:
99
+ logger.warning("[EXPERIMENTS] Failed to load from Redis: %s", e)
100
+
101
+ def _save_run(run: Dict):
102
+ _runs[run["run_id"]] = run
103
+ r = _get_redis()
104
+ if r:
105
+ try:
106
+ r.hset(f"experiment_runs:{run['experiment_id']}", run["run_id"], json.dumps(run, default=str))
107
+ except Exception:
108
+ pass
109
+
110
+ # Load on import
111
+ _load_experiments()
112
+
113
+ # ------------------------------------------------------------------
114
+ # CRUD
115
+ # ------------------------------------------------------------------
116
+
117
+ @router.post("", status_code=201)
118
+ async def create_experiment(req: dict = Body(...), user=Depends(user_auth)):
119
+ exp_id = str(uuid.uuid4())[:12]
120
+ session_id = req.get("session_id", "")
121
+ context_id = req.get("context_id", "")
122
+
123
+ exp = {
124
+ "id": exp_id,
125
+ "name": req.get("name", "Untitled Experiment"),
126
+ "description": req.get("description", ""),
127
+ "goal": req.get("goal", ""), # what agents work toward
128
+ "session_id": session_id, # shared runtime — all agents operate here
129
+ "context_id": context_id, # target context/graph
130
+ "agents": [],
131
+ "execution_mode": req.get("execution_mode", "queue"),
132
+ "created_at": datetime.now(timezone.utc).isoformat(),
133
+ "created_by": getattr(user, "user_id", ""),
134
+ "run_count": 0,
135
+ }
136
+ _save_experiment(exp)
137
+ return exp
138
+
139
+ @router.get("")
140
+ async def list_experiments(user=Depends(user_auth)):
141
+ _load_experiments() # sync from Redis
142
+ exps = sorted(_experiments.values(), key=lambda e: e.get("created_at", ""), reverse=True)
143
+ return {"experiments": exps, "total": len(exps)}
144
+
145
+ @router.get("/{experiment_id}")
146
+ async def get_experiment(experiment_id: str, user=Depends(user_auth)):
147
+ # Try in-memory first, then Redis
148
+ exp = _experiments.get(experiment_id)
149
+ if not exp:
150
+ r = _get_redis()
151
+ if r:
152
+ try:
153
+ data = r.hget("experiments", experiment_id)
154
+ if data:
155
+ exp = json.loads(data)
156
+ _experiments[experiment_id] = exp
157
+ except Exception:
158
+ pass
159
+ if not exp:
160
+ raise HTTPException(404, "Experiment not found.")
161
+ return exp
162
+
163
+ @router.put("/{experiment_id}")
164
+ async def update_experiment(experiment_id: str, req: dict = Body(...), user=Depends(require_member)):
165
+ exp = _experiments.get(experiment_id)
166
+ if not exp:
167
+ raise HTTPException(404, "Experiment not found.")
168
+ for key in ("name", "description", "goal", "execution_mode", "session_id", "context_id"):
169
+ if key in req:
170
+ exp[key] = req[key]
171
+ _save_experiment(exp)
172
+ return exp
173
+
174
+ @router.delete("/{experiment_id}")
175
+ async def delete_experiment(experiment_id: str, user=Depends(require_member)):
176
+ if experiment_id not in _experiments:
177
+ raise HTTPException(404, "Experiment not found.")
178
+
179
+ # Clean up sandbox graphs and runs for this experiment
180
+ cleaned_sandboxes = 0
181
+ cleaned_runs = 0
182
+ run_ids_to_delete = []
183
+ for rid, run in list(_runs.items()):
184
+ if run.get("experiment_id") == experiment_id:
185
+ run_ids_to_delete.append(rid)
186
+ # Delete sandbox graph if it exists
187
+ sandbox_ns = run.get("sandbox_namespace")
188
+ if sandbox_ns and graph_registry:
189
+ try:
190
+ graph_registry.delete_graph(sandbox_ns)
191
+ cleaned_sandboxes += 1
192
+ except Exception:
193
+ pass
194
+
195
+ for rid in run_ids_to_delete:
196
+ _runs.pop(rid, None)
197
+ _run_controls.pop(rid, None)
198
+ cleaned_runs += 1
199
+
200
+ # Delete experiment from memory + Redis
201
+ del _experiments[experiment_id]
202
+ r = _get_redis()
203
+ if r:
204
+ try:
205
+ r.hdel("experiments", experiment_id)
206
+ # Also clean up run records from Redis
207
+ for rid in run_ids_to_delete:
208
+ r.hdel(f"experiment_runs:{experiment_id}", rid)
209
+ except Exception:
210
+ pass
211
+
212
+ logger.info("[EXPERIMENT] Deleted experiment %s (cleaned %d runs, %d sandboxes)",
213
+ experiment_id, cleaned_runs, cleaned_sandboxes)
214
+ return {"deleted": True, "cleaned_runs": cleaned_runs, "cleaned_sandboxes": cleaned_sandboxes}
215
+
216
+ # ------------------------------------------------------------------
217
+ # Agent management
218
+ # ------------------------------------------------------------------
219
+
220
+ @router.post("/{experiment_id}/agents")
221
+ async def add_agent_to_experiment(experiment_id: str, req: dict = Body(...), user=Depends(require_member)):
222
+ """Add an agent to the experiment.
223
+
224
+ Two paths:
225
+ 1. Existing agent (from dropdown): provide agent_id → looks up in registry,
226
+ refreshes API key automatically
227
+ 2. New agent: provide name + role (no agent_id) → registers fresh agent
228
+
229
+ Body: {
230
+ agent_id: "" (select from dropdown — auto-fills everything),
231
+ name: "Researcher" (used for new agents or display override),
232
+ role: "researcher",
233
+ framework: "langgraph",
234
+ prompt: "Search the graph for...",
235
+ api_key: "" (optional — paste if you have it, otherwise auto-generated),
236
+ depends_on: [] (optional — agent names this one waits for)
237
+ }
238
+ """
239
+ exp = _experiments.get(experiment_id)
240
+ if not exp:
241
+ _load_experiments()
242
+ exp = _experiments.get(experiment_id)
243
+ if not exp:
244
+ raise HTTPException(404, "Experiment not found.")
245
+
246
+ agent_id_input = req.get("agent_id", "")
247
+ name = req.get("name", "Agent")
248
+ role = req.get("role", "agent")
249
+ framework = req.get("framework", "")
250
+ prompt = req.get("prompt", "")
251
+ provided_key = req.get("api_key", "")
252
+ session_id = exp.get("session_id", "")
253
+
254
+ # Prevent duplicate agents — if same agent_id exists, update it in place
255
+ existing_agents = exp.get("agents", [])
256
+ duplicate_idx = None
257
+ if agent_id_input:
258
+ for i, a in enumerate(existing_agents):
259
+ if a.get("agent_id") == agent_id_input:
260
+ duplicate_idx = i
261
+ break
262
+ elif name:
263
+ for i, a in enumerate(existing_agents):
264
+ if a.get("name") == name:
265
+ duplicate_idx = i
266
+ break
267
+
268
+ if agent_id_input:
269
+ # Path 1: Existing agent selected from dropdown
270
+ existing = agent_registry.get(agent_id_input)
271
+ if not existing:
272
+ raise HTTPException(404, f"Agent '{agent_id_input}' not found in registry.")
273
+
274
+ agent_id = existing.agent_id
275
+ name = name or existing.name
276
+ role = role or getattr(existing, "role", "agent")
277
+
278
+ if provided_key:
279
+ api_key = provided_key
280
+ else:
281
+ # Refresh key so we can return it
282
+ api_key = agent_registry.refresh_key(agent_id)
283
+ if not api_key:
284
+ raise HTTPException(500, "Failed to generate API key for agent.")
285
+ full_key = f"{agent_id}:{api_key}"
286
+ else:
287
+ # Path 2: Register new agent
288
+ try:
289
+ agent_identity, api_key = agent_registry.register(
290
+ name=f"sim_{name}_{experiment_id[:6]}",
291
+ role=role,
292
+ platform="app",
293
+ metadata={"experiment_id": experiment_id, "framework": framework},
294
+ )
295
+ agent_id = agent_identity.agent_id
296
+ full_key = f"{agent_id}:{api_key}"
297
+ except Exception as e:
298
+ raise HTTPException(500, f"Failed to register agent: {e}")
299
+
300
+ agent_entry = {
301
+ "agent_id": agent_id,
302
+ "name": name,
303
+ "role": role,
304
+ "framework": framework,
305
+ "prompt": prompt,
306
+ "llm_model": req.get("llm_model", ""), # per-agent LLM override
307
+ "api_key": api_key,
308
+ "full_key": full_key,
309
+ "session_id": session_id,
310
+ "depends_on": req.get("depends_on", []),
311
+ "added_at": datetime.now(timezone.utc).isoformat(),
312
+ }
313
+ if duplicate_idx is not None:
314
+ # Update existing agent in place (keeps position, updates config)
315
+ exp["agents"][duplicate_idx] = agent_entry
316
+ action = "updated"
317
+ else:
318
+ exp["agents"].append(agent_entry)
319
+ action = "added"
320
+ _save_experiment(exp)
321
+
322
+ return {
323
+ "agent_id": agent_id,
324
+ "name": name,
325
+ "api_key": api_key,
326
+ "full_key": full_key,
327
+ "message": f"Agent '{name}' {action} in experiment.",
328
+ }
329
+
330
+ @router.delete("/{experiment_id}/agents/{agent_id:path}")
331
+ async def remove_agent_from_experiment(experiment_id: str, agent_id: str, user=Depends(require_member)):
332
+ exp = _experiments.get(experiment_id)
333
+ if not exp:
334
+ raise HTTPException(404, "Experiment not found.")
335
+ before = len(exp.get("agents", []))
336
+ # Match by agent_id OR by name (frontend may send either)
337
+ exp["agents"] = [
338
+ a for a in exp.get("agents", [])
339
+ if a.get("agent_id") != agent_id and a.get("name") != agent_id
340
+ ]
341
+ after = len(exp["agents"])
342
+ if before == after:
343
+ raise HTTPException(404, f"Agent '{agent_id}' not found in experiment.")
344
+ _save_experiment(exp)
345
+ return {"removed": True, "agents_remaining": after}
346
+
347
+ # ------------------------------------------------------------------
348
+ # Run experiment
349
+ # ------------------------------------------------------------------
350
+
351
+ # ------------------------------------------------------------------
352
+ # Core execution engine (runs in background thread for async mode)
353
+ # ------------------------------------------------------------------
354
+
355
+ def _check_signal(run_id: str) -> str:
356
+ """Check control signal. Returns 'running', 'pause', or 'stop'."""
357
+ ctrl = _run_controls.get(run_id, {})
358
+ return ctrl.get("signal", "running")
359
+
360
+ def _wait_if_paused(run_id: str, timeout: int = 300):
361
+ """Block if paused. Returns False if stopped."""
362
+ import time as _t
363
+ waited = 0
364
+ while _check_signal(run_id) == "pause":
365
+ _t.sleep(1)
366
+ waited += 1
367
+ if waited >= timeout:
368
+ return False # auto-resume after timeout
369
+ return _check_signal(run_id) != "stop"
370
+
371
+ @router.post("/{experiment_id}/run")
372
+ async def run_experiment(experiment_id: str, req: dict = Body(default={}), user=Depends(require_member)):
373
+ """Execute the experiment.
374
+
375
+ Body params:
376
+ mode: "sandbox" (default) | "live"
377
+ async_mode: true (default) | false — async returns immediately with run_id
378
+ inputs: {} — runtime key-value inputs injected into agent prompts
379
+ llm_model: "" — override LLM (e.g. "openai:gpt-4o")
380
+ goal: "" — runtime goal override (replaces default agent prompts)
381
+ """
382
+ exp = _experiments.get(experiment_id)
383
+ if not exp:
384
+ # Try loading from Redis
385
+ _load_experiments()
386
+ exp = _experiments.get(experiment_id)
387
+ if not exp:
388
+ raise HTTPException(404, "Experiment not found.")
389
+
390
+ agents = exp.get("agents", [])
391
+ if not agents:
392
+ # Agents might not have been loaded from Redis — retry
393
+ _load_experiments()
394
+ exp = _experiments.get(experiment_id, exp)
395
+ agents = exp.get("agents", [])
396
+
397
+ # Auto-pull agents from the runtime context (session) if none configured
398
+ if not agents:
399
+ exp_session_id = exp.get("session_id", "")
400
+ if exp_session_id and session_manager:
401
+ try:
402
+ _sess = session_manager.get_session(exp_session_id)
403
+ if _sess:
404
+ assigned = _sess.config.get("assigned_agents", [])
405
+ if assigned and agent_registry:
406
+ for aid in assigned:
407
+ agent_obj = agent_registry.get(aid)
408
+ if agent_obj:
409
+ agents.append({
410
+ "agent_id": aid,
411
+ "name": getattr(agent_obj, "name", aid),
412
+ "role": getattr(agent_obj, "platform", "analyst") or "analyst",
413
+ "framework": "",
414
+ "prompt": "",
415
+ "llm_model": "",
416
+ })
417
+ if agents:
418
+ exp["agents"] = agents
419
+ _save_experiment(exp)
420
+ logger.info("[EXPERIMENT] Auto-pulled %d agents from session %s",
421
+ len(agents), exp_session_id[:16])
422
+ except Exception as _e:
423
+ logger.debug("[EXPERIMENT] Auto-pull agents failed: %s", _e)
424
+
425
+ if not agents:
426
+ raise HTTPException(400, "No agents configured and none found in the runtime context.")
427
+
428
+ exp_session = exp.get("session_id", "")
429
+ if not exp_session:
430
+ raise HTTPException(400, "No runtime context (session) attached.")
431
+
432
+ session_obj = None
433
+ exp_graph = None
434
+ _sm = session_manager
435
+ # Fallback: create session manager if not passed
436
+ if not _sm:
437
+ try:
438
+ from ..context.session import ContextSessionManager
439
+ _sm = ContextSessionManager(graph_registry=graph_registry)
440
+ except Exception:
441
+ pass
442
+ if _sm:
443
+ try:
444
+ session_obj = _sm.get_session(exp_session)
445
+ if session_obj:
446
+ exp_graph = session_obj.graph_namespace
447
+ except Exception as e:
448
+ logger.error("[EXPERIMENT] Session lookup failed: %s", e)
449
+ if not session_obj:
450
+ raise HTTPException(400, f"Session '{exp_session[:16]}' not found. session_manager={'available' if _sm else 'MISSING'}")
451
+ if not exp_graph:
452
+ raise HTTPException(400, f"Session '{exp_session[:16]}' has no graph namespace.")
453
+
454
+ # Runtime graph = session graph (has merged data from all attached contexts)
455
+ data_graph = exp_graph
456
+ logger.info("[EXPERIMENT] Using runtime graph: %s (session: %s)", data_graph, exp_session)
457
+
458
+ llm_model = req.get("llm_model", "")
459
+ mode = req.get("mode", "sandbox")
460
+ if mode not in ("sandbox", "live"):
461
+ mode = "sandbox"
462
+ async_mode = req.get("async_mode", True)
463
+ inputs = req.get("inputs", {}) # runtime key-value inputs
464
+ goal_override = req.get("goal", "") # runtime goal override
465
+
466
+ run_id = str(uuid.uuid4())[:12]
467
+ track_id = f"exp_{experiment_id[:8]}_{run_id}"
468
+
469
+ from .playground_conn import PlaygroundConn, create_tool_context
470
+ from ..tools import ToolRegistry
471
+ # Use the existing graph_registry (which already has session graphs loaded)
472
+ # instead of creating a fresh one that can't see them
473
+ reg = graph_registry
474
+
475
+ # Sandbox clone is deferred to the async thread to avoid blocking the API response.
476
+ # Just compute the namespace name here so we can return it immediately.
477
+ sandbox_ns = None
478
+ if mode == "sandbox":
479
+ sandbox_ns = f"__playground__/exp_{experiment_id[:8]}_{run_id[:8]}"
480
+
481
+ active_graph = sandbox_ns if mode == "sandbox" else data_graph
482
+
483
+ run = {
484
+ "run_id": run_id,
485
+ "track_id": track_id,
486
+ "type": "experiment",
487
+ "mode": mode,
488
+ "experiment_id": experiment_id,
489
+ "experiment_name": exp.get("name", ""),
490
+ "session_id": exp_session,
491
+ "source_graph": data_graph,
492
+ "sandbox_namespace": sandbox_ns,
493
+ "inputs": inputs,
494
+ "goal": goal_override,
495
+ "started_at": datetime.now(timezone.utc).isoformat(),
496
+ "agents": [],
497
+ "status": "running",
498
+ }
499
+ _runs[run_id] = run
500
+ _run_controls[run_id] = {"signal": "running"}
501
+ _save_run(run)
502
+
503
+ _first_graph = active_graph
504
+
505
+ # Helper: safely write a node to the graph via AIQL
506
+ def _graph_node(executor, label, props_dict):
507
+ """Create a node in the graph. Props are auto-escaped."""
508
+ try:
509
+ props_dict["_playground_generated"] = "true" # mark all experiment-generated nodes
510
+ parts = []
511
+ for k, v in props_dict.items():
512
+ sv = str(v).replace('"', "'").replace('\n', ' ')[:250]
513
+ parts.append(f'{k}: "{sv}"')
514
+ aiql = f'CREATE NODE {label} {{{", ".join(parts)}}}'
515
+ executor.execute(aiql)
516
+ except Exception:
517
+ pass
518
+
519
+ # ── Execution function (can run sync or in thread) ─────────
520
+ def _execute():
521
+ try:
522
+ _do_execute_run(
523
+ run, exp, agents, _first_graph, exp_session, experiment_id,
524
+ track_id, run_id, mode, reg, llm_model, inputs, goal_override,
525
+ PlaygroundConn, create_tool_context, ToolRegistry, _graph_node,
526
+ )
527
+ except Exception as _fatal:
528
+ import traceback
529
+ run["status"] = "failed"
530
+ run["error"] = str(_fatal)
531
+ run["error_detail"] = traceback.format_exc()[-500:]
532
+ run["completed_at"] = datetime.now(timezone.utc).isoformat()
533
+ _save_run(run)
534
+ _run_controls.pop(run_id, None)
535
+ _run_threads.pop(run_id, None)
536
+ logger.error("[EXPERIMENT] Fatal error in run %s: %s", run_id, _fatal)
537
+
538
+ if async_mode:
539
+ t = threading.Thread(target=_execute, daemon=True, name=f"exp-{run_id}")
540
+ _run_threads[run_id] = t
541
+ t.start()
542
+ # Return the full run object (with agents=[] initially) so frontend can render theater immediately
543
+ return {**run, "async": True}
544
+ else:
545
+ _execute()
546
+ return run
547
+
548
+ @router.get("/{experiment_id}/runs/{run_id}/stream")
549
+ async def stream_experiment_run(experiment_id: str, run_id: str):
550
+ """Stream experiment steps via SSE. Attach after POST /{id}/run to receive real-time events."""
551
+ import queue as _queue
552
+ import asyncio as _asyncio
553
+ from fastapi.responses import StreamingResponse
554
+
555
+ step_queue = _queue.Queue()
556
+
557
+ def on_step(event_type: str, data: dict):
558
+ step_queue.put({"event": event_type, "data": data})
559
+
560
+ _run_step_callbacks[run_id] = on_step
561
+
562
+ # If run is already completed before stream attached, emit complete immediately
563
+ existing_run = _runs.get(run_id)
564
+
565
+ async def generate():
566
+ try:
567
+ # If the run finished before we connected, drain any queued events then close
568
+ deadline_empty_ticks = 0
569
+ while True:
570
+ await _asyncio.sleep(0.1)
571
+ flushed = False
572
+ while not step_queue.empty():
573
+ try:
574
+ item = step_queue.get_nowait()
575
+ except _queue.Empty:
576
+ break
577
+ event_type = item.get("event", "step")
578
+ data_str = json.dumps(item.get("data", {}), default=str)
579
+ yield f"event: {event_type}\ndata: {data_str}\n\n"
580
+ flushed = True
581
+ if event_type in ("complete", "run_complete"):
582
+ return
583
+
584
+ # Check if run is finished and queue is empty
585
+ current_run = _runs.get(run_id)
586
+ if current_run and current_run.get("status") in ("completed", "failed", "aborted"):
587
+ if not flushed:
588
+ deadline_empty_ticks += 1
589
+ else:
590
+ deadline_empty_ticks = 0
591
+ # Give 5 extra ticks (0.5s) to drain any in-flight events
592
+ if deadline_empty_ticks >= 5:
593
+ status = current_run.get("status", "completed")
594
+ agents_count = len(current_run.get("agents", []))
595
+ duration = current_run.get("total_duration_ms", 0)
596
+ data_str = json.dumps({"status": status, "agents": agents_count, "duration_ms": duration}, default=str)
597
+ yield f"event: complete\ndata: {data_str}\n\n"
598
+ return
599
+ except _asyncio.CancelledError:
600
+ pass
601
+ except Exception:
602
+ pass
603
+ finally:
604
+ _run_step_callbacks.pop(run_id, None)
605
+
606
+ return StreamingResponse(
607
+ generate(),
608
+ media_type="text/event-stream",
609
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
610
+ )
611
+
612
+ def _do_execute_run(
613
+ run, exp, agents, _first_graph, exp_session, experiment_id,
614
+ track_id, run_id, mode, reg, llm_model, inputs, goal_override,
615
+ PlaygroundConn, create_tool_context, ToolRegistry, _graph_node,
616
+ ):
617
+ """Core execution engine — runs agents sequentially."""
618
+ total_start = time.time()
619
+ experiment_start = total_start # alias for SSE complete event
620
+ completed_agents = set()
621
+
622
+ # ── Progress helper (defined early — used by all phases) ──
623
+ def _progress(msg):
624
+ run["_progress"] = msg
625
+ run["_progress_at"] = datetime.now(timezone.utc).isoformat()
626
+ # Append to progress log so UI can show all steps
627
+ if "_progress_log" not in run:
628
+ run["_progress_log"] = []
629
+ run["_progress_log"].append({
630
+ "msg": msg,
631
+ "time": datetime.now(timezone.utc).isoformat(),
632
+ "elapsed_ms": int((time.time() - total_start) * 1000),
633
+ })
634
+ logger.info("[EXPERIMENT] %s — %s", track_id, msg)
635
+
636
+ # ── Sandbox setup: verify source data, reuse or clone ──────────
637
+ sandbox_ns = run.get("sandbox_namespace")
638
+ if mode == "live":
639
+ logger.warning("[EXPERIMENT] Running in LIVE mode — writes go directly to '%s'", _first_graph)
640
+ _progress("⚠️ Live mode — writes affect production graph")
641
+ if mode == "sandbox" and sandbox_ns:
642
+ source = run.get("source_graph", _first_graph)
643
+ run["_progress"] = "Checking source graph data..."
644
+
645
+ # 1. Verify source graph actually has data
646
+ _t_check = time.time()
647
+ try:
648
+ source_db = reg.get_graph(source, load_if_missing=True)
649
+ if not source_db:
650
+ source_db = reg.get_graph_for_request(source)
651
+ source_nodes = source_db.get_all_nodes() if source_db else []
652
+ source_count = len(source_nodes)
653
+ except Exception:
654
+ source_count = 0
655
+
656
+ if source_count == 0:
657
+ run["status"] = "failed"
658
+ run["error"] = f"Source graph '{source}' has no data. Ingest content first."
659
+ run["completed_at"] = datetime.now(timezone.utc).isoformat()
660
+ _save_run(run)
661
+ return
662
+
663
+ _progress(f"Source graph: {source_count} nodes ({(time.time()-_t_check)*1000:.0f}ms)")
664
+ logger.info("[EXPERIMENT] Source graph '%s' has %d nodes (%.0fms)", source, source_count, (time.time()-_t_check)*1000)
665
+
666
+ # 2. Check if sandbox already has data (reuse from previous clone)
667
+ try:
668
+ sandbox_db = reg.get_graph_for_request(sandbox_ns)
669
+ existing_nodes = len(sandbox_db.get_all_nodes()) if sandbox_db else 0
670
+ except Exception:
671
+ existing_nodes = 0
672
+
673
+ if existing_nodes > 0:
674
+ # Sandbox already populated — skip clone
675
+ logger.info("[EXPERIMENT] Sandbox '%s' already has %d nodes — skipping clone",
676
+ sandbox_ns, existing_nodes)
677
+ run["_progress"] = f"Sandbox ready ({existing_nodes} nodes, clone skipped)"
678
+ else:
679
+ # 3. Try direct reference first: if source is Redis-backed,
680
+ # just point the sandbox at the source data (zero-copy)
681
+ source_is_redis = getattr(source_db, '_using_redis_backend', False)
682
+ if source_is_redis:
683
+ # Use source graph directly as sandbox — agents write to sandbox namespace
684
+ # but read from source. This avoids the expensive clone entirely.
685
+ run["_progress"] = f"Linking sandbox to source ({source_count} nodes)..."
686
+ _first_graph = source
687
+ run["sandbox_namespace"] = None # no separate sandbox needed
688
+ sandbox_ns = None
689
+ logger.info("[EXPERIMENT] Using source graph directly (Redis, %d nodes) — no clone needed",
690
+ source_count)
691
+ else:
692
+ # 4. Fallback: full clone (non-Redis source)
693
+ run["_progress"] = f"Cloning {source_count} nodes..."
694
+ # Auto-cleanup previous sandbox graphs
695
+ for prev_run in list(_runs.values()):
696
+ if (prev_run.get("experiment_id") == experiment_id
697
+ and prev_run.get("mode") == "sandbox"
698
+ and prev_run.get("sandbox_namespace")
699
+ and prev_run.get("sandbox_namespace") != sandbox_ns
700
+ and not prev_run.get("promoted")
701
+ and not prev_run.get("discarded")):
702
+ try:
703
+ reg.delete_graph(prev_run["sandbox_namespace"])
704
+ prev_run["discarded"] = True
705
+ _save_run(prev_run)
706
+ except Exception:
707
+ pass
708
+
709
+ from ..core.replication import GraphReplicator
710
+ replicator = GraphReplicator(graph_registry=reg)
711
+ clone_result = replicator.clone_graph(source, sandbox_ns)
712
+ if "error" in clone_result:
713
+ run["status"] = "failed"
714
+ run["error"] = f"Failed to create sandbox: {clone_result['error']}"
715
+ run["completed_at"] = datetime.now(timezone.utc).isoformat()
716
+ _save_run(run)
717
+ return
718
+ logger.info("[EXPERIMENT] Cloned: %s -> %s (%d nodes, %d edges)",
719
+ source, sandbox_ns, clone_result["cloned_nodes"], clone_result["cloned_edges"])
720
+
721
+ # Update active graph
722
+ _first_graph = sandbox_ns or source
723
+
724
+ # Copy manifest from source to sandbox so agents see quality context
725
+ if sandbox_ns and source:
726
+ try:
727
+ from ..context.quality import get_manifest
728
+ import redis as _redis_mod
729
+ _r = _redis_mod.from_url(os.environ.get("CONTEXTSYNAPSE_REDIS_URL") or os.environ.get("AICONTEXTDB_REDIS_URL", "redis://localhost:6379"))
730
+ src_manifest = get_manifest(source, _r)
731
+ if src_manifest:
732
+ import json as _json
733
+ _r.set(f"manifest:{sandbox_ns}", _json.dumps(src_manifest), ex=3600)
734
+ logger.info("[EXPERIMENT] Copied manifest from %s to %s", source, sandbox_ns)
735
+ # Also copy result cache keys
736
+ for cache_key in _r.keys(f"results:{source}:*"):
737
+ ck = cache_key.decode() if isinstance(cache_key, bytes) else cache_key
738
+ new_key = ck.replace(f"results:{source}:", f"results:{sandbox_ns}:")
739
+ val = _r.get(cache_key)
740
+ if val:
741
+ _r.set(new_key, val, ex=3600)
742
+ except Exception as e:
743
+ logger.debug("[EXPERIMENT] Manifest copy failed: %s", e)
744
+
745
+ # Pre-warm search indexes (keyword + BM25) in background thread
746
+ def _warm_indexes():
747
+ try:
748
+ _warm_start = time.time()
749
+ from ..search.rag import _build_keyword_index, plain_search
750
+ _warm_db = reg.get_graph(_first_graph, load_if_missing=True)
751
+ if _warm_db:
752
+ _build_keyword_index(_warm_db, _first_graph)
753
+ # Also trigger BM25 build by doing a dummy search
754
+ plain_search(_warm_db, "warmup", k=1)
755
+ logger.info("[EXPERIMENT] Search indexes warmed in %dms", int((time.time() - _warm_start) * 1000))
756
+ except Exception as e:
757
+ logger.debug("[EXPERIMENT] Index warmup failed: %s", e)
758
+
759
+ import threading
760
+ _warm_thread = threading.Thread(target=_warm_indexes, daemon=True)
761
+ _warm_thread.start()
762
+
763
+ # Pre-init project so add_task works instantly (not 18s on first call)
764
+ try:
765
+ _init_conn = PlaygroundConn(_first_graph, reg)
766
+ from .experiment_router import ProjectGraph as _PG2
767
+ except ImportError:
768
+ pass
769
+ try:
770
+ from ..project.graph import ProjectGraph as _PG2
771
+ _pre_project = _PG2(_first_graph, connection=_init_conn)
772
+ logger.info("[EXPERIMENT] Project pre-initialized for %s", _first_graph)
773
+ except Exception as _pe:
774
+ logger.debug("[EXPERIMENT] Project pre-init failed: %s", _pe)
775
+
776
+ # Log experiment start in the graph
777
+ try:
778
+ if _first_graph:
779
+ _conn = PlaygroundConn(_first_graph, reg)
780
+ if _conn.executor:
781
+ _graph_node(_conn.executor, "ExperimentRun", {
782
+ "name": f"Experiment: {exp.get('name', '')}",
783
+ "track_id": track_id, "run_id": run_id,
784
+ "experiment_id": experiment_id, "type": "experiment",
785
+ "mode": mode, "agent_count": len(agents),
786
+ "status": "running",
787
+ "started_at": datetime.now(timezone.utc).isoformat(),
788
+ "inputs": json.dumps(inputs)[:200] if inputs else "",
789
+ "goal": goal_override[:200] if goal_override else "",
790
+ "playground": "true",
791
+ "_playground_generated": "true",
792
+ })
793
+ except Exception as e:
794
+ logger.debug("[EXPERIMENT] Failed to log ExperimentRun node: %s", e)
795
+
796
+ # ── Initialize shared intelligence systems ──────────────────
797
+ from ..context.propagation import ContextPropagator
798
+ from ..context.gravity import ContextGravity
799
+ from ..core.replay import get_replay_engine
800
+ propagator = ContextPropagator(ttl_seconds=300)
801
+ gravity = ContextGravity(decay_seconds=300)
802
+ try:
803
+ from ..core.checkpoint import checkpoint_manager as _cp_mgr
804
+ except Exception:
805
+ _cp_mgr = None
806
+ replay_engine = get_replay_engine(_cp_mgr, reg)
807
+
808
+ # Resolve LLM once for all agents (no test call — just check init)
809
+ _progress("Resolving LLM provider...")
810
+ from ..llm import get_llm_client
811
+ import os as _os
812
+ llm = None
813
+ llm_provider_name = ""
814
+ if llm_model:
815
+ provider, _, model = llm_model.partition(":")
816
+ llm = get_llm_client(provider=provider, model=model) if model else get_llm_client(provider=provider)
817
+ llm_provider_name = provider
818
+ else:
819
+ # Check which providers have API keys configured (no network call)
820
+ provider_checks = [
821
+ ("groq", "GROQ_API_KEY"), # fastest (free, ~0.5s/call)
822
+ ("openai", "OPENAI_API_KEY"), # reliable
823
+ ("anthropic", "ANTHROPIC_API_KEY"),
824
+ ("ollama", None), # local fallback
825
+ ]
826
+ for _prov, _env_key in provider_checks:
827
+ if _env_key and not _os.environ.get(_env_key):
828
+ continue # skip providers without keys
829
+ try:
830
+ llm = get_llm_client(provider=_prov)
831
+ llm_provider_name = _prov
832
+ _progress(f"LLM provider: {_prov}")
833
+ break
834
+ except Exception:
835
+ llm = None
836
+ if not llm:
837
+ run["status"] = "failed"
838
+ run["error"] = "No LLM provider available. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or start Ollama."
839
+ _save_run(run)
840
+ return
841
+
842
+ # Warm up the LLM — skip if all agents have per-agent overrides
843
+ all_have_own_llm = all(a.get("llm_model", "") not in ("", "auto", "default") for a in agents)
844
+ if not all_have_own_llm:
845
+ _t_warmup = time.time()
846
+ _progress(f"Warming up {llm_provider_name}:{llm.model}...")
847
+ try:
848
+ llm.generate("hi", max_tokens=1)
849
+ except Exception:
850
+ pass
851
+ _progress(f"LLM ready: {llm_provider_name}:{llm.model} ({(time.time()-_t_warmup)*1000:.0f}ms)")
852
+ else:
853
+ _progress("Agents have per-agent LLM — skipping default warmup")
854
+
855
+ # ── Goal decomposition helper ─────────────────────────────
856
+ def _decompose_goal(goal_text, topic_context, project_graph):
857
+ """Use LLM to break a goal into tasks with dependencies. Returns list of created tasks."""
858
+ _progress("Decomposing goal into tasks...")
859
+ prompt = f"""Break this goal into 3-5 concrete tasks. Return a JSON array.
860
+
861
+ GOAL: {goal_text}
862
+
863
+ AVAILABLE DATA IN THE GRAPH:
864
+ {topic_context[:600] if topic_context else '(no topic scan available)'}
865
+
866
+ Return ONLY a valid JSON array like this:
867
+ [
868
+ {{"title": "first task title", "description": "what to search and analyze", "depends_on": []}},
869
+ {{"title": "second task", "description": "what to do", "depends_on": [0]}},
870
+ {{"title": "write final summary", "description": "combine all findings into a summary", "depends_on": [0, 1]}}
871
+ ]
872
+
873
+ Rules:
874
+ - depends_on = array of task indexes (0-based) that must complete first
875
+ - First task(s) should have depends_on: [] (no dependencies)
876
+ - Last task should be a summary/synthesis that depends on all others
877
+ - Each task description should mention specific keywords to search for
878
+ - 3-5 tasks maximum"""
879
+
880
+ try:
881
+ parsed = llm.generate_json(prompt, system="You decompose goals into tasks. Reply with valid JSON array only.")
882
+ except Exception:
883
+ # Fallback: try raw generate and parse
884
+ try:
885
+ raw = llm.generate(prompt, system="Reply with valid JSON array only.", max_tokens=500)
886
+ import re as _re
887
+ match = _re.search(r'\[[\s\S]*\]', raw)
888
+ parsed = json.loads(match.group()) if match else []
889
+ except Exception:
890
+ parsed = []
891
+
892
+ if not parsed or not isinstance(parsed, list):
893
+ # Fallback: single task from the goal
894
+ parsed = [{"title": goal_text[:60], "description": goal_text, "depends_on": []}]
895
+
896
+ # Create tasks in the project graph
897
+ created_tasks = []
898
+ task_ids = []
899
+ for i, t in enumerate(parsed):
900
+ title = t.get("title", f"Task {i+1}")
901
+ desc = t.get("description", "")
902
+ deps = t.get("depends_on", [])
903
+ dep_ids = [task_ids[d] for d in deps if d < len(task_ids)]
904
+
905
+ task_id = project_graph.add_task(
906
+ title=title,
907
+ description=desc,
908
+ priority="medium",
909
+ depends_on=dep_ids or None,
910
+ )
911
+ task_ids.append(task_id)
912
+ created_tasks.append({"title": title, "description": desc, "task_id": task_id})
913
+ logger.info("[EXPERIMENT] Created task: %s (id: %s, deps: %s)", title[:40], task_id[:12], dep_ids)
914
+
915
+ _progress(f"Created {len(created_tasks)} tasks from goal")
916
+ return created_tasks
917
+
918
+ # Scoped tool set — graph/search/context/task tools only
919
+ # Tier 1: CORE — tools shown in LLM prompt
920
+ CORE_TOOLS = {
921
+ # Search & Discovery
922
+ "search_nodes", # find by label + query (LMDB, fast)
923
+ "search", # text search across all nodes
924
+ "rag_query", # semantic search with sources (Qdrant)
925
+ "rag_graph", # graph-aware semantic search
926
+ # Graph Operations
927
+ "add_knowledge", # write Finding/Insight to graph
928
+ "add_relationship", # connect two nodes
929
+ "query_graph", # AIQL queries for advanced users
930
+ "graph_summary", # overview of graph structure
931
+ # Task Coordination
932
+ "add_task", # create and assign tasks
933
+ "complete_task", # mark task done
934
+ "my_tasks", # check assigned tasks
935
+ }
936
+ # Tier 2: EXTENDED — callable but not in prompt
937
+ EXTENDED_TOOLS = {
938
+ "add_decision", "use_graph", "topic_scan",
939
+ "briefing", "orient", "get_context", "get_agent_context",
940
+ "claim_task", "list_tasks", "get_unblocked_tasks",
941
+ "handoff_task", "task_context",
942
+ "extract_entities", "extract_facts", "log_action", "reason",
943
+ "check_freshness", "detect_conflicts", "graph_diff", "graph_timeline",
944
+ "report_tokens", "my_usage", "remember", "recall",
945
+ }
946
+ EXPERIMENT_TOOLS = CORE_TOOLS | EXTENDED_TOOLS
947
+ _progress(f"Loading tools ({len(CORE_TOOLS)} core + {len(EXTENDED_TOOLS)} extended)...")
948
+ tool_schemas = [
949
+ t.to_json_schema() for t in ToolRegistry.all()
950
+ if t.handler and t.name in EXPERIMENT_TOOLS
951
+ ]
952
+ tools_json = json.dumps([
953
+ {"name": t["name"], "description": t.get("description", ""),
954
+ "parameters": t.get("parameters", {})}
955
+ for t in tool_schemas
956
+ ], indent=1)
957
+
958
+ agent_graph = _first_graph or "default"
959
+ execution_mode = exp.get("execution_mode", "queue")
960
+
961
+ # ── Helper: run a single agent with a given prompt + role ──────
962
+ def _run_single_agent(agent_def, prompt, role, phase="execute"):
963
+ """Execute one agent's LLM loop. Returns the agent_result dict."""
964
+ agent_name = agent_def.get("name", "Agent")
965
+ agent_id = agent_def.get("agent_id", f"sim_{agent_name}")
966
+
967
+ # Per-agent LLM override — allows mixing expensive (GPT-4o for leader)
968
+ # with cheap (GPT-4o-mini for workers) in the same experiment
969
+ agent_llm = llm # default to run-level LLM
970
+ agent_llm_name = llm_provider_name
971
+ agent_llm_model = agent_def.get("llm_model", "")
972
+ if agent_llm_model and agent_llm_model not in ("auto", "default", ""):
973
+ try:
974
+ _prov, _, _model = agent_llm_model.partition(":")
975
+ candidate = get_llm_client(provider=_prov, model=_model) if _model else get_llm_client(provider=_prov)
976
+ # Quick validation: ensure it's not Ollama when Ollama is down
977
+ if _prov == "ollama":
978
+ try:
979
+ import requests as _req
980
+ _req.get("http://localhost:11434/api/tags", timeout=2)
981
+ except Exception:
982
+ raise RuntimeError("Ollama is not running — falling back to run-level LLM")
983
+ agent_llm = candidate
984
+ agent_llm_name = agent_llm_model
985
+ _progress(f"Agent '{agent_name}' using LLM: {agent_llm_model}")
986
+ except Exception as e:
987
+ logger.warning("[EXPERIMENT] Per-agent LLM '%s' failed for %s: %s — using run-level LLM (%s)",
988
+ agent_llm_model, agent_name, e, llm_provider_name)
989
+ _progress(f"Agent '{agent_name}' LLM '{agent_llm_model}' unavailable, using {llm_provider_name}")
990
+
991
+ conn = PlaygroundConn(agent_graph, reg)
992
+ ctx = create_tool_context(conn, agent_id, agent_name)
993
+ ctx.metadata = getattr(ctx, "metadata", {})
994
+ ctx.metadata.update({
995
+ "experiment_id": experiment_id,
996
+ "track_id": track_id, "run_id": run_id, "type": "experiment",
997
+ })
998
+
999
+ # Auto-init project so task tools (add_task, claim_task, etc.) work
1000
+ if not hasattr(ctx, "project") or not ctx.project:
1001
+ try:
1002
+ from ..project.graph import ProjectGraph
1003
+ ctx.project = ProjectGraph(agent_graph, connection=conn)
1004
+ except Exception as e:
1005
+ logger.warning("[EXPERIMENT] ProjectGraph init failed for %s: %s", agent_name, e)
1006
+ # Retry with a simpler approach — just create a minimal project object
1007
+ try:
1008
+ from ..project.graph import ProjectGraph
1009
+ # Ensure the conn has get_nodes before trying
1010
+ ctx.project = ProjectGraph(agent_graph, connection=conn)
1011
+ except Exception:
1012
+ logger.error("[EXPERIMENT] ProjectGraph completely failed for %s", agent_name)
1013
+
1014
+ agent_start = time.time()
1015
+ agent_result = {
1016
+ "name": agent_name, "agent_id": agent_id, "role": role,
1017
+ "framework": agent_def.get("framework", ""),
1018
+ "llm_model": agent_llm_name or llm_provider_name,
1019
+ "prompt": prompt, "track_id": track_id,
1020
+ "type": "experiment", "steps": [], "status": "running",
1021
+ }
1022
+
1023
+ try:
1024
+ # ── Get live graph context for this agent ────────
1025
+ # Try manifest first (fast, <100ms, high quality)
1026
+ # Look up manifest from SOURCE graph, not sandbox (sandbox has no manifest)
1027
+ manifest_text = ""
1028
+ try:
1029
+ from ..context.quality import get_manifest, ManifestBuilder
1030
+ _db = conn.db if hasattr(conn, 'db') else None
1031
+ _redis = getattr(_db, '_redis', None) if _db else None
1032
+ # Try source graph first, then current namespace
1033
+ _source = run.get("source_graph", "")
1034
+ _ns = getattr(conn, 'namespace', '') or agent_graph
1035
+ manifest = get_manifest(_source, _redis) if _source else None
1036
+ if not manifest:
1037
+ manifest = get_manifest(_ns, _redis)
1038
+ if manifest:
1039
+ manifest_text = ManifestBuilder.to_text(manifest)
1040
+ except Exception as e:
1041
+ logger.debug("[EXPERIMENT] Manifest lookup failed: %s", e)
1042
+
1043
+ graph_summary_text = ""
1044
+ if not manifest_text:
1045
+ # Fallback to graph_summary if no manifest
1046
+ try:
1047
+ sr = ToolRegistry.dispatch("graph_summary", ctx, {})
1048
+ if sr and isinstance(sr, str):
1049
+ graph_summary_text = sr.strip()
1050
+ except Exception as e:
1051
+ logger.warning("[EXPERIMENT] Graph summary failed for %s: %s", agent_name, e)
1052
+
1053
+ # Topic scan — shows what data topics exist (no LLM needed)
1054
+ topic_text = ""
1055
+ if not manifest_text:
1056
+ # Manifest already includes themes — skip topic_scan
1057
+ try:
1058
+ from ..search.rag import topic_scan_text
1059
+ _db = conn.db if hasattr(conn, 'db') else None
1060
+ _ns = getattr(conn, 'namespace', '') or agent_graph
1061
+ if _db:
1062
+ topic_text = topic_scan_text(_db, _ns)
1063
+ except Exception as e:
1064
+ logger.debug("[EXPERIMENT] topic_scan failed: %s", e)
1065
+
1066
+ # ── Build team roster ────────────────────────────
1067
+ team_info = []
1068
+ for i, a in enumerate(agents):
1069
+ status = "completed" if a.get("name") in completed_agents else "waiting"
1070
+ if a.get("name") == agent_name:
1071
+ status = "YOU (running now)"
1072
+ team_info.append(f" - {a.get('name')} ({a.get('role', 'agent')}): {status}")
1073
+ team_text = "\n".join(team_info)
1074
+
1075
+ # ── Query graph for experiment knowledge (from prior agents) ──
1076
+ def _get_experiment_context():
1077
+ """Read Findings, Insights, AgentMessages from this experiment run."""
1078
+ sections = []
1079
+ try:
1080
+ db = conn.db
1081
+ if not db:
1082
+ return ""
1083
+ all_nodes = db.get_all_nodes()
1084
+ findings = []
1085
+ insights = []
1086
+ messages = []
1087
+ for n in all_nodes:
1088
+ props = n.properties if hasattr(n, "properties") else {}
1089
+ if props.get("track_id") != track_id:
1090
+ continue
1091
+ if props.get("agent") == agent_name:
1092
+ continue # skip own nodes
1093
+ label = n.label if hasattr(n, "label") else ""
1094
+ content = props.get("content", "")[:200]
1095
+ source = props.get("agent", "unknown")
1096
+ if label == "Finding" and content:
1097
+ findings.append(f" [{source}] {content}")
1098
+ elif label == "Insight" and content:
1099
+ insights.append(f" [{source}] {content}")
1100
+ # AgentMessage nodes no longer created — cross-agent updates come from propagation
1101
+ if insights:
1102
+ sections.append("INSIGHTS FROM OTHER AGENTS:\n" + "\n".join(insights[:8]))
1103
+ if findings:
1104
+ sections.append("FINDINGS FROM OTHER AGENTS:\n" + "\n".join(findings[:10]))
1105
+ if messages:
1106
+ sections.append("AGENT MESSAGES:\n" + "\n".join(messages[:8]))
1107
+ except Exception as e:
1108
+ logger.debug("[EXPERIMENT] Context collection failed: %s", e)
1109
+ return "\n\n".join(sections)
1110
+
1111
+ # ── Agent loop: reason → act → observe → repeat ──
1112
+ history = []
1113
+ _recent_actions = [] # track last few (action, params) to detect loops
1114
+ search_count = 0
1115
+ write_count = 0
1116
+ _progress(f"Agent '{agent_name}' ({role}) starting — graph={agent_graph}")
1117
+
1118
+ max_steps = 8 if role in ("worker", "agent") else 10 # workers need fewer steps
1119
+ if phase == "synthesize":
1120
+ max_steps = 6 # synthesize just reads findings and writes summary
1121
+ for step_num in range(max_steps):
1122
+ # Check control signals between steps
1123
+ sig = _check_signal(run_id)
1124
+ if sig == "stop":
1125
+ agent_result["steps"].append({"step": step_num + 1, "summary": "Stopped by user"})
1126
+ agent_result["status"] = "aborted"
1127
+ break
1128
+ if sig == "pause":
1129
+ if not _wait_if_paused(run_id):
1130
+ agent_result["steps"].append({"step": step_num + 1, "summary": "Stopped while paused"})
1131
+ agent_result["status"] = "aborted"
1132
+ break
1133
+
1134
+ # Collect cross-agent updates (what did other agents do?)
1135
+ pending_updates = propagator.get_pending(agent_id, agent_graph, max_updates=5)
1136
+ updates_text = ""
1137
+ if pending_updates:
1138
+ updates_text = "\n\nUpdates from other agents:\n" + "\n".join(f" - {u}" for u in pending_updates)
1139
+
1140
+ # Gravity-adjusted intent
1141
+ intent = gravity.get_intent(agent_id) if hasattr(gravity, "get_intent") else ""
1142
+ intent_text = f"\nYour focus areas: {intent}" if intent else ""
1143
+
1144
+ # Read experiment knowledge from graph (findings/insights by other agents)
1145
+ exp_context = _get_experiment_context() if step_num == 0 or step_num % 3 == 0 else ""
1146
+ # Only refresh every 3 steps to avoid overhead
1147
+
1148
+ # Build step history
1149
+ history_text = ""
1150
+ for h in history[-6:]: # only keep last 6 steps to avoid prompt bloat
1151
+ result_preview = str(h.get("result", ""))[:250]
1152
+ history_text += f"\nStep {h['step']}: {h['tool']}({json.dumps(h['params'])[:80]})"
1153
+ if h.get("success") is False:
1154
+ history_text += f" ERROR: {h.get('error', '')[:80]}"
1155
+ else:
1156
+ history_text += f" OK: {result_preview}"
1157
+ history_text += "\n"
1158
+
1159
+ # ── Build the step prompt ──
1160
+ # PLATFORM POLICY: agents don't need to know tool names or mechanics.
1161
+ # The agent thinks about the TASK. The platform translates intent to tools.
1162
+ # Prompts are: context + task + what you've learned so far.
1163
+ steps_left = max_steps - step_num
1164
+
1165
+ # Summarize what agent has learned so far (not raw tool calls)
1166
+ learned_text = ""
1167
+ for h in history[-4:]:
1168
+ result_str = str(h.get("result", ""))[:200]
1169
+ learned_text += f"\n- {result_str}\n"
1170
+
1171
+ # Save context for first step (so UI can show what agent sees)
1172
+ if step_num == 0:
1173
+ ctx_parts = [manifest_text or graph_summary_text, topic_text]
1174
+ # Add team info
1175
+ team = [a.get("name", "?") for a in agents]
1176
+ if len(team) > 1:
1177
+ ctx_parts.append(f"TEAM: {', '.join(team)} ({len(team)} agents)")
1178
+ else:
1179
+ ctx_parts.append("TEAM: You are the only agent.")
1180
+ agent_result["context"] = "\n".join(p for p in ctx_parts if p).strip()
1181
+
1182
+ # Use manifest if available, otherwise fall back to graph_summary + topic_text
1183
+ context_block = manifest_text if manifest_text else f"{graph_summary_text}\n{topic_text}"
1184
+
1185
+ step_prompt = f"""You are "{agent_name}" ({role}).
1186
+
1187
+ CONTEXT:
1188
+ {context_block}
1189
+ {updates_text}
1190
+ {"" if not exp_context else exp_context}
1191
+
1192
+ TASK: {prompt}
1193
+ {"" if not inputs else "INPUTS: " + "; ".join(f"{k}={v}" for k, v in inputs.items())}
1194
+
1195
+ {f'DATA COLLECTED SO FAR:{learned_text}' if learned_text else ''}
1196
+
1197
+ TOOLS:
1198
+ - search(query="keywords") — find facts and data across all nodes
1199
+ - search_nodes(label="Person") — find entities by type (also: Organization, Location, Event, Fact, Finding)
1200
+ - search_nodes(label="Fact", query="topic") — find facts about a specific topic
1201
+ - rag_query(question="...") — semantic search with source citations
1202
+ - add_knowledge(content="...", node_type="Finding") — write analysis to graph
1203
+ - add_task(title="...", description="...", assigned_to="AgentName") — assign work to others
1204
+ - complete_task(task_id="...") — mark a task as done when you finish it
1205
+ - my_tasks() — check tasks assigned to you
1206
+
1207
+ You have {steps_left} steps left.
1208
+ {">>> You have searched enough. Finish now — say done with your findings in the summary. <<<" if search_count >= 3 else ""}
1209
+ Only report facts found in search results — do NOT invent data.
1210
+
1211
+ Respond ONLY with JSON:
1212
+ {{"reasoning": "your thinking", "action": "tool_name", "params": {{}}, "done": false}}
1213
+ When done: {{"reasoning": "conclusion", "action": "", "params": {{}}, "done": true, "summary": "your key findings with specific data"}}"""
1214
+
1215
+ # Record context delivery for quality tracking
1216
+ try:
1217
+ from ..context.quality_tracker import get_quality_tracker
1218
+ _qt = get_quality_tracker()
1219
+ _qt.record_delivery(
1220
+ agent_id, node_ids=[],
1221
+ token_count=max(1, len(step_prompt) // 4),
1222
+ node_labels=list(set(
1223
+ t for t in (graph_summary_text or "").split("\n")
1224
+ if ":" in t and not t.strip().startswith("Search")
1225
+ ))[:10],
1226
+ )
1227
+ except Exception:
1228
+ pass
1229
+
1230
+ _progress(f"Agent '{agent_name}' step {step_num + 1} — calling LLM...")
1231
+
1232
+ # Emit step-start event for SSE streaming
1233
+ _step_start_ts = time.time()
1234
+ if run_id in _run_step_callbacks:
1235
+ try:
1236
+ _run_step_callbacks[run_id]("step", {
1237
+ "agent": agent_id, "agent_name": agent_name,
1238
+ "step": step_num + 1, "action": "thinking",
1239
+ "status": "running",
1240
+ "ts": datetime.now(timezone.utc).isoformat(),
1241
+ })
1242
+ except Exception:
1243
+ pass
1244
+
1245
+ try:
1246
+ import concurrent.futures as _cf
1247
+ with _cf.ThreadPoolExecutor(max_workers=1) as _tp:
1248
+ future = _tp.submit(agent_llm.generate, prompt=step_prompt, max_tokens=500)
1249
+ raw = future.result(timeout=60) # 60s timeout per step
1250
+ except _cf.TimeoutError:
1251
+ logger.error("[EXPERIMENT] Agent '%s' step %d — LLM timeout (60s)", agent_name, step_num + 1)
1252
+ agent_result["steps"].append({"step": step_num + 1, "error": "LLM timeout (60s)"})
1253
+ agent_result["status"] = "failed"
1254
+ agent_result["error"] = "LLM call timed out after 60 seconds"
1255
+ break
1256
+ except Exception as llm_err:
1257
+ err_msg = str(llm_err)[:200]
1258
+ logger.error("[EXPERIMENT] Agent '%s' LLM call failed: %s", agent_name, err_msg)
1259
+ agent_result["steps"].append({"step": step_num + 1, "error": f"LLM error: {err_msg}"})
1260
+ agent_result["status"] = "failed"
1261
+ agent_result["error"] = err_msg
1262
+ break
1263
+ if not raw or not raw.strip():
1264
+ logger.warning("[EXPERIMENT] Agent '%s' step %d — empty response", agent_name, step_num + 1)
1265
+ agent_result["steps"].append({"step": step_num + 1, "error": "Empty LLM response"})
1266
+ break
1267
+
1268
+ text = raw.strip()
1269
+ # Extract JSON from LLM response — handle markdown blocks,
1270
+ # trailing text, and other common LLM quirks
1271
+ if "```" in text:
1272
+ for p in text.split("```")[1:]:
1273
+ cleaned = p.strip()
1274
+ if cleaned.startswith("json"):
1275
+ cleaned = cleaned[4:].strip()
1276
+ if cleaned.startswith("{"):
1277
+ text = cleaned
1278
+ break
1279
+
1280
+ # Try to find a JSON object in the text (LLMs sometimes add preamble/postamble)
1281
+ decision = None
1282
+ try:
1283
+ decision = json.loads(text)
1284
+ except json.JSONDecodeError:
1285
+ # Try extracting first { ... } block
1286
+ brace_start = text.find("{")
1287
+ if brace_start >= 0:
1288
+ depth = 0
1289
+ for ci, ch in enumerate(text[brace_start:], brace_start):
1290
+ if ch == "{": depth += 1
1291
+ elif ch == "}": depth -= 1
1292
+ if depth == 0:
1293
+ try:
1294
+ decision = json.loads(text[brace_start:ci+1])
1295
+ except json.JSONDecodeError:
1296
+ pass
1297
+ break
1298
+
1299
+ if decision is None:
1300
+ logger.warning("[EXPERIMENT] Agent '%s' step %d — bad JSON: %s", agent_name, step_num + 1, text[:120])
1301
+ agent_result["steps"].append({"step": step_num + 1, "error": "Invalid JSON from LLM", "raw": text[:300]})
1302
+ # Don't break — give the agent another chance
1303
+ continue
1304
+
1305
+ reasoning = decision.get("reasoning", "")
1306
+ action = decision.get("action", "")
1307
+ params = decision.get("params", {})
1308
+ done = decision.get("done", False)
1309
+
1310
+ # ── Track search vs write balance ──
1311
+ _recent_actions.append(action)
1312
+ search_count = sum(1 for a in _recent_actions if a in ("search", "search_nodes", "rag_query"))
1313
+ write_count = sum(1 for a in _recent_actions if a in ("add_knowledge", "add_decision"))
1314
+ # Also detect 4+ of same action overall (even with gaps)
1315
+ if not done and action:
1316
+ action_count = sum(1 for a in _recent_actions if a == action)
1317
+ # Hard limits per action type
1318
+ # Leaders need to create tasks for each worker — allow more
1319
+ task_limit = 4 if role == "leader" else 2
1320
+ max_per_action = {"add_task": task_limit, "add_knowledge": 3, "add_decision": 2}
1321
+ limit = max_per_action.get(action, 5)
1322
+ if action_count >= limit:
1323
+ logger.warning("[EXPERIMENT] Agent '%s' hit limit for %s (%d/%d) — forcing done",
1324
+ agent_name, action, action_count, limit)
1325
+ done = True
1326
+ reasoning += f" [Auto-stopped: used {action} {action_count} times]"
1327
+
1328
+ _step_start = time.time()
1329
+ step_entry = {
1330
+ "step": step_num + 1, "reasoning": reasoning,
1331
+ "tool": action, "params": params,
1332
+ "llm_ms": int((_step_start - agent_start) * 1000) if step_num == 0 else None,
1333
+ }
1334
+
1335
+ logger.info("[EXPERIMENT] Agent '%s' step %d — %s → %s",
1336
+ agent_name, step_num + 1, reasoning[:80], action or "DONE")
1337
+
1338
+ # NOTE: AgentThought nodes are NOT persisted to the graph.
1339
+ # They pollute search results and confuse agents. The reasoning
1340
+ # is already captured in the step_entry for the timeline UI.
1341
+
1342
+ if done:
1343
+ # Guard: if agent says done but never wrote anything, force one more step
1344
+ WRITE_ACTIONS = {"add_knowledge", "add_task", "add_decision", "log_action", "remember"}
1345
+ has_written = any(h.get("tool") in WRITE_ACTIONS for h in history)
1346
+ if not has_written and step_num < 11 and role != "leader":
1347
+ # Agent tried to finish without producing output — inject correction
1348
+ logger.info("[EXPERIMENT] Agent '%s' said done without writing — forcing write step", agent_name)
1349
+ # Build a write prompt from search history
1350
+ search_data = "\n".join(
1351
+ str(h.get("result", ""))[:200] for h in history if h.get("tool") in ("search", "search_nodes")
1352
+ )
1353
+ if search_data:
1354
+ # Extract actual facts, not raw tool output
1355
+ clean_facts = []
1356
+ for line in search_data.split("\n"):
1357
+ line = line.strip()
1358
+ if any(line.startswith(f"[{l}]") for l in ("Fact", "Passage", "Entity", "Person", "Event", "Location", "Organization")):
1359
+ fact_text = line.split("]", 1)[-1].strip()
1360
+ if fact_text and len(fact_text) > 15:
1361
+ clean_facts.append(fact_text[:150])
1362
+ finding_content = "; ".join(clean_facts[:5]) if clean_facts else search_data[:300]
1363
+ # Override the decision — force a write
1364
+ done = False
1365
+ action = "add_knowledge"
1366
+ params = {
1367
+ "content": f"Analysis by {agent_name}: {finding_content}",
1368
+ "node_type": "Finding",
1369
+ }
1370
+ reasoning = "[System: agent tried to finish without writing. Auto-generating Finding from search results.]"
1371
+ # If no search data either, let it finish
1372
+
1373
+ summary = decision.get("summary", reasoning)
1374
+ step_entry["summary"] = summary
1375
+ agent_result["steps"].append(step_entry)
1376
+ # Persist summary as Insight node
1377
+ _graph_node(conn.executor, "Insight", {
1378
+ "name": f"Insight by {agent_name}",
1379
+ "content": summary,
1380
+ "source": f"experiment:{experiment_id}",
1381
+ "agent": agent_name, "agent_id": agent_id,
1382
+ "role": role, "track_id": track_id, "type": "experiment",
1383
+ })
1384
+ # Propagate to other agents
1385
+ propagator.propagate(
1386
+ source_agent=agent_name, event_type="agent_completed",
1387
+ content=f"{agent_name} finished: {summary[:200]}",
1388
+ namespace=agent_graph, priority="normal",
1389
+ )
1390
+ # Cross-agent notification via propagation only — no graph pollution
1391
+ break
1392
+
1393
+ if action:
1394
+ gravity.observe(agent_id, action, params)
1395
+
1396
+ if action not in EXPERIMENT_TOOLS:
1397
+ step_entry["error"] = f"Tool '{action}' not available. Use graph/search/task tools."
1398
+ step_entry["success"] = False
1399
+ history.append({"step": step_num + 1, "tool": action, "params": params, "success": False, "error": step_entry["error"]})
1400
+ else:
1401
+ try:
1402
+ result_str = ToolRegistry.dispatch(action, ctx, params)
1403
+ try:
1404
+ result_data = json.loads(result_str) if isinstance(result_str, str) else result_str
1405
+ except Exception:
1406
+ result_data = result_str
1407
+ step_entry["result"] = result_data
1408
+ step_entry["success"] = True
1409
+ history.append({"step": step_num + 1, "tool": action, "params": params, "result": result_data, "success": True})
1410
+ logger.info("[EXPERIMENT] Agent '%s' step %d — %s OK", agent_name, step_num + 1, action)
1411
+
1412
+ # ── Record for Execution Replay ──
1413
+ try:
1414
+ cp_id = replay_engine.record_step(
1415
+ run_id, step_num + 1, agent_id, agent_name,
1416
+ action, params, str(result_data)[:500],
1417
+ agent_graph, reasoning=reasoning,
1418
+ )
1419
+ if cp_id:
1420
+ step_entry["checkpoint_id"] = cp_id
1421
+ except Exception:
1422
+ pass
1423
+
1424
+ # ── Platform auto-write: only in multi-agent scenarios where
1425
+ # other agents need the results. Single agent just reports in summary. ──
1426
+ search_count = sum(1 for h in history if h.get("tool") in ("search", "search_nodes", "rag_query"))
1427
+ write_count = sum(1 for h in history if h.get("tool") in ("add_knowledge", "add_task", "add_decision"))
1428
+ is_multi_agent = len(agents) > 1
1429
+ if search_count >= 2 and write_count == 0 and step_num >= 2 and is_multi_agent and role != "leader":
1430
+ # Extract actual facts from search results (not raw tool output)
1431
+ facts = []
1432
+ for h in history:
1433
+ if h.get("tool") in ("search", "search_nodes") and h.get("result"):
1434
+ for line in str(h["result"]).split("\n"):
1435
+ line = line.strip()
1436
+ if any(line.startswith(f"[{l}]") for l in ("Fact","Passage","Entity","Statistic")):
1437
+ content = line.split("—")[-1].strip() if "—" in line else line.split("]")[-1].strip()
1438
+ if content and len(content) > 20:
1439
+ facts.append(content[:150])
1440
+ if facts:
1441
+ auto_content = f"Key findings by {agent_name}: " + "; ".join(facts[:5])
1442
+ logger.info("[PLATFORM] Auto-writing Finding for %s (searched %dx, wrote 0)", agent_name, search_count)
1443
+ try:
1444
+ auto_result = ToolRegistry.dispatch("add_knowledge", ctx, {
1445
+ "content": auto_content[:800],
1446
+ "node_type": "Finding",
1447
+ })
1448
+ history.append({"step": step_num + 1, "tool": "add_knowledge", "params": {"auto": True}, "result": auto_result, "success": True})
1449
+ agent_result["steps"].append({"step": step_num + 1, "reasoning": "[Platform: auto-write Finding from collected data]", "tool": "add_knowledge", "result": auto_result[:200], "success": True})
1450
+ except Exception:
1451
+ pass
1452
+
1453
+ # NOTE: AgentAction nodes are NOT persisted to the graph.
1454
+ # They pollute search and confuse agents. Actions are
1455
+ # already captured in step_entry for the timeline UI.
1456
+
1457
+ # ── Persist findings from read/search tools ──
1458
+ READ_TOOLS = {"search_nodes", "search", "rag_query", "rag_graph", "briefing", "graph_summary", "query_graph"}
1459
+ if action in READ_TOOLS and result_data:
1460
+ # Extract a useful snippet from the result
1461
+ snippet = ""
1462
+ if isinstance(result_data, dict):
1463
+ # rag_query returns {answer: ...}
1464
+ snippet = result_data.get("answer", "") or result_data.get("summary", "")
1465
+ if not snippet:
1466
+ # search_nodes returns {nodes: [...]}
1467
+ nodes = result_data.get("nodes", [])
1468
+ if nodes:
1469
+ snippet = f"Found {len(nodes)} nodes"
1470
+ if len(nodes) <= 5:
1471
+ names = [n.get("name", n.get("id", ""))[:30] for n in nodes[:5] if isinstance(n, dict)]
1472
+ if names:
1473
+ snippet += ": " + ", ".join(names)
1474
+ elif isinstance(result_data, str) and len(result_data) > 30:
1475
+ snippet = result_data[:250]
1476
+
1477
+ if snippet and len(snippet) > 10:
1478
+ _graph_node(conn.executor, "Finding", {
1479
+ "name": f"Finding by {agent_name} via {action}",
1480
+ "content": snippet,
1481
+ "source_tool": action,
1482
+ "query": json.dumps(params)[:150],
1483
+ "agent": agent_name, "agent_id": agent_id,
1484
+ "step": step_num + 1,
1485
+ "track_id": track_id, "type": "experiment",
1486
+ })
1487
+
1488
+ # ── Propagate write actions to other agents ──
1489
+ WRITE_TOOLS = {"add_knowledge", "add_task", "add_decision", "complete_task", "handoff_task", "log_action"}
1490
+ if action in WRITE_TOOLS:
1491
+ propagator.propagate(
1492
+ source_agent=agent_name, event_type=action,
1493
+ content=f"{agent_name} called {action}: {json.dumps(params)[:150]}",
1494
+ namespace=agent_graph, priority="normal",
1495
+ )
1496
+ # Propagation handles cross-agent notification — no AgentMessage nodes
1497
+
1498
+ except Exception as e:
1499
+ step_entry["error"] = str(e)
1500
+ step_entry["success"] = False
1501
+ history.append({"step": step_num + 1, "tool": action, "params": params, "success": False, "error": str(e)})
1502
+ logger.error("[EXPERIMENT] Agent '%s' %s error: %s", agent_name, action, e)
1503
+
1504
+ step_entry["duration_ms"] = int((time.time() - _step_start) * 1000)
1505
+ agent_result["steps"].append(step_entry)
1506
+
1507
+ # Emit step-result event for SSE streaming
1508
+ if run_id in _run_step_callbacks:
1509
+ try:
1510
+ tool_name = step_entry.get("tool") or action or "unknown"
1511
+ result_val = step_entry.get("result", "")
1512
+ result_preview = str(result_val)[:200] if result_val else ""
1513
+ step_params = step_entry.get("params") or {}
1514
+ _run_step_callbacks[run_id]("step", {
1515
+ "agent": agent_id, "agent_name": agent_name,
1516
+ "step": step_num + 1, "action": tool_name,
1517
+ "params": {k: str(v)[:50] for k, v in (step_params or {}).items()},
1518
+ "status": "done",
1519
+ "result": result_preview,
1520
+ "success": step_entry.get("success"),
1521
+ "duration_ms": int((time.time() - _step_start_ts) * 1000),
1522
+ "ts": datetime.now(timezone.utc).isoformat(),
1523
+ })
1524
+ except Exception:
1525
+ pass
1526
+
1527
+ # Live update: expose current agent's steps to polling
1528
+ run["_live_agent"] = {
1529
+ "name": agent_name,
1530
+ "role": role,
1531
+ "phase": agent_result.get("phase", ""),
1532
+ "status": "running",
1533
+ "steps": list(agent_result["steps"]),
1534
+ "step_count": len(agent_result["steps"]),
1535
+ "duration_ms": int((time.time() - agent_start) * 1000),
1536
+ }
1537
+
1538
+ if agent_result["status"] == "running":
1539
+ agent_result["status"] = "completed"
1540
+ agent_result["duration_ms"] = int((time.time() - agent_start) * 1000)
1541
+ agent_result["steps_count"] = len(agent_result["steps"])
1542
+ run.pop("_live_agent", None) # clear live data — agent is done
1543
+ logger.info("[EXPERIMENT] Agent '%s' done — %d steps, %dms",
1544
+ agent_name, len(agent_result["steps"]), agent_result["duration_ms"])
1545
+
1546
+ # Auto-complete tasks assigned to this agent
1547
+ try:
1548
+ task_results = ToolRegistry.dispatch("my_tasks", ctx, {})
1549
+ if task_results and "id:" in str(task_results):
1550
+ import re as _re
1551
+ task_ids = _re.findall(r'id:\s*([a-f0-9-]+)', str(task_results))
1552
+ for tid in task_ids[:3]:
1553
+ try:
1554
+ ToolRegistry.dispatch("complete_task", ctx, {"task_id": tid})
1555
+ logger.info("[EXPERIMENT] Auto-completed task %s for agent %s", tid, agent_name)
1556
+ except Exception:
1557
+ pass
1558
+ except Exception:
1559
+ pass
1560
+
1561
+ # Emit agent_done event for SSE streaming
1562
+ if run_id in _run_step_callbacks:
1563
+ try:
1564
+ _run_step_callbacks[run_id]("agent_done", {
1565
+ "agent": agent_id, "agent_name": agent_name,
1566
+ "steps": len(agent_result["steps"]),
1567
+ "status": agent_result.get("status", "completed"),
1568
+ "duration_ms": agent_result["duration_ms"],
1569
+ "ts": datetime.now(timezone.utc).isoformat(),
1570
+ })
1571
+ except Exception:
1572
+ pass
1573
+
1574
+ except Exception as e:
1575
+ import traceback
1576
+ agent_result["status"] = "failed"
1577
+ agent_result["error"] = str(e)
1578
+ agent_result["error_detail"] = traceback.format_exc()[-500:]
1579
+ agent_result["duration_ms"] = int((time.time() - agent_start) * 1000)
1580
+ logger.error("[EXPERIMENT] Agent '%s' FAILED: %s\n%s", agent_name, e, traceback.format_exc()[-500:])
1581
+
1582
+ return agent_result
1583
+
1584
+ # ── ORCHESTRATION (via RunOrchestrator) ────────────────────
1585
+ goal = goal_override or exp.get("goal", "") or exp.get("description", "") or "Analyze the graph and provide insights"
1586
+
1587
+ # Map old "queue" mode to "parallel" (unified pre-planned execution)
1588
+ if execution_mode == "queue":
1589
+ exp["execution_mode"] = "parallel"
1590
+
1591
+ from .experiment_orchestrator import RunOrchestrator
1592
+
1593
+ def _orchestrator_emit_sse(event_type, data):
1594
+ """Bridge orchestrator SSE events to the existing callback system."""
1595
+ if run_id in _run_step_callbacks:
1596
+ try:
1597
+ _run_step_callbacks[run_id](event_type, data)
1598
+ except Exception:
1599
+ pass
1600
+
1601
+ orchestrator = RunOrchestrator(
1602
+ run=run,
1603
+ exp=exp,
1604
+ agents=agents,
1605
+ graph=agent_graph,
1606
+ registry=reg,
1607
+ run_id=run_id,
1608
+ experiment_id=experiment_id,
1609
+ track_id=track_id,
1610
+ session_id=exp_session,
1611
+ llm=llm,
1612
+ llm_provider_name=llm_provider_name,
1613
+ goal=goal,
1614
+ mode=mode,
1615
+ inputs=inputs,
1616
+ PlaygroundConn=PlaygroundConn,
1617
+ create_tool_context=create_tool_context,
1618
+ ToolRegistry=ToolRegistry,
1619
+ graph_node_fn=_graph_node,
1620
+ save_run_fn=_save_run,
1621
+ check_signal_fn=_check_signal,
1622
+ emit_sse_fn=_orchestrator_emit_sse,
1623
+ get_llm_client_fn=get_llm_client,
1624
+ experiment_tools=EXPERIMENT_TOOLS,
1625
+ )
1626
+
1627
+ orchestrator.execute()
1628
+
1629
+ # Auto-cleanup sandbox graph after completion (1 hour TTL for review)
1630
+ if sandbox_ns:
1631
+ def _cleanup_sandbox():
1632
+ try:
1633
+ import time as _time
1634
+ _time.sleep(3600) # 1 hour TTL
1635
+ reg.delete_graph(sandbox_ns)
1636
+ logger.info("[EXPERIMENT] Auto-cleaned sandbox: %s", sandbox_ns)
1637
+ except Exception:
1638
+ pass
1639
+ threading.Thread(target=_cleanup_sandbox, daemon=True, name=f"cleanup-{sandbox_ns}").start()
1640
+
1641
+ # Finalize
1642
+ _save_experiment(exp)
1643
+ _run_controls.pop(run_id, None)
1644
+ _run_threads.pop(run_id, None)
1645
+
1646
+ completed = sum(1 for a in run["agents"] if a.get("status") == "completed")
1647
+ skipped = sum(1 for a in run["agents"] if a.get("status") == "skipped")
1648
+ failed = sum(1 for a in run["agents"] if a.get("status") == "failed")
1649
+ logger.info("[EXPERIMENT] Run '%s' finished — overall=%d/100, graph=%s, agents=%d (ok=%d, skip=%d, fail=%d), %dms",
1650
+ track_id, run.get("scores", {}).get("overall", 0),
1651
+ agent_graph, len(agents), completed, skipped, failed, run.get("total_duration_ms", 0))
1652
+
1653
+ return run
1654
+
1655
+ # ------------------------------------------------------------------
1656
+ # Run history
1657
+ # ------------------------------------------------------------------
1658
+
1659
+ @router.get("/runs/all")
1660
+ async def list_all_runs(
1661
+ session_id: str = Query(None),
1662
+ mode: str = Query(None),
1663
+ limit: int = Query(50, ge=1, le=200),
1664
+ user=Depends(user_auth),
1665
+ ):
1666
+ """List all runs across all experiments. Supports filtering by session and mode."""
1667
+ all_runs = []
1668
+
1669
+ # Collect from in-memory
1670
+ for run in _runs.values():
1671
+ all_runs.append(run)
1672
+
1673
+ # Collect from Redis
1674
+ r = _get_redis()
1675
+ if r:
1676
+ seen_ids = {run["run_id"] for run in all_runs}
1677
+ try:
1678
+ for exp_id in list(_experiments.keys()):
1679
+ raw = r.hgetall(f"experiment_runs:{exp_id}")
1680
+ for rid, data in raw.items():
1681
+ if rid not in seen_ids:
1682
+ try:
1683
+ all_runs.append(json.loads(data))
1684
+ seen_ids.add(rid)
1685
+ except Exception:
1686
+ pass
1687
+ except Exception:
1688
+ pass
1689
+
1690
+ # Filter
1691
+ if session_id:
1692
+ all_runs = [r for r in all_runs if r.get("session_id") == session_id]
1693
+ if mode:
1694
+ all_runs = [r for r in all_runs if r.get("mode") == mode]
1695
+
1696
+ all_runs.sort(key=lambda x: x.get("started_at", ""), reverse=True)
1697
+ return {"runs": all_runs[:limit], "total": len(all_runs)}
1698
+
1699
+ @router.get("/{experiment_id}/runs")
1700
+ async def list_runs(experiment_id: str, user=Depends(user_auth)):
1701
+ """List past runs for an experiment."""
1702
+ # Load from Redis
1703
+ r = _get_redis()
1704
+ runs = []
1705
+ if r:
1706
+ try:
1707
+ raw = r.hgetall(f"experiment_runs:{experiment_id}")
1708
+ for rid, data in raw.items():
1709
+ runs.append(json.loads(data))
1710
+ except Exception:
1711
+ pass
1712
+ # Also check in-memory
1713
+ for run in _runs.values():
1714
+ if run.get("experiment_id") == experiment_id and run["run_id"] not in {r["run_id"] for r in runs}:
1715
+ runs.append(run)
1716
+ runs.sort(key=lambda x: x.get("started_at", ""), reverse=True)
1717
+ return {"runs": runs, "total": len(runs)}
1718
+
1719
+ @router.get("/{experiment_id}/runs/{run_id}")
1720
+ async def get_run(experiment_id: str, run_id: str, user=Depends(user_auth)):
1721
+ run = _runs.get(run_id)
1722
+ if not run:
1723
+ r = _get_redis()
1724
+ if r:
1725
+ try:
1726
+ data = r.hget(f"experiment_runs:{experiment_id}", run_id)
1727
+ if data:
1728
+ run = json.loads(data)
1729
+ except Exception:
1730
+ pass
1731
+ if not run:
1732
+ raise HTTPException(404, "Run not found.")
1733
+ return run
1734
+
1735
+ # ------------------------------------------------------------------
1736
+ # Run control (stop, pause, resume)
1737
+ # ------------------------------------------------------------------
1738
+
1739
+ @router.post("/{experiment_id}/runs/{run_id}/stop")
1740
+ async def stop_run(experiment_id: str, run_id: str, user=Depends(require_member)):
1741
+ """Stop a running experiment."""
1742
+ if run_id not in _run_controls:
1743
+ # Check if run exists but already completed
1744
+ run_data = _runs.get(run_id)
1745
+ if run_data and run_data.get("status") != "running":
1746
+ return {"run_id": run_id, "signal": "already_finished", "status": run_data.get("status")}
1747
+ raise HTTPException(404, "Run not active.")
1748
+ _run_controls[run_id]["signal"] = "stop"
1749
+ logger.info("[EXPERIMENT] Run %s — STOP signal sent", run_id)
1750
+ return {"run_id": run_id, "signal": "stop"}
1751
+
1752
+ @router.post("/{experiment_id}/runs/{run_id}/pause")
1753
+ async def pause_run(experiment_id: str, run_id: str, user=Depends(require_member)):
1754
+ """Pause a running experiment (agents will wait between steps)."""
1755
+ if run_id not in _run_controls:
1756
+ raise HTTPException(404, "Run not active.")
1757
+ _run_controls[run_id]["signal"] = "pause"
1758
+ logger.info("[EXPERIMENT] Run %s — PAUSE signal sent", run_id)
1759
+ return {"run_id": run_id, "signal": "pause"}
1760
+
1761
+ @router.post("/{experiment_id}/runs/{run_id}/resume")
1762
+ async def resume_run(experiment_id: str, run_id: str, user=Depends(require_member)):
1763
+ """Resume a paused experiment."""
1764
+ if run_id not in _run_controls:
1765
+ raise HTTPException(404, "Run not active.")
1766
+ _run_controls[run_id]["signal"] = "running"
1767
+ logger.info("[EXPERIMENT] Run %s — RESUME signal sent", run_id)
1768
+ return {"run_id": run_id, "signal": "running"}
1769
+
1770
+ @router.get("/{experiment_id}/runs/{run_id}/status")
1771
+ async def get_run_status(experiment_id: str, run_id: str, user=Depends(user_auth)):
1772
+ """Get live status of a running experiment (for polling)."""
1773
+ run_data = _runs.get(run_id)
1774
+ if not run_data:
1775
+ r = _get_redis()
1776
+ if r:
1777
+ try:
1778
+ raw = r.hget(f"experiment_runs:{experiment_id}", run_id)
1779
+ if raw:
1780
+ run_data = json.loads(raw)
1781
+ except Exception:
1782
+ pass
1783
+ if not run_data:
1784
+ raise HTTPException(404, "Run not found.")
1785
+ ctrl = _run_controls.get(run_id, {})
1786
+ return {
1787
+ "run_id": run_id,
1788
+ "status": run_data.get("status"),
1789
+ "signal": ctrl.get("signal", "finished"),
1790
+ "agents_completed": sum(1 for a in run_data.get("agents", []) if a.get("status") in ("completed", "failed", "skipped", "aborted")),
1791
+ "agents_total": len(run_data.get("agents", [])),
1792
+ "total_duration_ms": run_data.get("total_duration_ms"),
1793
+ "mode": run_data.get("mode"),
1794
+ }
1795
+
1796
+ # ------------------------------------------------------------------
1797
+ # Execution Replay
1798
+ # ------------------------------------------------------------------
1799
+
1800
+ @router.get("/{experiment_id}/runs/{run_id}/replay")
1801
+ async def get_run_replay(experiment_id: str, run_id: str, user=Depends(user_auth)):
1802
+ """Get step-by-step replay with graph diffs for each write operation."""
1803
+ from ..core.replay import get_replay_engine
1804
+ engine = get_replay_engine()
1805
+ steps = engine.get_replay_dicts(run_id)
1806
+ write_steps = [s for s in steps if s.get("is_write")]
1807
+ return {
1808
+ "run_id": run_id,
1809
+ "total_steps": len(steps),
1810
+ "write_steps": len(write_steps),
1811
+ "steps": steps,
1812
+ }
1813
+
1814
+ @router.get("/{experiment_id}/runs/{run_id}/quality")
1815
+ async def get_run_quality(experiment_id: str, run_id: str, user=Depends(user_auth)):
1816
+ """Get context quality scores for each agent in a run."""
1817
+ from ..context.quality_tracker import get_quality_tracker
1818
+ tracker = get_quality_tracker()
1819
+ run_data = _runs.get(run_id, {})
1820
+ agents = run_data.get("agents", [])
1821
+ results = []
1822
+ for agent in agents:
1823
+ aid = agent.get("agent_id", "")
1824
+ report = tracker.compute_quality(aid)
1825
+ if report.deliveries_count > 0:
1826
+ results.append({
1827
+ "agent_id": aid,
1828
+ "agent_name": agent.get("name", ""),
1829
+ "role": agent.get("role", ""),
1830
+ **report.to_dict(),
1831
+ })
1832
+ return {"run_id": run_id, "quality": results}
1833
+
1834
+ @router.post("/{experiment_id}/runs/{run_id}/fork")
1835
+ async def fork_run(experiment_id: str, run_id: str, req: dict = Body(default={}), user=Depends(require_member)):
1836
+ """Fork an experiment from a specific step to try alternative decisions."""
1837
+ from ..core.replay import get_replay_engine
1838
+ step_num = req.get("from_step", 1)
1839
+ new_ns = req.get("namespace", f"fork_{run_id[:8]}_{step_num}")
1840
+ source_ns = req.get("source_namespace", "")
1841
+
1842
+ # Try to find source namespace from run data
1843
+ if not source_ns:
1844
+ run_data = _runs.get(run_id)
1845
+ if run_data:
1846
+ source_ns = run_data.get("sandbox_namespace") or run_data.get("source_graph", "")
1847
+
1848
+ engine = get_replay_engine()
1849
+ result = engine.fork_from_step(run_id, step_num, new_ns, source_ns)
1850
+ if "error" in result:
1851
+ raise HTTPException(400, result["error"])
1852
+ return result
1853
+
1854
+ @router.get("/{experiment_id}/runs/{run_id}/steps/{step}/graph")
1855
+ async def get_step_graph(experiment_id: str, run_id: str, step: int, user=Depends(user_auth)):
1856
+ """Get graph snapshot at a specific step."""
1857
+ from ..core.replay import get_replay_engine
1858
+ run_data = _runs.get(run_id, {})
1859
+ ns = run_data.get("sandbox_namespace") or run_data.get("source_graph", "")
1860
+ engine = get_replay_engine()
1861
+ result = engine.get_graph_at_step(run_id, step, ns)
1862
+ if "error" in result:
1863
+ raise HTTPException(404, result["error"])
1864
+ return result
1865
+
1866
+ # ------------------------------------------------------------------
1867
+ # Bootstrap code generation
1868
+ # ------------------------------------------------------------------
1869
+
1870
+ @router.get("/{experiment_id}/bootstrap")
1871
+ async def get_bootstrap(experiment_id: str, user=Depends(user_auth)):
1872
+ """Generate runnable bootstrap code for all agents in the experiment.
1873
+
1874
+ Returns per-agent code snippets for their configured framework.
1875
+ """
1876
+ exp = _experiments.get(experiment_id)
1877
+ if not exp:
1878
+ _load_experiments()
1879
+ exp = _experiments.get(experiment_id)
1880
+ if not exp:
1881
+ raise HTTPException(404, "Experiment not found.")
1882
+
1883
+ from .experiment_bootstrap import generate_bootstrap
1884
+ import os
1885
+
1886
+ api_url = os.environ.get("CONTEXTSYNAPSE_API_URL") or os.environ.get("AICONTEXTDB_API_URL", "http://localhost:8000")
1887
+ agents = exp.get("agents", [])
1888
+ bootstraps = []
1889
+ for agent in agents:
1890
+ b = generate_bootstrap(agent, exp, api_url=api_url)
1891
+ b["agent_name"] = agent.get("name", "")
1892
+ b["agent_id"] = agent.get("agent_id", "")
1893
+ bootstraps.append(b)
1894
+
1895
+ return {
1896
+ "experiment_id": experiment_id,
1897
+ "experiment_name": exp.get("name", ""),
1898
+ "agents": bootstraps,
1899
+ }
1900
+
1901
+ @router.get("/{experiment_id}/bootstrap/{agent_id}")
1902
+ async def get_agent_bootstrap(experiment_id: str, agent_id: str, user=Depends(user_auth)):
1903
+ """Generate bootstrap code for a single agent."""
1904
+ exp = _experiments.get(experiment_id)
1905
+ if not exp:
1906
+ _load_experiments()
1907
+ exp = _experiments.get(experiment_id)
1908
+ if not exp:
1909
+ raise HTTPException(404, "Experiment not found.")
1910
+
1911
+ agent = next((a for a in exp.get("agents", []) if a.get("agent_id") == agent_id), None)
1912
+ if not agent:
1913
+ raise HTTPException(404, "Agent not found in experiment.")
1914
+
1915
+ from .experiment_bootstrap import generate_bootstrap
1916
+ import os
1917
+
1918
+ api_url = os.environ.get("CONTEXTSYNAPSE_API_URL") or os.environ.get("AICONTEXTDB_API_URL", "http://localhost:8000")
1919
+ return generate_bootstrap(agent, exp, api_url=api_url)
1920
+
1921
+ # ------------------------------------------------------------------
1922
+ # Promote sandbox → live (replay experiment actions on real graph)
1923
+ # ------------------------------------------------------------------
1924
+
1925
+ @router.post("/{experiment_id}/runs/{run_id}/promote")
1926
+ async def promote_sandbox(experiment_id: str, run_id: str, user=Depends(require_member)):
1927
+ """Promote a sandbox run — copy experiment-created nodes/edges to the real graph."""
1928
+ run_data = _runs.get(run_id)
1929
+ if not run_data:
1930
+ r = _get_redis()
1931
+ if r:
1932
+ try:
1933
+ raw = r.hget(f"experiment_runs:{experiment_id}", run_id)
1934
+ if raw:
1935
+ run_data = json.loads(raw)
1936
+ except Exception:
1937
+ pass
1938
+ if not run_data:
1939
+ raise HTTPException(404, "Run not found.")
1940
+ if run_data.get("mode") != "sandbox":
1941
+ raise HTTPException(400, "Only sandbox runs can be promoted.")
1942
+ if run_data.get("promoted"):
1943
+ raise HTTPException(400, "Already promoted.")
1944
+ if run_data.get("discarded"):
1945
+ raise HTTPException(400, "Run was discarded.")
1946
+
1947
+ sandbox_ns = run_data.get("sandbox_namespace", "")
1948
+ source_graph = run_data.get("source_graph", "")
1949
+ exp_track_id = run_data.get("track_id", "")
1950
+
1951
+ if not sandbox_ns or not source_graph:
1952
+ raise HTTPException(400, "Missing sandbox or source graph info.")
1953
+
1954
+ from ..core.registry_factory import create_graph_registry
1955
+ from ..core.graph_structures import GraphNode, GraphEdge
1956
+ reg = create_graph_registry()
1957
+
1958
+ sandbox_db = reg.get_graph(sandbox_ns)
1959
+ target_db = reg.get_graph(source_graph)
1960
+ if not sandbox_db or not target_db:
1961
+ raise HTTPException(400, "Could not access sandbox or target graph.")
1962
+
1963
+ # Find nodes created during the experiment (tagged with track_id)
1964
+ promoted_nodes = 0
1965
+ promoted_edges = 0
1966
+ for node in sandbox_db.get_all_nodes():
1967
+ props = node.properties if hasattr(node, "properties") else {}
1968
+ if props.get("track_id") == exp_track_id or props.get("type") == "experiment":
1969
+ try:
1970
+ new_node = GraphNode(id=node.id, label=node.label, properties=props)
1971
+ target_db.add_node(new_node)
1972
+ promoted_nodes += 1
1973
+ except Exception:
1974
+ pass
1975
+
1976
+ for edge in sandbox_db.get_all_edges():
1977
+ props = edge.properties if hasattr(edge, "properties") else {}
1978
+ if props.get("track_id") == exp_track_id:
1979
+ try:
1980
+ new_edge = GraphEdge(
1981
+ id=edge.id, source=edge.source, target=edge.target,
1982
+ label=edge.label, properties=props,
1983
+ )
1984
+ target_db.add_edge(new_edge)
1985
+ promoted_edges += 1
1986
+ except Exception:
1987
+ pass
1988
+
1989
+ # Drop sandbox
1990
+ try:
1991
+ reg.delete_graph(sandbox_ns)
1992
+ except Exception:
1993
+ pass
1994
+
1995
+ run_data["promoted"] = True
1996
+ run_data["promoted_nodes"] = promoted_nodes
1997
+ run_data["promoted_edges"] = promoted_edges
1998
+ _save_run(run_data)
1999
+
2000
+ logger.info("[EXPERIMENT] Promoted sandbox %s → %s: %d nodes, %d edges",
2001
+ sandbox_ns, source_graph, promoted_nodes, promoted_edges)
2002
+
2003
+ return {
2004
+ "promoted": True,
2005
+ "target_graph": source_graph,
2006
+ "promoted_nodes": promoted_nodes,
2007
+ "promoted_edges": promoted_edges,
2008
+ }
2009
+
2010
+ # ------------------------------------------------------------------
2011
+ # Discard sandbox (drop temp graph)
2012
+ # ------------------------------------------------------------------
2013
+
2014
+ @router.post("/{experiment_id}/runs/{run_id}/discard")
2015
+ async def discard_sandbox(experiment_id: str, run_id: str, user=Depends(require_member)):
2016
+ """Discard a sandbox run — drop the temporary graph."""
2017
+ run_data = _runs.get(run_id)
2018
+ if not run_data:
2019
+ r = _get_redis()
2020
+ if r:
2021
+ try:
2022
+ raw = r.hget(f"experiment_runs:{experiment_id}", run_id)
2023
+ if raw:
2024
+ run_data = json.loads(raw)
2025
+ except Exception:
2026
+ pass
2027
+ if not run_data:
2028
+ raise HTTPException(404, "Run not found.")
2029
+ if run_data.get("mode") != "sandbox":
2030
+ raise HTTPException(400, "Only sandbox runs can be discarded.")
2031
+ if run_data.get("discarded"):
2032
+ raise HTTPException(400, "Already discarded.")
2033
+
2034
+ sandbox_ns = run_data.get("sandbox_namespace", "")
2035
+ if sandbox_ns:
2036
+ from ..core.registry_factory import create_graph_registry
2037
+ reg = create_graph_registry()
2038
+ try:
2039
+ reg.delete_graph(sandbox_ns)
2040
+ except Exception:
2041
+ pass
2042
+
2043
+ run_data["discarded"] = True
2044
+ _save_run(run_data)
2045
+
2046
+ logger.info("[EXPERIMENT] Discarded sandbox: %s", sandbox_ns)
2047
+ return {"discarded": True, "sandbox_namespace": sandbox_ns}
2048
+
2049
+ # ------------------------------------------------------------------
2050
+ # Delete a run (permanently remove from history)
2051
+ # ------------------------------------------------------------------
2052
+
2053
+ @router.delete("/{experiment_id}/runs/{run_id}")
2054
+ async def delete_run(experiment_id: str, run_id: str, user=Depends(require_member)):
2055
+ """Permanently delete a run and its sandbox graph if any."""
2056
+ run_data = _runs.pop(run_id, None)
2057
+
2058
+ # Also try Redis
2059
+ r = _get_redis()
2060
+ if r:
2061
+ try:
2062
+ raw = r.hget(f"experiment_runs:{experiment_id}", run_id)
2063
+ if raw and not run_data:
2064
+ run_data = json.loads(raw)
2065
+ r.hdel(f"experiment_runs:{experiment_id}", run_id)
2066
+ except Exception:
2067
+ pass
2068
+
2069
+ if not run_data:
2070
+ raise HTTPException(404, "Run not found.")
2071
+
2072
+ # Cleanup sandbox graph if exists
2073
+ sandbox_ns = run_data.get("sandbox_namespace", "")
2074
+ if sandbox_ns:
2075
+ try:
2076
+ from ..core.registry_factory import create_graph_registry
2077
+ reg = create_graph_registry()
2078
+ reg.delete_graph(sandbox_ns)
2079
+ except Exception:
2080
+ pass
2081
+
2082
+ # Stop if still running
2083
+ _run_controls.pop(run_id, None)
2084
+ t = _run_threads.pop(run_id, None)
2085
+
2086
+ logger.info("[EXPERIMENT] Deleted run %s (experiment %s)", run_id, experiment_id)
2087
+ return {"deleted": True, "run_id": run_id}
2088
+
2089
+ return router