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.
- contextsynapse/__init__.py +107 -0
- contextsynapse/__main__.py +9 -0
- contextsynapse/a2a/__init__.py +28 -0
- contextsynapse/a2a/client.py +120 -0
- contextsynapse/a2a/discovery.py +128 -0
- contextsynapse/a2a/handlers.py +164 -0
- contextsynapse/a2a/models.py +246 -0
- contextsynapse/a2a/streaming.py +92 -0
- contextsynapse/a2a/task_manager.py +275 -0
- contextsynapse/adapters/__init__.py +98 -0
- contextsynapse/adapters/_base.py +298 -0
- contextsynapse/adapters/autogen/__init__.py +4 -0
- contextsynapse/adapters/autogen/tools.py +34 -0
- contextsynapse/adapters/crewai/__init__.py +4 -0
- contextsynapse/adapters/crewai/tools.py +63 -0
- contextsynapse/adapters/langchain/__init__.py +19 -0
- contextsynapse/adapters/langchain/message_history.py +17 -0
- contextsynapse/adapters/langchain/retriever.py +158 -0
- contextsynapse/adapters/langchain/tools.py +43 -0
- contextsynapse/adapters/langgraph/__init__.py +21 -0
- contextsynapse/adapters/langgraph/checkpoint.py +223 -0
- contextsynapse/adapters/langgraph/context_tools.py +341 -0
- contextsynapse/adapters/langgraph/message_history.py +138 -0
- contextsynapse/adapters/langgraph/tools.py +43 -0
- contextsynapse/adapters/llamaindex/__init__.py +4 -0
- contextsynapse/adapters/llamaindex/tools.py +64 -0
- contextsynapse/adapters/openai/__init__.py +24 -0
- contextsynapse/adapters/openai/tools.py +206 -0
- contextsynapse/adapters/pydantic_ai/__init__.py +4 -0
- contextsynapse/adapters/pydantic_ai/tools.py +36 -0
- contextsynapse/adapters/swarm/__init__.py +4 -0
- contextsynapse/adapters/swarm/tools.py +44 -0
- contextsynapse/agents/__init__.py +6 -0
- contextsynapse/agents/auto_capture.py +80 -0
- contextsynapse/agents/dispatcher.py +299 -0
- contextsynapse/agents/notifications.py +112 -0
- contextsynapse/aiql/__init__.py +71 -0
- contextsynapse/aiql/cache.py +341 -0
- contextsynapse/aiql/compiler/__init__.py +62 -0
- contextsynapse/aiql/compiler/optimizer.py +126 -0
- contextsynapse/aiql/compiler/planner.py +366 -0
- contextsynapse/aiql/compiler/statistics.py +235 -0
- contextsynapse/aiql/compiler/validator.py +425 -0
- contextsynapse/aiql/engine/__init__.py +11 -0
- contextsynapse/aiql/engine/chunk_storage.py +2584 -0
- contextsynapse/aiql/engine/executor.py +7737 -0
- contextsynapse/aiql/engine/extraction_integration.py +442 -0
- contextsynapse/aiql/engine/graph_builder.py +405 -0
- contextsynapse/aiql/engine/node_creation_from_normalized.py +691 -0
- contextsynapse/aiql/engine/performance_monitor.py +289 -0
- contextsynapse/aiql/engine/postprocessing.py +365 -0
- contextsynapse/aiql/engine/ray_stage_executors.py +918 -0
- contextsynapse/aiql/engine/redis_cache.py +106 -0
- contextsynapse/aiql/engine/streaming_json.py +366 -0
- contextsynapse/aiql/engine/streaming_parquet.py +568 -0
- contextsynapse/aiql/engine/streaming_ray_chunking.py +419 -0
- contextsynapse/aiql/grammar/__init__.py +14 -0
- contextsynapse/aiql/grammar/aiql_grammar.py +1822 -0
- contextsynapse/aiql/grammar/base.py +71 -0
- contextsynapse/aiql/parser/__init__.py +46 -0
- contextsynapse/aiql/parser/aiql_parser.py +11330 -0
- contextsynapse/aiql/parser/base_parser.py +47 -0
- contextsynapse/api/__init__.py +5 -0
- contextsynapse/api/a2a_router.py +233 -0
- contextsynapse/api/admin_router.py +140 -0
- contextsynapse/api/agent_worker_router.py +978 -0
- contextsynapse/api/algorithms_router.py +317 -0
- contextsynapse/api/api.py +4144 -0
- contextsynapse/api/audit.py +204 -0
- contextsynapse/api/auth.py +441 -0
- contextsynapse/api/auth_router.py +537 -0
- contextsynapse/api/billing.py +261 -0
- contextsynapse/api/billing_router.py +275 -0
- contextsynapse/api/boundary_router.py +452 -0
- contextsynapse/api/cognition_router.py +116 -0
- contextsynapse/api/connector_router.py +237 -0
- contextsynapse/api/context_router.py +1619 -0
- contextsynapse/api/dashboard_router.py +11367 -0
- contextsynapse/api/errors.py +183 -0
- contextsynapse/api/events.py +88 -0
- contextsynapse/api/experiment_bootstrap.py +321 -0
- contextsynapse/api/experiment_orchestrator.py +1078 -0
- contextsynapse/api/experiment_router.py +2089 -0
- contextsynapse/api/feature_gate.py +89 -0
- contextsynapse/api/federation_router.py +108 -0
- contextsynapse/api/integrations.py +567 -0
- contextsynapse/api/integrations_router.py +461 -0
- contextsynapse/api/intelligence_router.py +1028 -0
- contextsynapse/api/metering.py +155 -0
- contextsynapse/api/metrics.py +112 -0
- contextsynapse/api/models.py +38 -0
- contextsynapse/api/monitoring_router.py +174 -0
- contextsynapse/api/pipeline_router.py +1437 -0
- contextsynapse/api/playground_conn.py +116 -0
- contextsynapse/api/projects_router.py +131 -0
- contextsynapse/api/queries_router.py +282 -0
- contextsynapse/api/quota.py +95 -0
- contextsynapse/api/rate_limit.py +64 -0
- contextsynapse/api/search_router.py +261 -0
- contextsynapse/api/shield_router.py +447 -0
- contextsynapse/api/tenant_guard.py +103 -0
- contextsynapse/api/tenant_quotas.py +231 -0
- contextsynapse/api/tenants.py +305 -0
- contextsynapse/api/users.py +646 -0
- contextsynapse/api/v1.py +167 -0
- contextsynapse/api/vertical_builder_router.py +387 -0
- contextsynapse/benchmark/__init__.py +15 -0
- contextsynapse/benchmark/__main__.py +64 -0
- contextsynapse/benchmark/agents.py +338 -0
- contextsynapse/benchmark/live_ab.py +338 -0
- contextsynapse/benchmark/reporter.py +149 -0
- contextsynapse/benchmark/runner.py +264 -0
- contextsynapse/benchmark/savings.py +266 -0
- contextsynapse/benchmark/token_counter.py +212 -0
- contextsynapse/benchmark/workloads.py +289 -0
- contextsynapse/buffer/__init__.py +154 -0
- contextsynapse/buffer/base.py +257 -0
- contextsynapse/buffer/duckdb_buffer.py +246 -0
- contextsynapse/buffer/file_buffer.py +229 -0
- contextsynapse/buffer/lmdb_buffer.py +286 -0
- contextsynapse/buffer/local_buffer.py +154 -0
- contextsynapse/buffer/redis_buffer.py +211 -0
- contextsynapse/buffer/sqlite_buffer.py +266 -0
- contextsynapse/cache.py +2 -0
- contextsynapse/cli/__init__.py +40 -0
- contextsynapse/cli/cli.py +475 -0
- contextsynapse/cli/config_cli.py +177 -0
- contextsynapse/cli/scaffold.py +465 -0
- contextsynapse/cognition/__init__.py +31 -0
- contextsynapse/cognition/derivation.py +55 -0
- contextsynapse/cognition/events.py +135 -0
- contextsynapse/cognition/feedback.py +52 -0
- contextsynapse/cognition/hallucination.py +90 -0
- contextsynapse/cognition/invalidation.py +70 -0
- contextsynapse/cognition/read_tracker.py +50 -0
- contextsynapse/collaboration.py +2 -0
- contextsynapse/comparison.py +2 -0
- contextsynapse/config/__init__.py +10 -0
- contextsynapse/config/connectors/amfi_nav.yaml +18 -0
- contextsynapse/config/connectors/auto_sector.yaml +20 -0
- contextsynapse/config/connectors/banking_sector.yaml +20 -0
- contextsynapse/config/connectors/energy_sector.yaml +20 -0
- contextsynapse/config/connectors/fmcg_sector.yaml +20 -0
- contextsynapse/config/connectors/nsdl_fpi.yaml +9 -0
- contextsynapse/config/connectors/nse_price.yaml +61 -0
- contextsynapse/config/connectors/pharma_sector.yaml +20 -0
- contextsynapse/config/connectors/rbi_dbie.yaml +12 -0
- contextsynapse/config/context_presets/ai_research.json +25 -0
- contextsynapse/config/context_presets/chatgpt_conversations.json +19 -0
- contextsynapse/config/context_presets/claude_conversations.json +19 -0
- contextsynapse/config/context_presets/legal_compliance.json +21 -0
- contextsynapse/config/context_presets/meeting_notes.json +21 -0
- contextsynapse/config/context_presets/slack_engineering.json +21 -0
- contextsynapse/config/context_presets/tech_climate.json +24 -0
- contextsynapse/config/context_presets/toi_politics.json +25 -0
- contextsynapse/config/database_config.py +453 -0
- contextsynapse/config/deployment.py +221 -0
- contextsynapse/config/gpt_action_schema.json +164 -0
- contextsynapse/config/indicators/india_macro.yaml +213 -0
- contextsynapse/config/logging_config.py +88 -0
- contextsynapse/config/mode_config.py +223 -0
- contextsynapse/config/schemas/api_spec.yaml +48 -0
- contextsynapse/config/schemas/architecture_doc.yaml +51 -0
- contextsynapse/config/schemas/chat_history.yaml +90 -0
- contextsynapse/config/schemas/chatgpt_conversation.yaml +101 -0
- contextsynapse/config/schemas/claude_conversation.yaml +148 -0
- contextsynapse/config/schemas/contract.yaml +48 -0
- contextsynapse/config/schemas/conversation.yaml +167 -0
- contextsynapse/config/schemas/database.yaml +74 -0
- contextsynapse/config/schemas/decision.yaml +72 -0
- contextsynapse/config/schemas/ecommerce.yaml +56 -0
- contextsynapse/config/schemas/education.yaml +42 -0
- contextsynapse/config/schemas/healthcare.yaml +133 -0
- contextsynapse/config/schemas/hr_document.yaml +48 -0
- contextsynapse/config/schemas/invoice.yaml +40 -0
- contextsynapse/config/schemas/knowledge_base.yaml +75 -0
- contextsynapse/config/schemas/legal.yaml +42 -0
- contextsynapse/config/schemas/marketing_content.yaml +48 -0
- contextsynapse/config/schemas/meeting_notes.yaml +51 -0
- contextsynapse/config/schemas/news_article.yaml +61 -0
- contextsynapse/config/schemas/real_estate.yaml +49 -0
- contextsynapse/config/schemas/requirements_doc.yaml +48 -0
- contextsynapse/config/schemas/research_paper.yaml +56 -0
- contextsynapse/config/schemas/resume.yaml +49 -0
- contextsynapse/config/schemas/retail.yaml +42 -0
- contextsynapse/config/schemas/rule_meta.yaml +173 -0
- contextsynapse/config/schemas/rules.yaml +72 -0
- contextsynapse/config/schemas/screener_meta.yaml +236 -0
- contextsynapse/config/schemas/sdlc.yaml +103 -0
- contextsynapse/config/schemas/session_graph.yaml +172 -0
- contextsynapse/config/schemas/support_ticket.yaml +48 -0
- contextsynapse/config/schemas/web.yaml +64 -0
- contextsynapse/config/sensors/earnings_calendar.yaml +52 -0
- contextsynapse/config/sensors/global_risk.yaml +53 -0
- contextsynapse/config/sensors/government_policy.yaml +54 -0
- contextsynapse/config/sensors/supply_chain.yaml +50 -0
- contextsynapse/config/sensors/templates/behavioral.yaml +56 -0
- contextsynapse/config/sensors/templates/equity_sentiment.yaml +48 -0
- contextsynapse/config/sensors/templates/global_macro.yaml +47 -0
- contextsynapse/config/sensors/templates/market_data.yaml +118 -0
- contextsynapse/config/sensors/templates/sector_technical.yaml +47 -0
- contextsynapse/config/sensors/weather.yaml +52 -0
- contextsynapse/config/storage_strategy.py +202 -0
- contextsynapse/config/templates/competitive_intel.yaml +39 -0
- contextsynapse/config/templates/geopolitical_risk.yaml +46 -0
- contextsynapse/config/templates/market_analysis.yaml +58 -0
- contextsynapse/connectors/__init__.py +15 -0
- contextsynapse/connectors/api_router.py +121 -0
- contextsynapse/connectors/base.py +357 -0
- contextsynapse/connectors/financial/audio.py +170 -0
- contextsynapse/connectors/financial/youtube.py +272 -0
- contextsynapse/connectors/github.py +164 -0
- contextsynapse/connectors/jira.py +120 -0
- contextsynapse/connectors/kafka_connector.py +124 -0
- contextsynapse/connectors/pipeline_connector.py +747 -0
- contextsynapse/connectors/registry.py +116 -0
- contextsynapse/connectors/scheduler.py +87 -0
- contextsynapse/connectors/universal.py +360 -0
- contextsynapse/context/__init__.py +96 -0
- contextsynapse/context/__main__.py +4 -0
- contextsynapse/context/acl.py +125 -0
- contextsynapse/context/agent_card.py +201 -0
- contextsynapse/context/agent_memory.py +814 -0
- contextsynapse/context/agents.py +472 -0
- contextsynapse/context/agents_redis.py +391 -0
- contextsynapse/context/assembled.py +204 -0
- contextsynapse/context/attribution.py +113 -0
- contextsynapse/context/blob.py +184 -0
- contextsynapse/context/boundaries.py +187 -0
- contextsynapse/context/boundary.py +507 -0
- contextsynapse/context/bundle.py +155 -0
- contextsynapse/context/cli.py +519 -0
- contextsynapse/context/collaboration.py +165 -0
- contextsynapse/context/compiler.py +604 -0
- contextsynapse/context/composite.py +536 -0
- contextsynapse/context/compression.py +147 -0
- contextsynapse/context/context_manager.py +888 -0
- contextsynapse/context/context_schema.py +90 -0
- contextsynapse/context/context_schema.yaml +105 -0
- contextsynapse/context/context_units.py +891 -0
- contextsynapse/context/conversation.py +488 -0
- contextsynapse/context/dedup.py +207 -0
- contextsynapse/context/document_processor.py +698 -0
- contextsynapse/context/embedding_hooks.py +338 -0
- contextsynapse/context/execution_schema.py +274 -0
- contextsynapse/context/fan_out.py +138 -0
- contextsynapse/context/frozen.py +80 -0
- contextsynapse/context/gravity.py +227 -0
- contextsynapse/context/hub.py +1401 -0
- contextsynapse/context/ingest.py +391 -0
- contextsynapse/context/injection.py +226 -0
- contextsynapse/context/interaction_graphifier.py +562 -0
- contextsynapse/context/layers.py +574 -0
- contextsynapse/context/projection.py +1195 -0
- contextsynapse/context/promotion.py +634 -0
- contextsynapse/context/proof.py +131 -0
- contextsynapse/context/propagation.py +577 -0
- contextsynapse/context/pubsub.py +135 -0
- contextsynapse/context/quality.py +1054 -0
- contextsynapse/context/quality_tracker.py +270 -0
- contextsynapse/context/scoping.py +370 -0
- contextsynapse/context/seed.py +992 -0
- contextsynapse/context/session.py +1142 -0
- contextsynapse/context/session_graph.py +593 -0
- contextsynapse/context/session_memory.py +309 -0
- contextsynapse/context/session_resolver.py +111 -0
- contextsynapse/context/skill_learning.py +237 -0
- contextsynapse/context/smart_budget.py +129 -0
- contextsynapse/context/source_policy.py +242 -0
- contextsynapse/context/store_factory.py +53 -0
- contextsynapse/context/sync.py +648 -0
- contextsynapse/context/token.py +236 -0
- contextsynapse/context/vector_integration.py +297 -0
- contextsynapse/context/webhooks.py +194 -0
- contextsynapse/context/working_memory.py +224 -0
- contextsynapse/core/__init__.py +18 -0
- contextsynapse/core/archiver.py +237 -0
- contextsynapse/core/change_stream.py +282 -0
- contextsynapse/core/checkpoint.py +310 -0
- contextsynapse/core/cloud_storage.py +387 -0
- contextsynapse/core/context_state.py +651 -0
- contextsynapse/core/cron.py +324 -0
- contextsynapse/core/datasource.py +643 -0
- contextsynapse/core/db.py +289 -0
- contextsynapse/core/federation.py +283 -0
- contextsynapse/core/graph_coordinator.py +201 -0
- contextsynapse/core/graph_intelligence.py +388 -0
- contextsynapse/core/graph_structures.py +106 -0
- contextsynapse/core/graph_sync.py +157 -0
- contextsynapse/core/hybrid_graph_storage.py +2998 -0
- contextsynapse/core/lifecycle.py +691 -0
- contextsynapse/core/multiworker.py +378 -0
- contextsynapse/core/pruning.py +312 -0
- contextsynapse/core/redis_registry.py +524 -0
- contextsynapse/core/registry.py +1043 -0
- contextsynapse/core/registry_factory.py +51 -0
- contextsynapse/core/registry_metadata.py +294 -0
- contextsynapse/core/replay.py +354 -0
- contextsynapse/core/replication.py +339 -0
- contextsynapse/core/time_travel.py +260 -0
- contextsynapse/core/write_behind.py +152 -0
- contextsynapse/core/write_context.py +39 -0
- contextsynapse/db/__init__.py +1 -0
- contextsynapse/db/postgres.py +267 -0
- contextsynapse/db/profile.py +236 -0
- contextsynapse/db/rules.py +328 -0
- contextsynapse/demo/__init__.py +0 -0
- contextsynapse/demo/report.py +147 -0
- contextsynapse/demo/runner.py +520 -0
- contextsynapse/demo/scenario.py +165 -0
- contextsynapse/demo/session_demo.py +280 -0
- contextsynapse/demo/session_report.py +143 -0
- contextsynapse/demo/universal_runner.py +257 -0
- contextsynapse/engine/__init__.py +15 -0
- contextsynapse/engine/core.py +555 -0
- contextsynapse/extraction/__init__.py +69 -0
- contextsynapse/extraction/cli.py +274 -0
- contextsynapse/extraction/config.py +107 -0
- contextsynapse/extraction/examples/example_usage.py +118 -0
- contextsynapse/extraction/examples/sample_manifest.json +53 -0
- contextsynapse/extraction/examples/sample_normalized.json +185 -0
- contextsynapse/extraction/extract_engine.py +440 -0
- contextsynapse/extraction/extractors/__init__.py +37 -0
- contextsynapse/extraction/extractors/chat_export_extractor.py +189 -0
- contextsynapse/extraction/extractors/csv_extractor.py +83 -0
- contextsynapse/extraction/extractors/docx_extractor.py +129 -0
- contextsynapse/extraction/extractors/excel_extractor.py +152 -0
- contextsynapse/extraction/extractors/html_extractor.py +146 -0
- contextsynapse/extraction/extractors/pdf_extractor.py +635 -0
- contextsynapse/extraction/extractors/pdf_extractor_layoutparser.py +408 -0
- contextsynapse/extraction/extractors/register_extractors.py +63 -0
- contextsynapse/extraction/extractors/text_extractor.py +65 -0
- contextsynapse/extraction/extractors/txt_extractor.py +120 -0
- contextsynapse/extraction/extractors/website_extractor.py +246 -0
- contextsynapse/extraction/fact_extractor.py +236 -0
- contextsynapse/extraction/file_manager.py +353 -0
- contextsynapse/extraction/hierarchy.py +239 -0
- contextsynapse/extraction/id_generator.py +450 -0
- contextsynapse/extraction/layout_detector.py +409 -0
- contextsynapse/extraction/llm_entity_extractor.py +301 -0
- contextsynapse/extraction/normalized_store.py +480 -0
- contextsynapse/extraction/normalizer.py +594 -0
- contextsynapse/extraction/parquet_writer.py +598 -0
- contextsynapse/extraction/post_processors/__init__.py +40 -0
- contextsynapse/extraction/post_processors/column_reorganizer.py +507 -0
- contextsynapse/extraction/ray_normalization.py +233 -0
- contextsynapse/extraction/ray_runner.py +207 -0
- contextsynapse/extraction/redis_schema_registry.py +222 -0
- contextsynapse/extraction/registry.py +204 -0
- contextsynapse/extraction/schema_loader.py +663 -0
- contextsynapse/extraction/schema_registry.py +230 -0
- contextsynapse/extraction/semantic_normalizer.py +245 -0
- contextsynapse/extraction/version_manager.py +314 -0
- contextsynapse/gateway/__init__.py +24 -0
- contextsynapse/gateway/action_emitter.py +212 -0
- contextsynapse/gateway/cost_tracker.py +508 -0
- contextsynapse/gateway/gateway.py +377 -0
- contextsynapse/gateway/policy.py +303 -0
- contextsynapse/governance/__init__.py +13 -0
- contextsynapse/governance/layer.py +513 -0
- contextsynapse/ingestion/__init__.py +15 -0
- contextsynapse/ingestion/amplifier.py +288 -0
- contextsynapse/ingestion/async_ingest.py +463 -0
- contextsynapse/ingestion/chunker.py +303 -0
- contextsynapse/ingestion/chunking.py +58 -0
- contextsynapse/ingestion/cleaner.py +190 -0
- contextsynapse/ingestion/cleanse.py +252 -0
- contextsynapse/ingestion/connectors/__init__.py +38 -0
- contextsynapse/ingestion/connectors/base.py +300 -0
- contextsynapse/ingestion/connectors/chatgpt_memory.py +247 -0
- contextsynapse/ingestion/connectors/claude_memory.py +274 -0
- contextsynapse/ingestion/connectors/sap_ingestor.py +208 -0
- contextsynapse/ingestion/content_detector.py +80 -0
- contextsynapse/ingestion/content_monitor.py +502 -0
- contextsynapse/ingestion/dedup.py +106 -0
- contextsynapse/ingestion/feed.py +640 -0
- contextsynapse/ingestion/filters.py +461 -0
- contextsynapse/ingestion/graph_builder.py +705 -0
- contextsynapse/ingestion/input_classifier.py +307 -0
- contextsynapse/ingestion/job_manager.py +571 -0
- contextsynapse/ingestion/llm_extractor.py +610 -0
- contextsynapse/ingestion/orchestrator.py +116 -0
- contextsynapse/ingestion/parsers/__init__.py +19 -0
- contextsynapse/ingestion/parsers/graph_parser_registry.py +103 -0
- contextsynapse/ingestion/parsers/graphml_parser.py +141 -0
- contextsynapse/ingestion/parsers/jsonld_parser.py +171 -0
- contextsynapse/ingestion/parsers/rdf_parser.py +150 -0
- contextsynapse/ingestion/pipeline_context.py +228 -0
- contextsynapse/ingestion/pipeline_store.py +1195 -0
- contextsynapse/ingestion/queue.py +133 -0
- contextsynapse/ingestion/reprocess.py +179 -0
- contextsynapse/ingestion/scenario_router.py +224 -0
- contextsynapse/ingestion/schema_extractor.py +759 -0
- contextsynapse/ingestion/schema_validator.py +274 -0
- contextsynapse/ingestion/sdlc_ingest.py +87 -0
- contextsynapse/ingestion/smart_ingest.py +2451 -0
- contextsynapse/ingestion/source_connector.py +171 -0
- contextsynapse/ingestion/stage_executor.py +2095 -0
- contextsynapse/ingestion/stages/__init__.py +9 -0
- contextsynapse/ingestion/stages/tabular_to_graph.py +289 -0
- contextsynapse/ingestion/strategies.py +386 -0
- contextsynapse/ingestion/universal/__init__.py +4 -0
- contextsynapse/ingestion/universal/_operator_registry.py +59 -0
- contextsynapse/ingestion/universal/ingest_content.py +32 -0
- contextsynapse/ingestion/universal/operators/__init__.py +1 -0
- contextsynapse/ingestion/universal/operators/base.py +19 -0
- contextsynapse/ingestion/universal/operators/build_edges.py +75 -0
- contextsynapse/ingestion/universal/operators/cluster_topics.py +397 -0
- contextsynapse/ingestion/universal/operators/deduplicate.py +45 -0
- contextsynapse/ingestion/universal/operators/detect_signals.py +90 -0
- contextsynapse/ingestion/universal/operators/embed.py +80 -0
- contextsynapse/ingestion/universal/operators/extract_entities.py +253 -0
- contextsynapse/ingestion/universal/operators/extract_preferences.py +117 -0
- contextsynapse/ingestion/universal/operators/filter_content.py +69 -0
- contextsynapse/ingestion/universal/operators/index_bm25.py +52 -0
- contextsynapse/ingestion/universal/operators/infer_domains.py +69 -0
- contextsynapse/ingestion/universal/operators/link_cross_reference.py +210 -0
- contextsynapse/ingestion/universal/operators/parse_records.py +66 -0
- contextsynapse/ingestion/universal/operators/resolve_entities.py +261 -0
- contextsynapse/ingestion/universal/operators/scanners/__init__.py +16 -0
- contextsynapse/ingestion/universal/operators/scanners/edges.py +173 -0
- contextsynapse/ingestion/universal/operators/scanners/git.py +187 -0
- contextsynapse/ingestion/universal/operators/scanners/github.py +135 -0
- contextsynapse/ingestion/universal/operators/scanners/github_api.py +509 -0
- contextsynapse/ingestion/universal/operators/scanners/helpers.py +67 -0
- contextsynapse/ingestion/universal/operators/scanners/jira.py +65 -0
- contextsynapse/ingestion/universal/operators/scanners/llm_enrichment.py +82 -0
- contextsynapse/ingestion/universal/operators/scanners/repo.py +149 -0
- contextsynapse/ingestion/universal/operators/sdlc_scan.py +213 -0
- contextsynapse/ingestion/universal/operators/spec_scanner.py +149 -0
- contextsynapse/ingestion/universal/operators/store_documents.py +72 -0
- contextsynapse/ingestion/universal/operators/synthesize_cu.py +180 -0
- contextsynapse/ingestion/universal/operators/validate_gate.py +65 -0
- contextsynapse/ingestion/universal/parsers/__init__.py +5 -0
- contextsynapse/ingestion/universal/parsers/record_parser.py +203 -0
- contextsynapse/ingestion/universal/parsers/turn_parser.py +329 -0
- contextsynapse/ingestion/universal/shared_signals.py +161 -0
- contextsynapse/ingestion/universal/stage_executor.py +219 -0
- contextsynapse/ingestion/web_crawler.py +265 -0
- contextsynapse/intelligence/__init__.py +53 -0
- contextsynapse/intelligence/alerts.py +198 -0
- contextsynapse/intelligence/collector.py +655 -0
- contextsynapse/intelligence/comparison.py +198 -0
- contextsynapse/intelligence/config.py +56 -0
- contextsynapse/intelligence/conflict_detector.py +182 -0
- contextsynapse/intelligence/context_gaps.py +192 -0
- contextsynapse/intelligence/context_radar.py +135 -0
- contextsynapse/intelligence/correlation_engine.py +416 -0
- contextsynapse/intelligence/correlation_template.py +120 -0
- contextsynapse/intelligence/dedup.py +296 -0
- contextsynapse/intelligence/event_bus.py +91 -0
- contextsynapse/intelligence/feedback_loop.py +101 -0
- contextsynapse/intelligence/freshness.py +307 -0
- contextsynapse/intelligence/fundamental_feed.py +551 -0
- contextsynapse/intelligence/geo_data.py +170 -0
- contextsynapse/intelligence/geo_reference.py +112 -0
- contextsynapse/intelligence/graph_fusion.py +465 -0
- contextsynapse/intelligence/heuristics.py +176 -0
- contextsynapse/intelligence/impact_tracker.py +336 -0
- contextsynapse/intelligence/macro_indicators.py +319 -0
- contextsynapse/intelligence/ocr.py +104 -0
- contextsynapse/intelligence/persistence.py +267 -0
- contextsynapse/intelligence/pipeline_health.py +130 -0
- contextsynapse/intelligence/price_feed.py +221 -0
- contextsynapse/intelligence/rate_limiter.py +130 -0
- contextsynapse/intelligence/reactive.py +411 -0
- contextsynapse/intelligence/runtime_context.py +390 -0
- contextsynapse/intelligence/sentiment_decay.py +426 -0
- contextsynapse/intelligence/session.py +277 -0
- contextsynapse/intelligence/signal_hierarchy.py +438 -0
- contextsynapse/intelligence/signals.py +185 -0
- contextsynapse/intelligence/source_watcher.py +132 -0
- contextsynapse/intelligence/table_enricher.py +256 -0
- contextsynapse/intelligence/tagger.py +319 -0
- contextsynapse/intelligence/timeseries.py +209 -0
- contextsynapse/intelligence/tracker.py +318 -0
- contextsynapse/intelligence/watchdog.py +392 -0
- contextsynapse/intelligence/watchdog_manager.py +263 -0
- contextsynapse/intelligence/webhook_receiver.py +165 -0
- contextsynapse/intelligence/weight_learner.py +234 -0
- contextsynapse/llm/__init__.py +8 -0
- contextsynapse/llm/client.py +348 -0
- contextsynapse/llm/rate_limiter.py +166 -0
- contextsynapse/logging_config.py +2 -0
- contextsynapse/marketplace/__init__.py +7 -0
- contextsynapse/marketplace/registry.py +173 -0
- contextsynapse/mcp/__init__.py +11 -0
- contextsynapse/mcp/__main__.py +4 -0
- contextsynapse/mcp/auth_middleware.py +212 -0
- contextsynapse/mcp/connection_pool.py +128 -0
- contextsynapse/mcp/server.py +986 -0
- contextsynapse/mcp/session_context.py +67 -0
- contextsynapse/memory/__init__.py +34 -0
- contextsynapse/memory/engine.py +475 -0
- contextsynapse/memory/temporal.py +327 -0
- contextsynapse/metadata/__init__.py +12 -0
- contextsynapse/metadata/metadata_db.py +272 -0
- contextsynapse/metadata/metadata_query_tool.py +300 -0
- contextsynapse/metadata/metadata_tracker.py +425 -0
- contextsynapse/models/__init__.py +27 -0
- contextsynapse/models/embedding_service.py +360 -0
- contextsynapse/models/model_registry.py +228 -0
- contextsynapse/multiworker.py +2 -0
- contextsynapse/pipelines/__init__.py +1 -0
- contextsynapse/pipelines/executor.py +851 -0
- contextsynapse/pipelines/filters.py +189 -0
- contextsynapse/pipelines/models.py +176 -0
- contextsynapse/pipelines/scheduler.py +268 -0
- contextsynapse/playground/__init__.py +1 -0
- contextsynapse/playground/templates.py +85 -0
- contextsynapse/plugins/__init__.py +43 -0
- contextsynapse/plugins/api.py +66 -0
- contextsynapse/plugins/base.py +350 -0
- contextsynapse/plugins/domain_interface.py +508 -0
- contextsynapse/plugins/domain_loader.py +241 -0
- contextsynapse/plugins/loader.py +148 -0
- contextsynapse/plugins/registry.py +265 -0
- contextsynapse/project/__init__.py +11 -0
- contextsynapse/project/code_context.py +807 -0
- contextsynapse/project/connectors/__init__.py +4 -0
- contextsynapse/project/connectors/github_connector.py +113 -0
- contextsynapse/project/connectors/jira_connector.py +160 -0
- contextsynapse/project/graph.py +1023 -0
- contextsynapse/project/project_context.py +545 -0
- contextsynapse/project/scanner_registry.py +61 -0
- contextsynapse/project/schema_manager.py +322 -0
- contextsynapse/project/schemas/knowledge.yaml +59 -0
- contextsynapse/project/schemas/sdlc.yaml +369 -0
- contextsynapse/project/sdlc_schema.py +172 -0
- contextsynapse/project/spec_parser.py +85 -0
- contextsynapse/project/task_lock.py +181 -0
- contextsynapse/project/task_stream.py +203 -0
- contextsynapse/project/team_tools.py +537 -0
- contextsynapse/py.typed +0 -0
- contextsynapse/realtime/__init__.py +6 -0
- contextsynapse/realtime/collaboration.py +147 -0
- contextsynapse/rules/__init__.py +10 -0
- contextsynapse/rules/engine.py +116 -0
- contextsynapse/rules/evaluators/__init__.py +6 -0
- contextsynapse/rules/evaluators/aggregate.py +205 -0
- contextsynapse/rules/evaluators/custom.py +170 -0
- contextsynapse/rules/evaluators/pre_action.py +125 -0
- contextsynapse/rules/evaluators/temporal.py +129 -0
- contextsynapse/rules/loader.py +52 -0
- contextsynapse/rules/models.py +124 -0
- contextsynapse/rules/universal.py +393 -0
- contextsynapse/scheduler.py +229 -0
- contextsynapse/schema/__init__.py +42 -0
- contextsynapse/schema/compiler.py +252 -0
- contextsynapse/schema/composer.py +158 -0
- contextsynapse/schema/dedup.py +73 -0
- contextsynapse/schema/derivation.py +172 -0
- contextsynapse/schema/sdl.py +326 -0
- contextsynapse/schema/validation_gate.py +124 -0
- contextsynapse/sdk/__init__.py +63 -0
- contextsynapse/sdk/agent.py +214 -0
- contextsynapse/sdk/client.py +269 -0
- contextsynapse/sdk/exceptions.py +23 -0
- contextsynapse/sdk/models.py +78 -0
- contextsynapse/sdk/session.py +408 -0
- contextsynapse/sdk/tracked.py +218 -0
- contextsynapse/sdk/worker.py +266 -0
- contextsynapse/sdk/workspace_client.py +98 -0
- contextsynapse/sdk/ws.py +41 -0
- contextsynapse/search/__init__.py +20 -0
- contextsynapse/search/embedding_cache.py +140 -0
- contextsynapse/search/enhanced_search.py +152 -0
- contextsynapse/search/fulltext.py +130 -0
- contextsynapse/search/graph_search.py +634 -0
- contextsynapse/search/lmdb_index.py +1218 -0
- contextsynapse/search/nl_to_aiql.py +199 -0
- contextsynapse/search/rag.py +1346 -0
- contextsynapse/search/rag_cache.py +167 -0
- contextsynapse/search/reasoning_chain.py +231 -0
- contextsynapse/search/redis_search.py +207 -0
- contextsynapse/search/retrieval_quality.py +314 -0
- contextsynapse/search/semantic_query.py +455 -0
- contextsynapse/search/text_resolver.py +159 -0
- contextsynapse/search/whoosh_search.py +373 -0
- contextsynapse/security/__init__.py +17 -0
- contextsynapse/security/audit.py +297 -0
- contextsynapse/security/audit_logger.py +97 -0
- contextsynapse/security/audit_trail.py +270 -0
- contextsynapse/security/auth_unified.py +284 -0
- contextsynapse/security/auto_tagger.py +230 -0
- contextsynapse/security/data_security.py +218 -0
- contextsynapse/security/encryption.py +207 -0
- contextsynapse/security/identity.py +447 -0
- contextsynapse/security/jwt_identity.py +59 -0
- contextsynapse/security/middleware.py +336 -0
- contextsynapse/security/pii.py +233 -0
- contextsynapse/security/rbac.py +96 -0
- contextsynapse/security/rls.py +205 -0
- contextsynapse/security/sanitize.py +233 -0
- contextsynapse/security/scoped_encryption.py +131 -0
- contextsynapse/security/tenant.py +401 -0
- contextsynapse/shield/__init__.py +21 -0
- contextsynapse/shield/anomaly.py +56 -0
- contextsynapse/shield/permissions.py +99 -0
- contextsynapse/shield/profile.py +103 -0
- contextsynapse/shield/shield.py +108 -0
- contextsynapse/shield/trust_engine.py +69 -0
- contextsynapse/skills/__init__.py +14 -0
- contextsynapse/skills/engine.py +233 -0
- contextsynapse/skills/loader.py +59 -0
- contextsynapse/skills/models.py +139 -0
- contextsynapse/storage/__init__.py +7 -0
- contextsynapse/storage/cache.py +208 -0
- contextsynapse/storage/columnar_store_v2.py +178 -0
- contextsynapse/storage/csr_graph_storage.py +587 -0
- contextsynapse/storage/csr_store.py +388 -0
- contextsynapse/storage/document_store.py +491 -0
- contextsynapse/storage/hnsw_index.py +294 -0
- contextsynapse/storage/hybrid_store_v2.py +278 -0
- contextsynapse/storage/lmdb_graph_storage.py +366 -0
- contextsynapse/storage/migration.py +279 -0
- contextsynapse/storage/namespace_store.py +346 -0
- contextsynapse/storage/pure_graph_storage.py +185 -0
- contextsynapse/storage/redis_graph_adapter.py +186 -0
- contextsynapse/storage/redis_graph_storage.py +375 -0
- contextsynapse/storage/router/__init__.py +29 -0
- contextsynapse/storage/router/duckdb_store.py +398 -0
- contextsynapse/storage/router/factory.py +83 -0
- contextsynapse/storage/router/interface.py +125 -0
- contextsynapse/storage/router/postgres_store.py +339 -0
- contextsynapse/storage/router/resolver.py +192 -0
- contextsynapse/storage/router/router.py +300 -0
- contextsynapse/storage/single_file_storage.py +391 -0
- contextsynapse/storage/storage_manager.py +214 -0
- contextsynapse/storage/storage_query_optimizer.py +314 -0
- contextsynapse/storage/wal.py +359 -0
- contextsynapse/temporal/__init__.py +0 -0
- contextsynapse/temporal/storage.py +106 -0
- contextsynapse/tests/__init__.py +11 -0
- contextsynapse/tests/regression/__init__.py +1 -0
- contextsynapse/tests/regression/add_metadata.py +395 -0
- contextsynapse/tests/regression/add_skip_flags.py +85 -0
- contextsynapse/tests/regression/cases/test_basic_create_node.yaml +20 -0
- contextsynapse/tests/regression/cases/test_basic_select.yaml +21 -0
- contextsynapse/tests/regression/cases/test_blockchain_audit_trail.yaml +37 -0
- contextsynapse/tests/regression/cases/test_blockchain_audit_trail_filtered.yaml +39 -0
- contextsynapse/tests/regression/cases/test_blockchain_blocks_range.yaml +39 -0
- contextsynapse/tests/regression/cases/test_blockchain_get_block.yaml +35 -0
- contextsynapse/tests/regression/cases/test_blockchain_latest_block.yaml +35 -0
- contextsynapse/tests/regression/cases/test_blockchain_length.yaml +37 -0
- contextsynapse/tests/regression/cases/test_blockchain_merkle_root.yaml +37 -0
- contextsynapse/tests/regression/cases/test_blockchain_verify.yaml +35 -0
- contextsynapse/tests/regression/cases/test_blockchain_verify_block.yaml +35 -0
- contextsynapse/tests/regression/cases/test_create_edge.yaml +28 -0
- contextsynapse/tests/regression/cases/test_delete_edge_basic.yaml +29 -0
- contextsynapse/tests/regression/cases/test_delete_node_basic.yaml +29 -0
- contextsynapse/tests/regression/cases/test_evaluation_mrr.yaml +40 -0
- contextsynapse/tests/regression/cases/test_evaluation_ndcg.yaml +40 -0
- contextsynapse/tests/regression/cases/test_evaluation_precision.yaml +40 -0
- contextsynapse/tests/regression/cases/test_evaluation_recall.yaml +40 -0
- contextsynapse/tests/regression/cases/test_group_by_basic.yaml +56 -0
- contextsynapse/tests/regression/cases/test_match_node_basic.yaml +29 -0
- contextsynapse/tests/regression/cases/test_match_node_with_order_limit.yaml +31 -0
- contextsynapse/tests/regression/cases/test_namespace_switch.yaml +18 -0
- contextsynapse/tests/regression/cases/test_pipeline_cascaded.yaml +61 -0
- contextsynapse/tests/regression/cases/test_pipeline_fixed_size_chunking.yaml +48 -0
- contextsynapse/tests/regression/cases/test_pipeline_gpt4_chunking.yaml +54 -0
- contextsynapse/tests/regression/cases/test_pipeline_hybrid_indexing.yaml +57 -0
- contextsynapse/tests/regression/cases/test_pipeline_large_embedding.yaml +52 -0
- contextsynapse/tests/regression/cases/test_pipeline_paragraph_chunking.yaml +49 -0
- contextsynapse/tests/regression/cases/test_pipeline_recursive_chunking.yaml +47 -0
- contextsynapse/tests/regression/cases/test_pipeline_run_pipeline.yaml +56 -0
- contextsynapse/tests/regression/cases/test_pipeline_section_based_chunking.yaml +53 -0
- contextsynapse/tests/regression/cases/test_pipeline_semantic_chunking.yaml +51 -0
- contextsynapse/tests/regression/cases/test_pipeline_with_entity_extraction.yaml +57 -0
- contextsynapse/tests/regression/cases/test_pipeline_with_relationships.yaml +62 -0
- contextsynapse/tests/regression/cases/test_rag_evaluation.yaml +38 -0
- contextsynapse/tests/regression/cases/test_rag_graph_bfs_with_eval.yaml +48 -0
- contextsynapse/tests/regression/cases/test_rag_graph_centrality.yaml +40 -0
- contextsynapse/tests/regression/cases/test_rag_graph_centrality_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_community.yaml +40 -0
- contextsynapse/tests/regression/cases/test_rag_graph_community_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_dfs_with_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_multi_hop_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_path.yaml +40 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_basic.yaml +38 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_bfs.yaml +52 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_dfs.yaml +41 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_multi_hop.yaml +43 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_shortest_path.yaml +41 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_weighted.yaml +43 -0
- contextsynapse/tests/regression/cases/test_rag_graph_shortest_path_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_traversal.yaml +30 -0
- contextsynapse/tests/regression/cases/test_rag_graph_weighted_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_hybrid_search.yaml +33 -0
- contextsynapse/tests/regression/cases/test_rag_hybrid_with_prompt_embedding.yaml +44 -0
- contextsynapse/tests/regression/cases/test_rag_keyword_search.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_multi_hop_graph.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_path_based.yaml +34 -0
- contextsynapse/tests/regression/cases/test_rag_profile_based.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_vector_search.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_weighted_graph.yaml +34 -0
- contextsynapse/tests/regression/cases/test_rag_weighted_hybrid.yaml +31 -0
- contextsynapse/tests/regression/cases/test_semantic_boundary_chunking.yaml +57 -0
- contextsynapse/tests/regression/cases/test_semantic_boundary_detection.yaml +33 -0
- contextsynapse/tests/regression/cases/test_semantic_hash_function.yaml +30 -0
- contextsynapse/tests/regression/cases/test_semantic_hash_with_model.yaml +31 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_fixed_size.yaml +44 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_recursive.yaml +46 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_section.yaml +46 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_semantic.yaml +44 -0
- contextsynapse/tests/regression/cases/test_time_travel_as_of.yaml +35 -0
- contextsynapse/tests/regression/cases/test_time_travel_at_commit.yaml +33 -0
- contextsynapse/tests/regression/cases/test_time_travel_at_timestamp.yaml +33 -0
- contextsynapse/tests/regression/cases/test_time_travel_for_system_time_all.yaml +37 -0
- contextsynapse/tests/regression/cases/test_time_travel_versions_between.yaml +39 -0
- contextsynapse/tests/regression/cases/test_traverse_basic.yaml +31 -0
- contextsynapse/tests/regression/cases/test_traverse_max_depth.yaml +34 -0
- contextsynapse/tests/regression/cases/test_update_edge_basic.yaml +29 -0
- contextsynapse/tests/regression/evaluation_metrics.py +227 -0
- contextsynapse/tests/regression/regression_runner.py +1081 -0
- contextsynapse/tests/regression/setup_regression_data.py +212 -0
- contextsynapse/tests/regression/test_basic_only.py +84 -0
- contextsynapse/tests/regression/test_namespace_creation.py +29 -0
- contextsynapse/tests/regression/test_single_fix.py +61 -0
- contextsynapse/tests/regression/test_single_query.py +20 -0
- contextsynapse/tests/regression/validate_test_syntax.py +179 -0
- contextsynapse/tests/test_advanced_features.py +347 -0
- contextsynapse/tests/test_aiql_summary.py +283 -0
- contextsynapse/tests/test_all_aiql_queries.py +948 -0
- contextsynapse/tests/test_all_create_queries.py +213 -0
- contextsynapse/tests/test_api_vs_native.py +280 -0
- contextsynapse/tests/test_chunking_dedup.py +199 -0
- contextsynapse/tests/test_connectors.py +171 -0
- contextsynapse/tests/test_edges_and_match.py +202 -0
- contextsynapse/tests/test_extraction_pipeline_integration.py +581 -0
- contextsynapse/tests/test_name_property.py +224 -0
- contextsynapse/tests/test_node_edge_metadata.py +250 -0
- contextsynapse/tests/test_playground_conn.py +114 -0
- contextsynapse/tests/test_query_cache.py +213 -0
- contextsynapse/tests/test_source_policy_auth.py +348 -0
- contextsynapse/tests/test_temporal_and_domain.py +224 -0
- contextsynapse/tests/test_time_travel_e2e.py +571 -0
- contextsynapse/tests/test_traversal_and_aggregation.py +275 -0
- contextsynapse/tools/__init__.py +36 -0
- contextsynapse/tools/a2a_tools.py +106 -0
- contextsynapse/tools/cognition_tools.py +118 -0
- contextsynapse/tools/context.py +905 -0
- contextsynapse/tools/formatting.py +92 -0
- contextsynapse/tools/gateway_tools.py +186 -0
- contextsynapse/tools/graph.py +1700 -0
- contextsynapse/tools/intelligence.py +235 -0
- contextsynapse/tools/memory.py +118 -0
- contextsynapse/tools/registry.py +373 -0
- contextsynapse/tools/shield_tools.py +159 -0
- contextsynapse/tools/tasks.py +341 -0
- contextsynapse/tools/workspace_tools.py +124 -0
- contextsynapse/utils/__init__.py +15 -0
- contextsynapse/utils/config_loader.py +198 -0
- contextsynapse/utils/env_loader.py +156 -0
- contextsynapse/utils/file_utils.py +288 -0
- contextsynapse/utils/logging_optimizer.py +119 -0
- contextsynapse/vector/__init__.py +30 -0
- contextsynapse/vector/chroma_vector_store.py +152 -0
- contextsynapse/vector/custom_vector_store.py +537 -0
- contextsynapse/vector/faiss_vector_store.py +296 -0
- contextsynapse/vector/qdrant_vector_store.py +163 -0
- contextsynapse/vector/vector_db.py +953 -0
- contextsynapse/vector/vector_db_manager.py +197 -0
- contextsynapse/workspace/__init__.py +27 -0
- contextsynapse/workspace/base.py +94 -0
- contextsynapse/workspace/git.py +228 -0
- contextsynapse/workspace/github.py +78 -0
- contextsynapse/workspace/local.py +73 -0
- contextsynapse/workspace/tools.py +222 -0
- contextsynapse-1.0.0.dist-info/METADATA +847 -0
- contextsynapse-1.0.0.dist-info/RECORD +776 -0
- contextsynapse-1.0.0.dist-info/WHEEL +5 -0
- contextsynapse-1.0.0.dist-info/entry_points.txt +4 -0
- contextsynapse-1.0.0.dist-info/licenses/LICENSE +200 -0
- 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
|