android-midscene-automation 0.2.3 → 1.1.0
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.
- package/CHANGELOG.md +11 -1
- package/README.md +63 -1
- package/artemis-main/.dockerignore +15 -0
- package/artemis-main/.env.example +34 -0
- package/artemis-main/CONTRIBUTING.md +35 -0
- package/artemis-main/Dockerfile +68 -0
- package/artemis-main/LICENSE +202 -0
- package/artemis-main/Makefile +115 -0
- package/artemis-main/README.md +322 -0
- package/artemis-main/README_CN.md +320 -0
- package/artemis-main/apps/__init__.py +15 -0
- package/artemis-main/apps/admin_console/__init__.py +19 -0
- package/artemis-main/apps/admin_console/core/__init__.py +13 -0
- package/artemis-main/apps/admin_console/core/config.py +43 -0
- package/artemis-main/apps/admin_console/core/security.py +178 -0
- package/artemis-main/apps/admin_console/core/state.py +243 -0
- package/artemis-main/apps/admin_console/database/__init__.py +13 -0
- package/artemis-main/apps/admin_console/database/connection.py +60 -0
- package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
- package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
- package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
- package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
- package/artemis-main/apps/admin_console/index.html +3787 -0
- package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
- package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
- package/artemis-main/apps/admin_console/routers/media.py +198 -0
- package/artemis-main/apps/admin_console/routers/replay.py +91 -0
- package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
- package/artemis-main/apps/admin_console/routers/steps.py +109 -0
- package/artemis-main/apps/admin_console/routers/stream.py +56 -0
- package/artemis-main/apps/admin_console/routers/system.py +761 -0
- package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
- package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
- package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
- package/artemis-main/apps/admin_console/server.py +508 -0
- package/artemis-main/apps/admin_console/services/__init__.py +13 -0
- package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
- package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
- package/artemis-main/apps/admin_console/services/media_service.py +623 -0
- package/artemis-main/apps/admin_console/services/model_service.py +148 -0
- package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
- package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
- package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
- package/artemis-main/apps/showcase_ui/README.md +31 -0
- package/artemis-main/apps/showcase_ui/angular.json +132 -0
- package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
- package/artemis-main/apps/showcase_ui/package.json +39 -0
- package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
- package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
- package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
- package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
- package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
- package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
- package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
- package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
- package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
- package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
- package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
- package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
- package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
- package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
- package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
- package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
- package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
- package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
- package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
- package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
- package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
- package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
- package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
- package/artemis-main/apps/showcase_ui/src/index.html +37 -0
- package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
- package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
- package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
- package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
- package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
- package/artemis-main/artemis/__init__.py +44 -0
- package/artemis-main/artemis/__main__.py +18 -0
- package/artemis-main/artemis/_legacy_imports.py +94 -0
- package/artemis-main/artemis/_version.py +30 -0
- package/artemis-main/artemis/agents/__init__.py +17 -0
- package/artemis-main/artemis/agents/checker/checker.json +9 -0
- package/artemis-main/artemis/agents/checker/checker.py +859 -0
- package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
- package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
- package/artemis-main/artemis/agents/explorer/constants.py +52 -0
- package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
- package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
- package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
- package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
- package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
- package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
- package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
- package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
- package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
- package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
- package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
- package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
- package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
- package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
- package/artemis-main/artemis/agents/flash/runner.py +1167 -0
- package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
- package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
- package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
- package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
- package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
- package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
- package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
- package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
- package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
- package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
- package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
- package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
- package/artemis-main/artemis/agents/operator/operator.json +3 -0
- package/artemis-main/artemis/agents/operator/operator.py +1394 -0
- package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
- package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
- package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
- package/artemis-main/artemis/agents/planner/planner.json +32 -0
- package/artemis-main/artemis/agents/planner/planner.py +436 -0
- package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
- package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
- package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
- package/artemis-main/artemis/agents/validator/categories.py +33 -0
- package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
- package/artemis-main/artemis/agents/validator/incidents.py +120 -0
- package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
- package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
- package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
- package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
- package/artemis-main/artemis/agents/validator/validator.py +188 -0
- package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
- package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
- package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
- package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
- package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
- package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
- package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
- package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
- package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
- package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
- package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
- package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
- package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
- package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
- package/artemis-main/artemis/clients/accessibility_client.py +438 -0
- package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
- package/artemis-main/artemis/config/__init__.py +309 -0
- package/artemis-main/artemis/config/agent.py +1098 -0
- package/artemis-main/artemis/config/constants.py +151 -0
- package/artemis-main/artemis/config/core/__init__.py +24 -0
- package/artemis-main/artemis/config/core/engine.py +118 -0
- package/artemis-main/artemis/config/core/interpolator.py +78 -0
- package/artemis-main/artemis/config/llm.py +267 -0
- package/artemis-main/artemis/config/paths.py +244 -0
- package/artemis-main/artemis/config/runtime.py +197 -0
- package/artemis-main/artemis/config/settings.py +315 -0
- package/artemis-main/artemis/constants.py +47 -0
- package/artemis-main/artemis/context.py +289 -0
- package/artemis-main/artemis/controllers/__init__.py +41 -0
- package/artemis-main/artemis/controllers/controller_factory.py +41 -0
- package/artemis-main/artemis/controllers/device_controller.py +29 -0
- package/artemis-main/artemis/controllers/types.py +37 -0
- package/artemis-main/artemis/controllers/unified_controller.py +649 -0
- package/artemis-main/artemis/core/__init__.py +21 -0
- package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
- package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
- package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
- package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
- package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
- package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
- package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
- package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
- package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
- package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
- package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
- package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
- package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
- package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
- package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
- package/artemis-main/artemis/core/tool_declaration.py +43 -0
- package/artemis-main/artemis/core/tool_failure.py +56 -0
- package/artemis-main/artemis/data_engine/context_vars.py +24 -0
- package/artemis-main/artemis/data_engine/engine.py +1615 -0
- package/artemis-main/artemis/data_engine/history_reader.py +142 -0
- package/artemis-main/artemis/data_engine/models.py +131 -0
- package/artemis-main/artemis/data_engine/storage.py +1245 -0
- package/artemis-main/artemis/data_engine/trace.py +727 -0
- package/artemis-main/artemis/drivers/__init__.py +48 -0
- package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
- package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
- package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
- package/artemis-main/artemis/drivers/android/recorder.py +57 -0
- package/artemis-main/artemis/drivers/base.py +290 -0
- package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
- package/artemis-main/artemis/drivers/factory.py +80 -0
- package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
- package/artemis-main/artemis/drivers/types.py +67 -0
- package/artemis-main/artemis/graph/checkpoints.py +1243 -0
- package/artemis-main/artemis/graph/graph.py +1055 -0
- package/artemis-main/artemis/graph/perception.py +234 -0
- package/artemis-main/artemis/graph/state.py +151 -0
- package/artemis-main/artemis/graph/visibility.py +238 -0
- package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
- package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
- package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
- package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
- package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
- package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
- package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
- package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
- package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
- package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
- package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
- package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
- package/artemis-main/artemis/interfaces/cli/main.py +109 -0
- package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
- package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
- package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
- package/artemis-main/artemis/llm/__init__.py +25 -0
- package/artemis-main/artemis/llm/fake_model.py +179 -0
- package/artemis-main/artemis/llm/google/__init__.py +57 -0
- package/artemis-main/artemis/llm/google/provider.py +185 -0
- package/artemis-main/artemis/llm/google/usage.py +148 -0
- package/artemis-main/artemis/llm/parser.py +32 -0
- package/artemis-main/artemis/llm/reliability.py +267 -0
- package/artemis-main/artemis/llm/router.py +389 -0
- package/artemis-main/artemis/llm/schemas.py +36 -0
- package/artemis-main/artemis/llm/structured.py +196 -0
- package/artemis-main/artemis/main.py +58 -0
- package/artemis-main/artemis/mcp/__init__.py +20 -0
- package/artemis-main/artemis/mcp/action_executor.py +760 -0
- package/artemis-main/artemis/mcp/action_manifest.py +320 -0
- package/artemis-main/artemis/mcp/action_names.py +172 -0
- package/artemis-main/artemis/mcp/action_server.py +202 -0
- package/artemis-main/artemis/mcp/action_session.py +245 -0
- package/artemis-main/artemis/mcp/action_specs.py +868 -0
- package/artemis-main/artemis/mcp/action_types.py +86 -0
- package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
- package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
- package/artemis-main/artemis/mcp/actuators/base.py +113 -0
- package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
- package/artemis-main/artemis/mcp/adb_server.py +490 -0
- package/artemis-main/artemis/mcp/observation.py +133 -0
- package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
- package/artemis-main/artemis/memory/__init__.py +90 -0
- package/artemis-main/artemis/memory/chunking.py +1843 -0
- package/artemis-main/artemis/memory/context_policy.py +226 -0
- package/artemis-main/artemis/memory/step_capsule.md +73 -0
- package/artemis-main/artemis/memory/step_memory.py +281 -0
- package/artemis-main/artemis/memory/transcript.py +798 -0
- package/artemis-main/artemis/platform/__init__.py +61 -0
- package/artemis-main/artemis/platform/base.py +104 -0
- package/artemis-main/artemis/platform/darwin.py +184 -0
- package/artemis-main/artemis/platform/linux.py +207 -0
- package/artemis-main/artemis/platform/windows.py +193 -0
- package/artemis-main/artemis/resources/__init__.py +46 -0
- package/artemis-main/artemis/runtime/__init__.py +108 -0
- package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
- package/artemis-main/artemis/runtime/awake_lease.py +283 -0
- package/artemis-main/artemis/runtime/awake_service.py +343 -0
- package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
- package/artemis-main/artemis/runtime/daemon_client.py +422 -0
- package/artemis-main/artemis/runtime/device_lock.py +1013 -0
- package/artemis-main/artemis/runtime/device_pool.py +478 -0
- package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
- package/artemis-main/artemis/runtime/process_probe.py +98 -0
- package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
- package/artemis-main/artemis/runtime/supervisor.py +121 -0
- package/artemis-main/artemis/runtime/trace_store.py +375 -0
- package/artemis-main/artemis/sdk/__init__.py +26 -0
- package/artemis-main/artemis/sdk/agent.py +485 -0
- package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
- package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
- package/artemis-main/artemis/sdk/constants.py +15 -0
- package/artemis-main/artemis/sdk/run_outcome.py +58 -0
- package/artemis-main/artemis/sdk/types/__init__.py +61 -0
- package/artemis-main/artemis/sdk/types/agent.py +89 -0
- package/artemis-main/artemis/sdk/utils.py +28 -0
- package/artemis-main/artemis/services/llm.py +1040 -0
- package/artemis-main/artemis/services/token_meter.py +267 -0
- package/artemis-main/artemis/telemetry/__init__.py +35 -0
- package/artemis-main/artemis/telemetry/models.py +64 -0
- package/artemis-main/artemis/telemetry/replay.py +34 -0
- package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
- package/artemis-main/artemis/telemetry/tracer.py +85 -0
- package/artemis-main/artemis/toolchain/__init__.py +52 -0
- package/artemis-main/artemis/toolchain/descriptors.py +92 -0
- package/artemis-main/artemis/toolchain/resolver.py +185 -0
- package/artemis-main/artemis/tools/base.py +232 -0
- package/artemis-main/artemis/tools/command_tool.py +1240 -0
- package/artemis-main/artemis/tools/committee_prompts.json +5 -0
- package/artemis-main/artemis/tools/committee_tool.py +422 -0
- package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
- package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
- package/artemis-main/artemis/tools/explorer_tool.py +581 -0
- package/artemis-main/artemis/tools/history/__init__.py +398 -0
- package/artemis-main/artemis/tools/history/replay.py +120 -0
- package/artemis-main/artemis/tools/history/screenshot.py +139 -0
- package/artemis-main/artemis/tools/history/search.py +563 -0
- package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
- package/artemis-main/artemis/tools/index.py +47 -0
- package/artemis-main/artemis/tools/log_tool.py +108 -0
- package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
- package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
- package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
- package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
- package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
- package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
- package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
- package/artemis-main/artemis/tools/scratchpad.py +819 -0
- package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
- package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
- package/artemis-main/artemis/tools/video_tool.py +261 -0
- package/artemis-main/artemis/tools/wait_tool.py +97 -0
- package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
- package/artemis-main/artemis/utils/coordinates.py +606 -0
- package/artemis-main/artemis/utils/credentials_validator.py +201 -0
- package/artemis-main/artemis/utils/cv_canvas.py +189 -0
- package/artemis-main/artemis/utils/cython_compat.py +45 -0
- package/artemis-main/artemis/utils/element_hit_test.py +80 -0
- package/artemis-main/artemis/utils/image_diff.py +335 -0
- package/artemis-main/artemis/utils/image_hash.py +80 -0
- package/artemis-main/artemis/utils/notes.py +470 -0
- package/artemis-main/artemis/utils/ocr_api.py +105 -0
- package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
- package/artemis-main/artemis/utils/plan_grammar.py +513 -0
- package/artemis-main/artemis/utils/python_executor.py +213 -0
- package/artemis-main/artemis/utils/startup_progress.py +60 -0
- package/artemis-main/artemis/utils/task_tree.py +1174 -0
- package/artemis-main/artemis/utils/text.py +37 -0
- package/artemis-main/artemis/utils/ui_filter.py +651 -0
- package/artemis-main/artemis/utils/video.py +685 -0
- package/artemis-main/artemis/utils/visualization.py +802 -0
- package/artemis-main/config/artemis.example.jsonc +12 -0
- package/artemis-main/mcp_server/README.md +111 -0
- package/artemis-main/mcp_server/__init__.py +21 -0
- package/artemis-main/mcp_server/__main__.py +20 -0
- package/artemis-main/mcp_server/background/__init__.py +15 -0
- package/artemis-main/mcp_server/background/task_runner.py +453 -0
- package/artemis-main/mcp_server/base.py +49 -0
- package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
- package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
- package/artemis-main/mcp_server/notifiers/base.py +45 -0
- package/artemis-main/mcp_server/notifiers/composite.py +87 -0
- package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
- package/artemis-main/mcp_server/notifiers/file.py +69 -0
- package/artemis-main/mcp_server/notifiers/script.py +90 -0
- package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
- package/artemis-main/mcp_server/rules.md +68 -0
- package/artemis-main/mcp_server/server.py +106 -0
- package/artemis-main/mcp_server/tools/__init__.py +29 -0
- package/artemis-main/mcp_server/tools/device_state.py +107 -0
- package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
- package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
- package/artemis-main/mcp_server/tools/task_manager.py +627 -0
- package/artemis-main/mcp_server/tools/task_runner.py +548 -0
- package/artemis-main/mcp_server/utils/__init__.py +15 -0
- package/artemis-main/mcp_server/utils/device_utils.py +180 -0
- package/artemis-main/mcp_server/utils/env_utils.py +65 -0
- package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
- package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
- package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
- package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
- package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
- package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
- package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
- package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
- package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
- package/artemis-main/packages/artemis-client/README.md +112 -0
- package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
- package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
- package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
- package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
- package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
- package/artemis-main/pyproject.toml +259 -0
- package/artemis-main/pyright-core.json +17 -0
- package/artemis-main/scripts/check_dependency_sources.py +68 -0
- package/artemis-main/scripts/install_deps.ps1 +520 -0
- package/artemis-main/scripts/install_deps.sh +533 -0
- package/artemis-main/scripts/quality_ratchet.py +97 -0
- package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
- package/artemis-main/scripts/run_edge_case_part.py +327 -0
- package/artemis-main/scripts/run_ui.py +242 -0
- package/artemis-main/scripts/start.ps1 +500 -0
- package/artemis-main/scripts/test_all_edge_cases.py +269 -0
- package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
- package/artemis-main/setup.py +164 -0
- package/artemis-main/start.bat +35 -0
- package/artemis-main/start.sh +423 -0
- package/artemis-main/third_party/mobile_use/LICENSE +190 -0
- package/artemis-main/third_party/mobile_use/METADATA +27 -0
- package/artemis-main/third_party/mobile_use/NOTICE +12 -0
- package/artemis-main/third_party/mobile_use/__init__.py +19 -0
- package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
- package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
- package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
- package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
- package/artemis-main/third_party/mobile_use/config/output.py +184 -0
- package/artemis-main/third_party/mobile_use/context.py +119 -0
- package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
- package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
- package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
- package/artemis-main/third_party/mobile_use/graph.py +54 -0
- package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
- package/artemis-main/third_party/mobile_use/main.py +130 -0
- package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
- package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
- package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
- package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
- package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
- package/artemis-main/third_party/mobile_use/testing.py +59 -0
- package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
- package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
- package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
- package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
- package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
- package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
- package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
- package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
- package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
- package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
- package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
- package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
- package/artemis-main/uv.lock +4908 -0
- package/bin/android-midscene-automation.js +1 -5
- package/bin/artemis-config.mjs +15 -0
- package/bin/artemis-embedded.py +56 -0
- package/bin/start-artemis.mjs +30 -0
- package/bin/start-dev.mjs +112 -0
- package/bin/test_artemis_embedded.py +44 -0
- package/package.json +10 -8
- package/server/artemis.ts +34 -0
- package/server/http-api.ts +13 -1
- package/src/App.vue +72 -23
- package/src/api.ts +5 -0
- package/src/appium-recorder/AppiumPage.vue +1 -1
- package/src/appium-recorder/components/ComponentInspector.vue +1 -1
- package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
- package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
- package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
- package/src/appium-recorder/flow-appearance.ts +4 -4
- package/src/components/AppSidebar.vue +8 -7
- package/src/components/MidsceneMaintenanceNotice.vue +17 -0
- package/src/components/analytics/AnalysisCharts.vue +4 -4
- package/src/components/analytics/ReportAnalysis.vue +1 -1
- package/src/components/generator/TestCaseFileUpload.vue +1 -1
- package/src/components/help/HelpCenter.vue +56 -37
- package/src/pages/AiGeneratorPage.vue +1 -1
- package/src/pages/ArtemisPage.vue +72 -0
- package/src/pages/ConfigPage.vue +18 -19
- package/src/style.css +369 -186
- package/src/types.ts +1 -1
|
@@ -0,0 +1,2585 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import shutil
|
|
19
|
+
import sqlite3
|
|
20
|
+
import sys
|
|
21
|
+
import traceback
|
|
22
|
+
from typing import Any
|
|
23
|
+
from uuid import UUID
|
|
24
|
+
|
|
25
|
+
WORKSPACE_ROOT = Path(__file__).parent.parent.resolve()
|
|
26
|
+
|
|
27
|
+
# Try to import settings from artemis
|
|
28
|
+
try:
|
|
29
|
+
from artemis.config import settings
|
|
30
|
+
|
|
31
|
+
TRACES_PATH = Path(settings.TRACES_PATH)
|
|
32
|
+
except ImportError:
|
|
33
|
+
# Fallback to default path if run outside project environment or imports fail
|
|
34
|
+
TRACES_PATH = WORKSPACE_ROOT / "traces"
|
|
35
|
+
|
|
36
|
+
DB_PATH = TRACES_PATH / "data_engine.db"
|
|
37
|
+
IMAGES_DIR = TRACES_PATH / "images"
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from artemis.context import ArtemisContext
|
|
41
|
+
from artemis.graph.state import State
|
|
42
|
+
except ImportError:
|
|
43
|
+
ArtemisContext = Any
|
|
44
|
+
State = Any
|
|
45
|
+
|
|
46
|
+
REPLAY_TOOLS_CONFIG = {
|
|
47
|
+
"ask_explorer": {
|
|
48
|
+
"display_name": "Ask Explorer",
|
|
49
|
+
"description": (
|
|
50
|
+
"Visual parsing agent that executes visual ReAct search loops using multi-modal models."
|
|
51
|
+
),
|
|
52
|
+
"module": "artemis.tools.explorer_tool",
|
|
53
|
+
"function": "_run_explorer_logic",
|
|
54
|
+
"agent_name": "explorer",
|
|
55
|
+
"denylist_args": ["ctx", "state"],
|
|
56
|
+
"fallback_mappings": {},
|
|
57
|
+
"llm_span_name": "gemini_explorer_call",
|
|
58
|
+
},
|
|
59
|
+
"ask_image_processor": {
|
|
60
|
+
"display_name": "Ask Image Processor",
|
|
61
|
+
"description": (
|
|
62
|
+
"Visual parsing agent that writes Python code using OpenCV and"
|
|
63
|
+
" computer vision to analyze and modify screen images."
|
|
64
|
+
),
|
|
65
|
+
"module": "artemis.tools.image_processor_tool",
|
|
66
|
+
"function": "_run_image_processor_logic",
|
|
67
|
+
"agent_name": "image_processor",
|
|
68
|
+
"denylist_args": ["ctx", "state"],
|
|
69
|
+
"fallback_mappings": {},
|
|
70
|
+
"llm_span_name": "gemini_image_processor_call",
|
|
71
|
+
},
|
|
72
|
+
# Future tools can easily be registered here using plain dictionaries:
|
|
73
|
+
# "ask_validator": {
|
|
74
|
+
# "display_name": "Ask Validator",
|
|
75
|
+
# "description": "Validation agent that checks if a step or action was completed successfully.",
|
|
76
|
+
# "module": "artemis.tools.validator_tool",
|
|
77
|
+
# ...
|
|
78
|
+
# }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ReplayManager:
|
|
83
|
+
"""Manages sandboxed execution and diagnostics of visual agent steps.
|
|
84
|
+
|
|
85
|
+
The ReplayManager is the core orchestrator for the Artemis step replay system.
|
|
86
|
+
It reconstructs historical execution states (SQLite database records, trace
|
|
87
|
+
histories, and screenshots) and replays a single agent step in an isolated,
|
|
88
|
+
deterministic sandbox environment while interacting live with a physical
|
|
89
|
+
Android device and Gemini.
|
|
90
|
+
|
|
91
|
+
Key Responsibilities:
|
|
92
|
+
- Reconstructs and preloads historical SQLite states up to a specific
|
|
93
|
+
step.
|
|
94
|
+
- Establishes sandboxed `ArtemisContext` and `DataEngine` instances.
|
|
95
|
+
- Integrates with physical devices via ADB and UIAutomator.
|
|
96
|
+
- Hooks and spies on the Gemini API to capture thoughts and raw outputs.
|
|
97
|
+
- Reconciles live and preloaded traces into unified chronological trees.
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
workspace_root (Path): The absolute path to the root of the project
|
|
101
|
+
workspace.
|
|
102
|
+
replay_base_dir (Path): Base directory containing replay data and
|
|
103
|
+
outputs.
|
|
104
|
+
test_data_dir (Path): Directory containing chunked historical step
|
|
105
|
+
traces.
|
|
106
|
+
test_outputs_dir (Path): Sandbox directory where replay outputs are
|
|
107
|
+
written.
|
|
108
|
+
db_path (Path): Path to the master Data Engine SQLite database.
|
|
109
|
+
traces_path (Path): Path to the master traces directory.
|
|
110
|
+
images_dir (Path): Path to the master images directory.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
workspace_root: Path = WORKSPACE_ROOT,
|
|
116
|
+
device_id: str = None,
|
|
117
|
+
original_db_path: Path = None,
|
|
118
|
+
init_device: bool = False,
|
|
119
|
+
):
|
|
120
|
+
"""Initializes the ReplayManager and autodetects the connected Android device.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
workspace_root (Path, optional): The absolute path to the workspace
|
|
124
|
+
root. Defaults to WORKSPACE_ROOT.
|
|
125
|
+
device_id (str, optional): The serial ID of the target Android
|
|
126
|
+
device. If not specified, autodetects the first connected device.
|
|
127
|
+
original_db_path (Path, optional): Path to the master SQLite
|
|
128
|
+
database. Defaults to DB_PATH.
|
|
129
|
+
init_device (bool, optional): Whether to initialize the ADB device
|
|
130
|
+
connection on startup.
|
|
131
|
+
"""
|
|
132
|
+
self.workspace_root = workspace_root
|
|
133
|
+
self.traces_path = TRACES_PATH
|
|
134
|
+
self.replay_base_dir = self.traces_path / "replay"
|
|
135
|
+
self.test_data_dir = self.replay_base_dir / "data"
|
|
136
|
+
self.test_outputs_dir = self.replay_base_dir / "outputs"
|
|
137
|
+
self.db_path = Path(original_db_path) if original_db_path else DB_PATH
|
|
138
|
+
self.images_dir = IMAGES_DIR
|
|
139
|
+
|
|
140
|
+
# Device connection and autodetection
|
|
141
|
+
self.device_id = None
|
|
142
|
+
self.w = None
|
|
143
|
+
self.h = None
|
|
144
|
+
self.adb = None
|
|
145
|
+
self.ui_client = None
|
|
146
|
+
|
|
147
|
+
if init_device:
|
|
148
|
+
try:
|
|
149
|
+
self._init_device(device_id)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
print(
|
|
152
|
+
"Warning: Connected device could not be initialized during"
|
|
153
|
+
f" ReplayManager startup: {e}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _init_device(self, device_id: str = None):
|
|
157
|
+
"""Initializes connection to the target device and queries screen metrics."""
|
|
158
|
+
from adbutils import AdbClient
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
from third_party.mobile_use.clients.ui_automator_client import UIAutomatorClient
|
|
162
|
+
except ImportError:
|
|
163
|
+
raise ImportError(
|
|
164
|
+
"Failed to import UIAutomatorClient. Ensure artemis package is installed in path."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
self.adb = AdbClient(host="localhost", port=5037)
|
|
168
|
+
try:
|
|
169
|
+
devices = self.adb.device_list()
|
|
170
|
+
except Exception as adb_err:
|
|
171
|
+
raise ConnectionError(
|
|
172
|
+
f"Failed to query device list from ADB server: {adb_err}"
|
|
173
|
+
) from adb_err
|
|
174
|
+
|
|
175
|
+
if not devices:
|
|
176
|
+
raise ConnectionError(
|
|
177
|
+
"No active ADB devices connected. Please connect an Android device via ADB."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if device_id:
|
|
181
|
+
matched_device = next((d.serial for d in devices if d.serial == device_id), None)
|
|
182
|
+
if not matched_device:
|
|
183
|
+
raise ConnectionError(
|
|
184
|
+
f"Requested device '{device_id}' is not connected."
|
|
185
|
+
f" Connected devices: {[d.serial for d in devices]}"
|
|
186
|
+
)
|
|
187
|
+
self.device_id = matched_device
|
|
188
|
+
else:
|
|
189
|
+
self.device_id = devices[0].serial
|
|
190
|
+
|
|
191
|
+
self.ui_client = UIAutomatorClient(device_id=self.device_id)
|
|
192
|
+
ui_data = self.ui_client.get_screen_data()
|
|
193
|
+
self.w, self.h = ui_data.width, ui_data.height
|
|
194
|
+
print(f"Connected to device: {self.device_id} ({self.w}x{self.h})")
|
|
195
|
+
|
|
196
|
+
def chunk_session_traces(self, session_id: str, output_dir: Path = None) -> Path:
|
|
197
|
+
"""Chunks traces, screenshots, and metadata for a specific execution session.
|
|
198
|
+
|
|
199
|
+
Queries the master SQLite database and exports step-by-step
|
|
200
|
+
subdirectories
|
|
201
|
+
(e.g., `step_00/`, `step_01/`) inside the output directory.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
session_id: The UUID string of the session to extract and chunk.
|
|
205
|
+
output_dir: Optional path to the directory where chunked results
|
|
206
|
+
should be saved. Defaults to `self.test_data_dir /
|
|
207
|
+
f"{session_id}_chunked"`.
|
|
208
|
+
"""
|
|
209
|
+
import sqlite3
|
|
210
|
+
import json
|
|
211
|
+
import shutil
|
|
212
|
+
|
|
213
|
+
db_path = self.db_path
|
|
214
|
+
traces_dir = self.traces_path
|
|
215
|
+
|
|
216
|
+
if not db_path.exists():
|
|
217
|
+
raise FileNotFoundError(f"Master database not found at {db_path}")
|
|
218
|
+
|
|
219
|
+
if not output_dir:
|
|
220
|
+
output_dir = self.test_data_dir / f"{session_id}_chunked"
|
|
221
|
+
else:
|
|
222
|
+
output_dir = Path(output_dir)
|
|
223
|
+
|
|
224
|
+
if output_dir.exists() or output_dir.is_symlink():
|
|
225
|
+
# Safety check: only delete directories that are named *_chunked
|
|
226
|
+
if output_dir.name.endswith("_chunked"):
|
|
227
|
+
if output_dir.is_symlink():
|
|
228
|
+
output_dir.unlink()
|
|
229
|
+
elif (
|
|
230
|
+
self.test_data_dir in output_dir.parents
|
|
231
|
+
or output_dir.parent == self.test_data_dir
|
|
232
|
+
or output_dir.parent.name == "inputs"
|
|
233
|
+
):
|
|
234
|
+
shutil.rmtree(output_dir)
|
|
235
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
print(f"Chunking traces for session: {session_id}")
|
|
237
|
+
print(f"Using database: {db_path}")
|
|
238
|
+
print(f"Output directory: {output_dir}")
|
|
239
|
+
|
|
240
|
+
conn = sqlite3.connect(db_path)
|
|
241
|
+
conn.row_factory = sqlite3.Row
|
|
242
|
+
cursor = conn.cursor()
|
|
243
|
+
|
|
244
|
+
cursor.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,))
|
|
245
|
+
session_row = cursor.fetchone()
|
|
246
|
+
|
|
247
|
+
cursor.execute(
|
|
248
|
+
"SELECT * FROM steps WHERE session_id = ? ORDER BY step_number ASC",
|
|
249
|
+
(session_id,),
|
|
250
|
+
)
|
|
251
|
+
steps = cursor.fetchall()
|
|
252
|
+
print(f"Found {len(steps)} steps for session {session_id}")
|
|
253
|
+
|
|
254
|
+
if not session_row and not steps:
|
|
255
|
+
conn.close()
|
|
256
|
+
raise ValueError(f"Session {session_id} not found in database.")
|
|
257
|
+
|
|
258
|
+
for step in steps:
|
|
259
|
+
step_number = step["step_number"]
|
|
260
|
+
step_id = step["step_id"]
|
|
261
|
+
step_dir = output_dir / f"step_{step_number:02d}"
|
|
262
|
+
step_dir.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
|
|
264
|
+
# 1. Process and save the step's primary metadata (step.json)
|
|
265
|
+
step_data = {}
|
|
266
|
+
for key in step.keys():
|
|
267
|
+
val = step[key]
|
|
268
|
+
if (
|
|
269
|
+
key
|
|
270
|
+
in (
|
|
271
|
+
"action_taken",
|
|
272
|
+
"last_execution_result",
|
|
273
|
+
"extra_metadata",
|
|
274
|
+
)
|
|
275
|
+
and val
|
|
276
|
+
):
|
|
277
|
+
try:
|
|
278
|
+
step_data[key] = json.loads(val)
|
|
279
|
+
except Exception:
|
|
280
|
+
step_data[key] = val
|
|
281
|
+
else:
|
|
282
|
+
step_data[key] = val
|
|
283
|
+
|
|
284
|
+
step_json_path = step_dir / "step.json"
|
|
285
|
+
with open(step_json_path, "w", encoding="utf-8") as f:
|
|
286
|
+
json.dump(step_data, f, indent=2, ensure_ascii=False)
|
|
287
|
+
|
|
288
|
+
# 2. Retrieve and save trace logs recorded during this step (traces.json)
|
|
289
|
+
cursor.execute(
|
|
290
|
+
"SELECT * FROM traces WHERE step_id = ? ORDER BY timestamp ASC",
|
|
291
|
+
(step_id,),
|
|
292
|
+
)
|
|
293
|
+
traces = cursor.fetchall()
|
|
294
|
+
|
|
295
|
+
traces_data = []
|
|
296
|
+
for trace_row in traces:
|
|
297
|
+
trace_dict = {}
|
|
298
|
+
for key in trace_row.keys():
|
|
299
|
+
val = trace_row[key]
|
|
300
|
+
if key == "payload" and val:
|
|
301
|
+
try:
|
|
302
|
+
trace_dict[key] = json.loads(val)
|
|
303
|
+
except Exception:
|
|
304
|
+
trace_dict[key] = val
|
|
305
|
+
else:
|
|
306
|
+
trace_dict[key] = val
|
|
307
|
+
traces_data.append(trace_dict)
|
|
308
|
+
|
|
309
|
+
traces_json_path = step_dir / "traces.json"
|
|
310
|
+
with open(traces_json_path, "w", encoding="utf-8") as f:
|
|
311
|
+
json.dump(traces_data, f, indent=2, ensure_ascii=False)
|
|
312
|
+
|
|
313
|
+
# 3. Copy the step's screenshots (pre-action and post-action screenshots)
|
|
314
|
+
pre_image_name = step_data.get("pre_image_name")
|
|
315
|
+
post_image_name = step_data.get("post_image_name")
|
|
316
|
+
if pre_image_name and post_image_name == pre_image_name:
|
|
317
|
+
post_image_name = None
|
|
318
|
+
images_dir = traces_dir / "images"
|
|
319
|
+
|
|
320
|
+
if pre_image_name:
|
|
321
|
+
src_pre = images_dir / f"{pre_image_name}.jpg"
|
|
322
|
+
if src_pre.exists():
|
|
323
|
+
shutil.copy2(src_pre, step_dir / "pre.jpg")
|
|
324
|
+
else:
|
|
325
|
+
print(f"Warning: Pre-action screenshot not found at {src_pre}")
|
|
326
|
+
|
|
327
|
+
if post_image_name:
|
|
328
|
+
src_post = images_dir / f"{post_image_name}.jpg"
|
|
329
|
+
if src_post.exists():
|
|
330
|
+
shutil.copy2(src_post, step_dir / "post.jpg")
|
|
331
|
+
else:
|
|
332
|
+
print(f"Warning: Post-action screenshot not found at {src_post}")
|
|
333
|
+
|
|
334
|
+
# 4. Save metadata of the pre-action screenshot (pre_image_meta.json)
|
|
335
|
+
if pre_image_name:
|
|
336
|
+
cursor.execute(
|
|
337
|
+
"SELECT * FROM images WHERE image_name = ?",
|
|
338
|
+
(pre_image_name,),
|
|
339
|
+
)
|
|
340
|
+
img_row = cursor.fetchone()
|
|
341
|
+
if img_row:
|
|
342
|
+
img_meta = {}
|
|
343
|
+
for key in img_row.keys():
|
|
344
|
+
val = img_row[key]
|
|
345
|
+
if key in ("ocr_result", "ui_tree", "extra_metadata") and val:
|
|
346
|
+
try:
|
|
347
|
+
img_meta[key] = json.loads(val)
|
|
348
|
+
except Exception:
|
|
349
|
+
img_meta[key] = val
|
|
350
|
+
else:
|
|
351
|
+
img_meta[key] = val
|
|
352
|
+
|
|
353
|
+
pre_img_meta_path = step_dir / "pre_image_meta.json"
|
|
354
|
+
with open(pre_img_meta_path, "w", encoding="utf-8") as f:
|
|
355
|
+
json.dump(img_meta, f, indent=2, ensure_ascii=False)
|
|
356
|
+
else:
|
|
357
|
+
print(
|
|
358
|
+
f"Warning: Image metadata row for {pre_image_name} not"
|
|
359
|
+
" found in images table."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# 5. Save metadata of the post-action screenshot (post_image_meta.json)
|
|
363
|
+
if post_image_name:
|
|
364
|
+
cursor.execute(
|
|
365
|
+
"SELECT * FROM images WHERE image_name = ?",
|
|
366
|
+
(post_image_name,),
|
|
367
|
+
)
|
|
368
|
+
img_row = cursor.fetchone()
|
|
369
|
+
if img_row:
|
|
370
|
+
img_meta = {}
|
|
371
|
+
for key in img_row.keys():
|
|
372
|
+
val = img_row[key]
|
|
373
|
+
if key in ("ocr_result", "ui_tree", "extra_metadata") and val:
|
|
374
|
+
try:
|
|
375
|
+
img_meta[key] = json.loads(val)
|
|
376
|
+
except Exception:
|
|
377
|
+
img_meta[key] = val
|
|
378
|
+
else:
|
|
379
|
+
img_meta[key] = val
|
|
380
|
+
|
|
381
|
+
post_img_meta_path = step_dir / "post_image_meta.json"
|
|
382
|
+
with open(post_img_meta_path, "w", encoding="utf-8") as f:
|
|
383
|
+
json.dump(img_meta, f, indent=2, ensure_ascii=False)
|
|
384
|
+
else:
|
|
385
|
+
print(
|
|
386
|
+
f"Warning: Image metadata row for {post_image_name} not"
|
|
387
|
+
" found in images table."
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
conn.close()
|
|
391
|
+
# Write sentinel file to indicate successful chunking
|
|
392
|
+
try:
|
|
393
|
+
(output_dir / ".chunked").touch()
|
|
394
|
+
except Exception as e:
|
|
395
|
+
print(f"Warning: Failed to write chunked sentinel file: {e}")
|
|
396
|
+
print("Chunking trace completion status: success")
|
|
397
|
+
return output_dir
|
|
398
|
+
|
|
399
|
+
def list_devices(self) -> list[dict]:
|
|
400
|
+
"""Dynamically queries the ADB server for connected Android devices."""
|
|
401
|
+
try:
|
|
402
|
+
from adbutils import AdbClient
|
|
403
|
+
|
|
404
|
+
adb = AdbClient(host="localhost", port=5037)
|
|
405
|
+
devices = adb.device_list()
|
|
406
|
+
return [{"serial": d.serial, "status": "online"} for d in devices]
|
|
407
|
+
except Exception as e:
|
|
408
|
+
print(f"Warning: Failed to query device list from ADB: {e}")
|
|
409
|
+
return []
|
|
410
|
+
|
|
411
|
+
def load_session_goal(
|
|
412
|
+
self, session_id: str, step_dir: Path, original_db_path: str = None
|
|
413
|
+
) -> str:
|
|
414
|
+
"""Loads the session goal from either session.json or the original session db."""
|
|
415
|
+
session_json_path = step_dir.parent / "session.json"
|
|
416
|
+
if session_json_path.exists():
|
|
417
|
+
try:
|
|
418
|
+
with open(session_json_path, encoding="utf-8") as f:
|
|
419
|
+
data = json.load(f)
|
|
420
|
+
if isinstance(data, dict) and "initial_goal" in data and data["initial_goal"]:
|
|
421
|
+
return data["initial_goal"]
|
|
422
|
+
except Exception as e:
|
|
423
|
+
print(f"Warning: Failed to load goal from session.json: {e}")
|
|
424
|
+
|
|
425
|
+
if original_db_path and Path(original_db_path).exists():
|
|
426
|
+
try:
|
|
427
|
+
conn = sqlite3.connect(original_db_path)
|
|
428
|
+
conn.row_factory = sqlite3.Row
|
|
429
|
+
cursor = conn.cursor()
|
|
430
|
+
cursor.execute(
|
|
431
|
+
"SELECT initial_goal FROM sessions WHERE session_id = ?",
|
|
432
|
+
(str(session_id),),
|
|
433
|
+
)
|
|
434
|
+
row = cursor.fetchone()
|
|
435
|
+
conn.close()
|
|
436
|
+
if row and row["initial_goal"]:
|
|
437
|
+
return row["initial_goal"]
|
|
438
|
+
except Exception as e:
|
|
439
|
+
print(f"Warning: Failed to load goal from original database: {e}")
|
|
440
|
+
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"Could not load initial_goal for session {session_id} from"
|
|
443
|
+
f" session.json or original database {original_db_path}."
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def extract_replay_data(
|
|
447
|
+
self,
|
|
448
|
+
step_dir,
|
|
449
|
+
original_db_path=None,
|
|
450
|
+
agent_name="explorer",
|
|
451
|
+
tool_name="ask_explorer",
|
|
452
|
+
):
|
|
453
|
+
"""Extracts final answer and chronological tool call sequence from traces.json/db."""
|
|
454
|
+
step_json_path = step_dir / "step.json"
|
|
455
|
+
if not step_json_path.exists():
|
|
456
|
+
raise FileNotFoundError(f"step.json not found in {step_dir}")
|
|
457
|
+
|
|
458
|
+
with open(step_json_path, encoding="utf-8") as f:
|
|
459
|
+
step_data = json.load(f)
|
|
460
|
+
step_id = step_data["step_id"]
|
|
461
|
+
|
|
462
|
+
traces_json_path = step_dir / "traces.json"
|
|
463
|
+
traces = []
|
|
464
|
+
if traces_json_path.exists():
|
|
465
|
+
with open(traces_json_path, encoding="utf-8") as f:
|
|
466
|
+
traces = json.load(f)
|
|
467
|
+
|
|
468
|
+
# If traces.json is empty/missing or lacks agent trace, check original DB
|
|
469
|
+
agent_trace = next(
|
|
470
|
+
(t for t in traces if t.get("type") == "agent" and t.get("name") == agent_name),
|
|
471
|
+
None,
|
|
472
|
+
)
|
|
473
|
+
if (not agent_trace or not traces) and original_db_path and Path(original_db_path).exists():
|
|
474
|
+
conn = sqlite3.connect(original_db_path)
|
|
475
|
+
conn.row_factory = sqlite3.Row
|
|
476
|
+
cursor = conn.cursor()
|
|
477
|
+
cursor.execute(
|
|
478
|
+
"SELECT * FROM traces WHERE step_id = ? ORDER BY timestamp ASC",
|
|
479
|
+
(step_id,),
|
|
480
|
+
)
|
|
481
|
+
rows = cursor.fetchall()
|
|
482
|
+
traces = []
|
|
483
|
+
for r in rows:
|
|
484
|
+
td = dict(r)
|
|
485
|
+
if td.get("payload"):
|
|
486
|
+
try:
|
|
487
|
+
td["payload"] = json.loads(td["payload"])
|
|
488
|
+
except (ValueError, TypeError):
|
|
489
|
+
# Non-JSON payload: keep the raw string.
|
|
490
|
+
pass
|
|
491
|
+
traces.append(td)
|
|
492
|
+
conn.close()
|
|
493
|
+
agent_trace = next(
|
|
494
|
+
(t for t in traces if t.get("type") == "agent" and t.get("name") == agent_name),
|
|
495
|
+
None,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
if not agent_trace:
|
|
499
|
+
print(
|
|
500
|
+
f"Warning: {agent_name} agent trace not found. Replay will"
|
|
501
|
+
" fallback to direct submit_answer."
|
|
502
|
+
)
|
|
503
|
+
return (
|
|
504
|
+
[],
|
|
505
|
+
{
|
|
506
|
+
"candidates": [],
|
|
507
|
+
"fallback_message": f"No {agent_name} trace found.",
|
|
508
|
+
},
|
|
509
|
+
traces,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
payload = agent_trace.get("payload") or {}
|
|
513
|
+
result_str = payload.get("result", "{}")
|
|
514
|
+
if isinstance(result_str, str):
|
|
515
|
+
try:
|
|
516
|
+
final_submit_args = json.loads(result_str)
|
|
517
|
+
except Exception:
|
|
518
|
+
final_submit_args = {
|
|
519
|
+
"candidates": [],
|
|
520
|
+
"fallback_message": result_str,
|
|
521
|
+
}
|
|
522
|
+
else:
|
|
523
|
+
final_submit_args = result_str
|
|
524
|
+
|
|
525
|
+
# Build sequence of intermediate tool calls under the agent trace
|
|
526
|
+
agent_trace_id = agent_trace["trace_id"]
|
|
527
|
+
|
|
528
|
+
def get_descendants(pid):
|
|
529
|
+
child_list = []
|
|
530
|
+
for t in traces:
|
|
531
|
+
if t.get("parent_trace_id") == pid:
|
|
532
|
+
child_list.append(t)
|
|
533
|
+
child_list.extend(get_descendants(t["trace_id"]))
|
|
534
|
+
return child_list
|
|
535
|
+
|
|
536
|
+
descendants = get_descendants(agent_trace_id)
|
|
537
|
+
descendants.sort(key=lambda x: x.get("timestamp", 0))
|
|
538
|
+
|
|
539
|
+
tool_calls_sequence = []
|
|
540
|
+
for t in descendants:
|
|
541
|
+
if t.get("type") == "tool" and t.get("name") not in (
|
|
542
|
+
tool_name,
|
|
543
|
+
"submit_answer",
|
|
544
|
+
):
|
|
545
|
+
t_payload = t.get("payload") or {}
|
|
546
|
+
t_args = t_payload.get("args") or {}
|
|
547
|
+
tool_calls_sequence.append({"name": t.get("name"), "args": t_args})
|
|
548
|
+
|
|
549
|
+
# Preload traces up to the point when agent is called
|
|
550
|
+
traces_to_preload = []
|
|
551
|
+
if agent_trace in traces:
|
|
552
|
+
idx = traces.index(agent_trace)
|
|
553
|
+
traces_to_preload = traces[:idx]
|
|
554
|
+
else:
|
|
555
|
+
agent_ts = agent_trace.get("timestamp", 0.0)
|
|
556
|
+
traces_to_preload = [t for t in traces if t.get("timestamp", 0.0) < agent_ts]
|
|
557
|
+
|
|
558
|
+
return tool_calls_sequence, final_submit_args, traces_to_preload
|
|
559
|
+
|
|
560
|
+
def resolve_step_data_path(self, data: dict, path: str):
|
|
561
|
+
"""Resolves a value from a nested dict/list using a dot-separated path (e.g.
|
|
562
|
+
|
|
563
|
+
'action_taken.0.target_text').
|
|
564
|
+
"""
|
|
565
|
+
parts = path.split(".")
|
|
566
|
+
curr = data
|
|
567
|
+
for p in parts:
|
|
568
|
+
if isinstance(curr, dict):
|
|
569
|
+
curr = curr.get(p)
|
|
570
|
+
elif isinstance(curr, list):
|
|
571
|
+
try:
|
|
572
|
+
idx = int(p)
|
|
573
|
+
if idx < len(curr):
|
|
574
|
+
curr = curr[idx]
|
|
575
|
+
else:
|
|
576
|
+
return None
|
|
577
|
+
except ValueError:
|
|
578
|
+
return None
|
|
579
|
+
else:
|
|
580
|
+
return None
|
|
581
|
+
return curr
|
|
582
|
+
|
|
583
|
+
def setup_temporary_database(
|
|
584
|
+
self,
|
|
585
|
+
temp_db_path,
|
|
586
|
+
temp_traces_dir,
|
|
587
|
+
session_id,
|
|
588
|
+
step_data,
|
|
589
|
+
pre_image_meta,
|
|
590
|
+
initial_goal,
|
|
591
|
+
traces_to_preload=None,
|
|
592
|
+
post_image_meta=None,
|
|
593
|
+
replay_id=None,
|
|
594
|
+
):
|
|
595
|
+
"""Initializes a sandbox SQLite database with preloaded step and screen metadata."""
|
|
596
|
+
if not initial_goal:
|
|
597
|
+
raise ValueError("initial_goal must be provided and cannot be empty.")
|
|
598
|
+
|
|
599
|
+
if temp_db_path.exists():
|
|
600
|
+
try:
|
|
601
|
+
temp_db_path.unlink()
|
|
602
|
+
except OSError:
|
|
603
|
+
pass
|
|
604
|
+
|
|
605
|
+
if temp_traces_dir.exists():
|
|
606
|
+
shutil.rmtree(temp_traces_dir)
|
|
607
|
+
temp_traces_dir.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
from artemis.data_engine.storage import StorageManager
|
|
611
|
+
from artemis.data_engine.models import (
|
|
612
|
+
SessionMetadata,
|
|
613
|
+
ImageRecord,
|
|
614
|
+
StepRecord,
|
|
615
|
+
TraceRecord,
|
|
616
|
+
)
|
|
617
|
+
except ImportError:
|
|
618
|
+
raise ImportError(
|
|
619
|
+
"Failed to import Artemis Data Engine modules. Ensure you are"
|
|
620
|
+
" running in the correct virtual environment."
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
storage = StorageManager(temp_db_path, temp_traces_dir)
|
|
624
|
+
|
|
625
|
+
# Preload session metadata
|
|
626
|
+
session_meta = SessionMetadata(
|
|
627
|
+
session_id=UUID(session_id) if isinstance(session_id, str) else session_id,
|
|
628
|
+
initial_goal=initial_goal,
|
|
629
|
+
status="running",
|
|
630
|
+
)
|
|
631
|
+
storage.create_session(session_meta)
|
|
632
|
+
|
|
633
|
+
if replay_id:
|
|
634
|
+
replay_session_meta = SessionMetadata(
|
|
635
|
+
session_id=UUID(replay_id) if isinstance(replay_id, str) else replay_id,
|
|
636
|
+
initial_goal=initial_goal,
|
|
637
|
+
status="running",
|
|
638
|
+
)
|
|
639
|
+
storage.create_session(replay_session_meta)
|
|
640
|
+
|
|
641
|
+
# Preload image metadata
|
|
642
|
+
if pre_image_meta:
|
|
643
|
+
image_record = ImageRecord(
|
|
644
|
+
image_name=pre_image_meta["image_name"],
|
|
645
|
+
timestamp=pre_image_meta.get("timestamp", 0.0),
|
|
646
|
+
ocr_result=pre_image_meta.get("ocr_result"),
|
|
647
|
+
ui_tree=pre_image_meta.get("ui_tree"),
|
|
648
|
+
extra_metadata=pre_image_meta.get("extra_metadata", {}),
|
|
649
|
+
)
|
|
650
|
+
storage.create_image(image_record)
|
|
651
|
+
|
|
652
|
+
if post_image_meta:
|
|
653
|
+
image_record = ImageRecord(
|
|
654
|
+
image_name=post_image_meta["image_name"],
|
|
655
|
+
timestamp=post_image_meta.get("timestamp", 0.0),
|
|
656
|
+
ocr_result=post_image_meta.get("ocr_result"),
|
|
657
|
+
ui_tree=post_image_meta.get("ui_tree"),
|
|
658
|
+
extra_metadata=post_image_meta.get("extra_metadata", {}),
|
|
659
|
+
)
|
|
660
|
+
storage.create_image(image_record)
|
|
661
|
+
|
|
662
|
+
# Preload step metadata
|
|
663
|
+
step_record = StepRecord(
|
|
664
|
+
step_id=step_data["step_id"],
|
|
665
|
+
session_id=step_data["session_id"],
|
|
666
|
+
step_number=step_data["step_number"],
|
|
667
|
+
timestamp=step_data.get("timestamp", 0.0),
|
|
668
|
+
pre_image_name=step_data.get("pre_image_name"),
|
|
669
|
+
post_image_name=step_data.get("post_image_name"),
|
|
670
|
+
summary=step_data.get("summary"),
|
|
671
|
+
action_taken=step_data.get("action_taken"),
|
|
672
|
+
operator_raw_thinking=step_data.get("operator_raw_thinking"),
|
|
673
|
+
operator_native_thinking=step_data.get("operator_native_thinking"),
|
|
674
|
+
last_execution_result=step_data.get("last_execution_result"),
|
|
675
|
+
extra_metadata=step_data.get("extra_metadata", {}),
|
|
676
|
+
)
|
|
677
|
+
storage.create_step(step_record)
|
|
678
|
+
|
|
679
|
+
# Preload trace history
|
|
680
|
+
if traces_to_preload:
|
|
681
|
+
for t in traces_to_preload:
|
|
682
|
+
try:
|
|
683
|
+
payload_val = t.get("payload")
|
|
684
|
+
if isinstance(payload_val, str):
|
|
685
|
+
try:
|
|
686
|
+
payload_val = json.loads(payload_val)
|
|
687
|
+
except Exception:
|
|
688
|
+
payload_val = {}
|
|
689
|
+
elif not isinstance(payload_val, dict):
|
|
690
|
+
payload_val = {}
|
|
691
|
+
|
|
692
|
+
trace_rec = TraceRecord(
|
|
693
|
+
trace_id=UUID(t["trace_id"])
|
|
694
|
+
if isinstance(t["trace_id"], str)
|
|
695
|
+
else t["trace_id"],
|
|
696
|
+
session_id=UUID(t["session_id"])
|
|
697
|
+
if isinstance(t["session_id"], str)
|
|
698
|
+
else t["session_id"],
|
|
699
|
+
step_id=UUID(t["step_id"]) if t.get("step_id") else None,
|
|
700
|
+
parent_trace_id=UUID(t["parent_trace_id"])
|
|
701
|
+
if t.get("parent_trace_id")
|
|
702
|
+
else None,
|
|
703
|
+
type=t["type"],
|
|
704
|
+
name=t["name"],
|
|
705
|
+
timestamp=t.get("timestamp", 0.0),
|
|
706
|
+
duration=t.get("duration"),
|
|
707
|
+
status=t.get("status", "success"),
|
|
708
|
+
payload=payload_val,
|
|
709
|
+
)
|
|
710
|
+
storage.create_trace(trace_rec)
|
|
711
|
+
except Exception as ex:
|
|
712
|
+
print(f"Warning: Failed to preload trace {t.get('trace_id')}: {ex}")
|
|
713
|
+
|
|
714
|
+
return storage
|
|
715
|
+
|
|
716
|
+
def get_preloaded_trace_ids_for_step(self, session_id: str, step_num: int) -> set:
|
|
717
|
+
traces_json_path = (
|
|
718
|
+
self.test_data_dir / f"{session_id}_chunked" / f"step_{step_num:02d}" / "traces.json"
|
|
719
|
+
)
|
|
720
|
+
if traces_json_path.exists():
|
|
721
|
+
try:
|
|
722
|
+
with open(traces_json_path, encoding="utf-8") as f:
|
|
723
|
+
traces = json.load(f)
|
|
724
|
+
return {str(t.get("trace_id")).lower() for t in traces if t.get("trace_id")}
|
|
725
|
+
except (OSError, ValueError) as e:
|
|
726
|
+
print(
|
|
727
|
+
f"Warning: Failed to read preloaded trace ids from {traces_json_path}: {e}",
|
|
728
|
+
file=sys.stderr,
|
|
729
|
+
)
|
|
730
|
+
return set()
|
|
731
|
+
|
|
732
|
+
def load_traces_from_db(self, db_path: Path, step_num: int, preloaded_trace_ids: set) -> list:
|
|
733
|
+
if not db_path.exists():
|
|
734
|
+
return []
|
|
735
|
+
try:
|
|
736
|
+
conn = sqlite3.connect(db_path)
|
|
737
|
+
conn.row_factory = sqlite3.Row
|
|
738
|
+
cursor = conn.cursor()
|
|
739
|
+
cursor.execute(
|
|
740
|
+
"SELECT trace_id, session_id, step_id, parent_trace_id, type,"
|
|
741
|
+
" name, status, timestamp, duration, payload FROM traces ORDER"
|
|
742
|
+
" BY timestamp ASC"
|
|
743
|
+
)
|
|
744
|
+
rows = cursor.fetchall()
|
|
745
|
+
conn.close()
|
|
746
|
+
|
|
747
|
+
preloaded_set = {str(tid).lower() for tid in preloaded_trace_ids if tid}
|
|
748
|
+
step_traces = []
|
|
749
|
+
for r in rows:
|
|
750
|
+
td = dict(r)
|
|
751
|
+
if td.get("payload"):
|
|
752
|
+
try:
|
|
753
|
+
td["payload"] = json.loads(td["payload"])
|
|
754
|
+
except (ValueError, TypeError):
|
|
755
|
+
# Non-JSON payload: normalized to {} just below.
|
|
756
|
+
pass
|
|
757
|
+
if not isinstance(td.get("payload"), dict):
|
|
758
|
+
td["payload"] = {}
|
|
759
|
+
|
|
760
|
+
tid = str(r["trace_id"]).lower()
|
|
761
|
+
if tid in preloaded_set:
|
|
762
|
+
td["is_preloaded"] = True
|
|
763
|
+
else:
|
|
764
|
+
td["is_preloaded"] = False
|
|
765
|
+
|
|
766
|
+
td["step_number"] = step_num
|
|
767
|
+
step_traces.append(td)
|
|
768
|
+
return step_traces
|
|
769
|
+
except Exception as e:
|
|
770
|
+
print(
|
|
771
|
+
f"Warning: Failed to load traces from database {db_path}: {e}",
|
|
772
|
+
file=sys.stderr,
|
|
773
|
+
)
|
|
774
|
+
return []
|
|
775
|
+
|
|
776
|
+
def normalize_trace_payload(self, t: dict) -> dict:
|
|
777
|
+
if t.get("type") == "tool":
|
|
778
|
+
payload = t.get("payload") or {}
|
|
779
|
+
args = payload.get("args") or {}
|
|
780
|
+
positional_args = payload.get("positional_args") or []
|
|
781
|
+
|
|
782
|
+
if (not args or args == {}) and positional_args:
|
|
783
|
+
name = t.get("name")
|
|
784
|
+
if name == "search_by_coords" and len(positional_args) >= 2:
|
|
785
|
+
try:
|
|
786
|
+
args = {
|
|
787
|
+
"nx": int(positional_args[0]),
|
|
788
|
+
"ny": int(positional_args[1]),
|
|
789
|
+
}
|
|
790
|
+
except ValueError:
|
|
791
|
+
args = {
|
|
792
|
+
"nx": positional_args[0],
|
|
793
|
+
"ny": positional_args[1],
|
|
794
|
+
}
|
|
795
|
+
elif name == "search_ui" and len(positional_args) >= 1:
|
|
796
|
+
args = {"search_query": positional_args[0]}
|
|
797
|
+
elif name == "detect_objects" and len(positional_args) >= 1:
|
|
798
|
+
args = {"labels": positional_args[0]}
|
|
799
|
+
else:
|
|
800
|
+
args = {"positional_args": positional_args}
|
|
801
|
+
|
|
802
|
+
payload["args"] = args
|
|
803
|
+
t["payload"] = payload
|
|
804
|
+
return t
|
|
805
|
+
|
|
806
|
+
def load_all_session_traces(
|
|
807
|
+
self,
|
|
808
|
+
temp_db_path: Path,
|
|
809
|
+
preloaded_trace_ids: set,
|
|
810
|
+
current_step_number: int,
|
|
811
|
+
) -> list:
|
|
812
|
+
"""Loads and merges all traces across all steps in the session."""
|
|
813
|
+
session_id = None
|
|
814
|
+
try:
|
|
815
|
+
session_id = temp_db_path.parents[1].name.split("_")[0]
|
|
816
|
+
except IndexError:
|
|
817
|
+
# Path too shallow to carry a session dir: fall back to the DB probe.
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
if not session_id:
|
|
821
|
+
try:
|
|
822
|
+
conn = sqlite3.connect(temp_db_path)
|
|
823
|
+
cursor = conn.cursor()
|
|
824
|
+
cursor.execute("SELECT session_id FROM traces LIMIT 1")
|
|
825
|
+
row = cursor.fetchone()
|
|
826
|
+
if row:
|
|
827
|
+
session_id = str(row[0])
|
|
828
|
+
conn.close()
|
|
829
|
+
except sqlite3.Error:
|
|
830
|
+
# Unreadable sandbox DB: fall back to single-step trace loading.
|
|
831
|
+
pass
|
|
832
|
+
|
|
833
|
+
if not session_id:
|
|
834
|
+
return self.load_traces_from_db(temp_db_path, current_step_number, preloaded_trace_ids)
|
|
835
|
+
|
|
836
|
+
session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
|
|
837
|
+
|
|
838
|
+
all_traces = []
|
|
839
|
+
if session_chunked_dir.exists():
|
|
840
|
+
step_subdirs = sorted(
|
|
841
|
+
[
|
|
842
|
+
d
|
|
843
|
+
for d in session_chunked_dir.iterdir()
|
|
844
|
+
if d.is_dir() and d.name.startswith("step_")
|
|
845
|
+
],
|
|
846
|
+
key=lambda d: d.name,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
for subdir in step_subdirs:
|
|
850
|
+
try:
|
|
851
|
+
step_num = int(subdir.name.split("_")[1])
|
|
852
|
+
except (IndexError, ValueError):
|
|
853
|
+
continue
|
|
854
|
+
|
|
855
|
+
step_outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_num:02d}"
|
|
856
|
+
step_db_path = step_outputs_dir / "temp_traces" / "data_engine.db"
|
|
857
|
+
|
|
858
|
+
step_preloaded_ids = self.get_preloaded_trace_ids_for_step(session_id, step_num)
|
|
859
|
+
|
|
860
|
+
if step_db_path.exists():
|
|
861
|
+
step_traces = self.load_traces_from_db(
|
|
862
|
+
step_db_path, step_num, step_preloaded_ids
|
|
863
|
+
)
|
|
864
|
+
all_traces.extend(step_traces)
|
|
865
|
+
else:
|
|
866
|
+
traces_json_path = subdir / "traces.json"
|
|
867
|
+
if traces_json_path.exists():
|
|
868
|
+
try:
|
|
869
|
+
with open(traces_json_path, encoding="utf-8") as f:
|
|
870
|
+
step_traces = json.load(f)
|
|
871
|
+
for t in step_traces:
|
|
872
|
+
t["is_preloaded"] = True
|
|
873
|
+
t["step_number"] = step_num
|
|
874
|
+
if not isinstance(t.get("payload"), dict):
|
|
875
|
+
t["payload"] = {}
|
|
876
|
+
all_traces.append(t)
|
|
877
|
+
except Exception as e:
|
|
878
|
+
print(
|
|
879
|
+
f"Warning: Failed to load traces from {traces_json_path}: {e}",
|
|
880
|
+
file=sys.stderr,
|
|
881
|
+
)
|
|
882
|
+
else:
|
|
883
|
+
all_traces = self.load_traces_from_db(
|
|
884
|
+
temp_db_path, current_step_number, preloaded_trace_ids
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
# Align live replay traces to the appropriate preloaded step/operator trace
|
|
888
|
+
for step_num in range(1, 100):
|
|
889
|
+
current_step_id = None
|
|
890
|
+
current_step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_num:02d}"
|
|
891
|
+
if not current_step_dir.exists():
|
|
892
|
+
continue
|
|
893
|
+
|
|
894
|
+
step_json_path = current_step_dir / "step.json"
|
|
895
|
+
if step_json_path.exists():
|
|
896
|
+
try:
|
|
897
|
+
with open(step_json_path, encoding="utf-8") as f:
|
|
898
|
+
step_data = json.load(f)
|
|
899
|
+
current_step_id = step_data.get("step_id")
|
|
900
|
+
except (OSError, ValueError) as e:
|
|
901
|
+
print(
|
|
902
|
+
f"Warning: Failed to read step id from {step_json_path}: {e}",
|
|
903
|
+
file=sys.stderr,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
current_step_id_str = str(current_step_id).lower() if current_step_id else None
|
|
907
|
+
|
|
908
|
+
# 1. Find live root trace (no parent_trace_id and not preloaded)
|
|
909
|
+
live_root_trace = None
|
|
910
|
+
for t in all_traces:
|
|
911
|
+
if t.get("step_number") != step_num:
|
|
912
|
+
continue
|
|
913
|
+
if not t.get("is_preloaded") and not t.get("parent_trace_id"):
|
|
914
|
+
live_root_trace = t
|
|
915
|
+
break
|
|
916
|
+
|
|
917
|
+
# 2. Find all preloaded traces for this step
|
|
918
|
+
step_preloaded_traces = [
|
|
919
|
+
t
|
|
920
|
+
for t in all_traces
|
|
921
|
+
if t.get("step_number") == step_num
|
|
922
|
+
and t.get("is_preloaded")
|
|
923
|
+
and str(t.get("step_id") or "").lower() == current_step_id_str
|
|
924
|
+
]
|
|
925
|
+
|
|
926
|
+
# Sort chronologically to identify the final response of the step
|
|
927
|
+
step_preloaded_traces.sort(key=lambda x: x.get("timestamp", 0.0))
|
|
928
|
+
final_response_trace = step_preloaded_traces[-1] if step_preloaded_traces else None
|
|
929
|
+
|
|
930
|
+
# 3. Branch immediately before the final response of the step
|
|
931
|
+
if live_root_trace and final_response_trace:
|
|
932
|
+
# Resolve the tool name from the live root trace name
|
|
933
|
+
tool_name = "ask_explorer"
|
|
934
|
+
agent_name = live_root_trace.get("name")
|
|
935
|
+
for t_name, cfg in REPLAY_TOOLS_CONFIG.items():
|
|
936
|
+
if cfg.get("agent_name") == agent_name:
|
|
937
|
+
tool_name = t_name
|
|
938
|
+
break
|
|
939
|
+
|
|
940
|
+
import uuid
|
|
941
|
+
|
|
942
|
+
virtual_id = str(uuid.uuid4())
|
|
943
|
+
|
|
944
|
+
# Branch from the same parent as the final response
|
|
945
|
+
parent_id = final_response_trace.get("parent_trace_id")
|
|
946
|
+
|
|
947
|
+
# Set timestamp to be immediately before the final response
|
|
948
|
+
virtual_timestamp = final_response_trace.get("timestamp", 0.0) - 0.001
|
|
949
|
+
|
|
950
|
+
live_query = None
|
|
951
|
+
live_cf = None
|
|
952
|
+
if live_root_trace.get("payload") and live_root_trace["payload"].get("args"):
|
|
953
|
+
args = live_root_trace["payload"]["args"]
|
|
954
|
+
live_query = (
|
|
955
|
+
args.get("q") or args.get("query") or args.get("arg2") or args.get("goal")
|
|
956
|
+
)
|
|
957
|
+
live_cf = (
|
|
958
|
+
args.get("cf") or args.get("context_feedback") or args.get("arg3") or ""
|
|
959
|
+
)
|
|
960
|
+
if not live_query:
|
|
961
|
+
live_query = "Replay Execution"
|
|
962
|
+
|
|
963
|
+
virtual_tool_trace = {
|
|
964
|
+
"trace_id": virtual_id,
|
|
965
|
+
"session_id": session_id,
|
|
966
|
+
"step_id": current_step_id,
|
|
967
|
+
"parent_trace_id": parent_id,
|
|
968
|
+
"type": "tool",
|
|
969
|
+
"name": tool_name,
|
|
970
|
+
"status": live_root_trace.get("status", "success"),
|
|
971
|
+
"timestamp": virtual_timestamp,
|
|
972
|
+
"duration": live_root_trace.get("duration", 0.0),
|
|
973
|
+
"payload": {
|
|
974
|
+
"args": {
|
|
975
|
+
"query": live_query,
|
|
976
|
+
"context_feedback": live_cf,
|
|
977
|
+
},
|
|
978
|
+
"result": (live_root_trace.get("payload", {}).get("result")),
|
|
979
|
+
},
|
|
980
|
+
"is_preloaded": False,
|
|
981
|
+
"step_number": step_num,
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
# Link the live root trace to our new virtual tool trace
|
|
985
|
+
live_root_trace["parent_trace_id"] = virtual_id
|
|
986
|
+
all_traces.append(virtual_tool_trace)
|
|
987
|
+
|
|
988
|
+
all_traces = [self.normalize_trace_payload(t) for t in all_traces]
|
|
989
|
+
all_traces.sort(key=lambda t: t.get("timestamp", 0.0))
|
|
990
|
+
return all_traces
|
|
991
|
+
|
|
992
|
+
def enrich_all_session_traces(self, all_traces: list, session_id: str) -> list:
|
|
993
|
+
"""Enriches all preloaded traces (across all steps) with file URLs to their corresponding screenshots."""
|
|
994
|
+
session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
|
|
995
|
+
|
|
996
|
+
step_dirs = {}
|
|
997
|
+
if session_chunked_dir.exists():
|
|
998
|
+
for subdir in session_chunked_dir.iterdir():
|
|
999
|
+
if subdir.is_dir() and subdir.name.startswith("step_"):
|
|
1000
|
+
step_json_path = subdir / "step.json"
|
|
1001
|
+
if step_json_path.exists():
|
|
1002
|
+
try:
|
|
1003
|
+
with open(step_json_path, encoding="utf-8") as f:
|
|
1004
|
+
step_data = json.load(f)
|
|
1005
|
+
sid = step_data.get("step_id")
|
|
1006
|
+
if sid:
|
|
1007
|
+
step_dirs[str(sid).lower()] = subdir
|
|
1008
|
+
except (OSError, ValueError):
|
|
1009
|
+
# Unreadable step.json: screenshots for this step
|
|
1010
|
+
# simply are not linked into the trace payloads.
|
|
1011
|
+
pass
|
|
1012
|
+
|
|
1013
|
+
for td in all_traces:
|
|
1014
|
+
if td.get("is_preloaded"):
|
|
1015
|
+
step_id = str(td.get("step_id") or "").lower()
|
|
1016
|
+
if step_id and step_id in step_dirs:
|
|
1017
|
+
step_dir = step_dirs[step_id]
|
|
1018
|
+
if td.get("type") == "agent":
|
|
1019
|
+
pre_jpg = step_dir / "pre.jpg"
|
|
1020
|
+
if pre_jpg.exists():
|
|
1021
|
+
td["payload"]["pre_screenshot_path"] = f"file://{pre_jpg.resolve()}"
|
|
1022
|
+
elif td.get("type") == "action":
|
|
1023
|
+
post_jpg = step_dir / "post.jpg"
|
|
1024
|
+
if post_jpg.exists():
|
|
1025
|
+
td["payload"]["post_screenshot_path"] = f"file://{post_jpg.resolve()}"
|
|
1026
|
+
|
|
1027
|
+
return all_traces
|
|
1028
|
+
|
|
1029
|
+
def load_steps_metadata(self, session_id: str) -> dict[int, dict]:
|
|
1030
|
+
"""Loads metadata for all steps in the session from the chunked data."""
|
|
1031
|
+
session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
|
|
1032
|
+
|
|
1033
|
+
steps = {}
|
|
1034
|
+
if session_chunked_dir.exists():
|
|
1035
|
+
for subdir in session_chunked_dir.iterdir():
|
|
1036
|
+
if subdir.is_dir() and subdir.name.startswith("step_"):
|
|
1037
|
+
step_json_path = subdir / "step.json"
|
|
1038
|
+
if step_json_path.exists():
|
|
1039
|
+
try:
|
|
1040
|
+
with open(step_json_path, encoding="utf-8") as f:
|
|
1041
|
+
step_data = json.load(f)
|
|
1042
|
+
step_num = step_data.get("step_number")
|
|
1043
|
+
if step_num is not None:
|
|
1044
|
+
steps[int(step_num)] = step_data
|
|
1045
|
+
except Exception as e:
|
|
1046
|
+
print(f"Warning: Failed to load step data from {step_json_path}: {e}")
|
|
1047
|
+
return steps
|
|
1048
|
+
|
|
1049
|
+
def make_file_url(self, val):
|
|
1050
|
+
if isinstance(val, str) and (val.endswith(".jpg") or val.endswith(".png") or "/" in val):
|
|
1051
|
+
try:
|
|
1052
|
+
p = Path(val)
|
|
1053
|
+
if p.exists():
|
|
1054
|
+
abs_path = str(p.resolve())
|
|
1055
|
+
return (
|
|
1056
|
+
f"file://{abs_path}" if abs_path.startswith("/") else f"file:///{abs_path}"
|
|
1057
|
+
)
|
|
1058
|
+
except (OSError, ValueError):
|
|
1059
|
+
# Not a resolvable filesystem path: return the value verbatim.
|
|
1060
|
+
pass
|
|
1061
|
+
return val
|
|
1062
|
+
|
|
1063
|
+
def clean_payload_paths(self, obj):
|
|
1064
|
+
if isinstance(obj, dict):
|
|
1065
|
+
return {k: self.clean_payload_paths(v) for k, v in obj.items()}
|
|
1066
|
+
elif isinstance(obj, list):
|
|
1067
|
+
return [self.clean_payload_paths(v) for v in obj]
|
|
1068
|
+
elif isinstance(obj, str):
|
|
1069
|
+
return self.make_file_url(obj)
|
|
1070
|
+
return obj
|
|
1071
|
+
|
|
1072
|
+
def sanitize_nodes(self, nodes_list):
|
|
1073
|
+
sanitized = []
|
|
1074
|
+
for node in nodes_list:
|
|
1075
|
+
node["children"] = self.sanitize_nodes(node["children"])
|
|
1076
|
+
if node["name"] == "node":
|
|
1077
|
+
sanitized.extend(node["children"])
|
|
1078
|
+
else:
|
|
1079
|
+
sanitized.append(node)
|
|
1080
|
+
return sanitized
|
|
1081
|
+
|
|
1082
|
+
def sort_tree_nodes_chronologically(self, nodes_list: list[dict]) -> list[dict]:
|
|
1083
|
+
for node in nodes_list:
|
|
1084
|
+
if "children" in node and node["children"]:
|
|
1085
|
+
node["children"] = self.sort_tree_nodes_chronologically(node["children"])
|
|
1086
|
+
return sorted(nodes_list, key=lambda x: x.get("timestamp", 0.0) or 0.0)
|
|
1087
|
+
|
|
1088
|
+
def extract_tool_calls_from_db(self, temp_db_path: Path, preloaded_trace_ids: set) -> list:
|
|
1089
|
+
"""Extracts tool calls executed during the current step replay from the sandbox database."""
|
|
1090
|
+
preloaded_set = {str(tid).lower() for tid in preloaded_trace_ids if tid}
|
|
1091
|
+
|
|
1092
|
+
if not temp_db_path.exists():
|
|
1093
|
+
return []
|
|
1094
|
+
|
|
1095
|
+
try:
|
|
1096
|
+
conn = sqlite3.connect(temp_db_path)
|
|
1097
|
+
conn.row_factory = sqlite3.Row
|
|
1098
|
+
cursor = conn.cursor()
|
|
1099
|
+
cursor.execute(
|
|
1100
|
+
"SELECT trace_id, name, status, payload FROM traces WHERE type"
|
|
1101
|
+
" = 'tool' ORDER BY timestamp ASC"
|
|
1102
|
+
)
|
|
1103
|
+
rows = cursor.fetchall()
|
|
1104
|
+
conn.close()
|
|
1105
|
+
except Exception as e:
|
|
1106
|
+
print(
|
|
1107
|
+
f"Warning: Failed to query tool calls from database: {e}",
|
|
1108
|
+
file=sys.stderr,
|
|
1109
|
+
)
|
|
1110
|
+
return []
|
|
1111
|
+
|
|
1112
|
+
tool_calls = []
|
|
1113
|
+
for r in rows:
|
|
1114
|
+
tid = str(r["trace_id"]).lower()
|
|
1115
|
+
if tid not in preloaded_set:
|
|
1116
|
+
payload = {}
|
|
1117
|
+
if r["payload"]:
|
|
1118
|
+
try:
|
|
1119
|
+
payload = json.loads(r["payload"])
|
|
1120
|
+
except (ValueError, TypeError):
|
|
1121
|
+
# Non-JSON payload: treated as an empty tool payload.
|
|
1122
|
+
pass
|
|
1123
|
+
|
|
1124
|
+
response_val = payload.get("result")
|
|
1125
|
+
if response_val is None:
|
|
1126
|
+
response_val = payload.get("error")
|
|
1127
|
+
else:
|
|
1128
|
+
if isinstance(response_val, str) and (
|
|
1129
|
+
response_val.startswith("{") or response_val.startswith("[")
|
|
1130
|
+
):
|
|
1131
|
+
try:
|
|
1132
|
+
response_val = json.loads(response_val)
|
|
1133
|
+
except ValueError:
|
|
1134
|
+
# Looks like JSON but is not: keep the raw string.
|
|
1135
|
+
pass
|
|
1136
|
+
|
|
1137
|
+
args = payload.get("args", {})
|
|
1138
|
+
positional_args = payload.get("positional_args", [])
|
|
1139
|
+
if positional_args:
|
|
1140
|
+
if r["name"] == "search_by_coords" and len(positional_args) >= 2:
|
|
1141
|
+
try:
|
|
1142
|
+
args = {
|
|
1143
|
+
"nx": int(positional_args[0]),
|
|
1144
|
+
"ny": int(positional_args[1]),
|
|
1145
|
+
}
|
|
1146
|
+
except ValueError:
|
|
1147
|
+
args = {
|
|
1148
|
+
"nx": positional_args[0],
|
|
1149
|
+
"ny": positional_args[1],
|
|
1150
|
+
}
|
|
1151
|
+
elif r["name"] == "search_ui" and len(positional_args) >= 1:
|
|
1152
|
+
args = {"search_query": positional_args[0]}
|
|
1153
|
+
elif r["name"] == "detect_objects" and len(positional_args) >= 1:
|
|
1154
|
+
args = {"labels": positional_args[0]}
|
|
1155
|
+
else:
|
|
1156
|
+
args = {"positional_args": positional_args}
|
|
1157
|
+
|
|
1158
|
+
tool_calls.append(
|
|
1159
|
+
{
|
|
1160
|
+
"name": r["name"],
|
|
1161
|
+
"args": args,
|
|
1162
|
+
"status": r["status"],
|
|
1163
|
+
"response": response_val,
|
|
1164
|
+
}
|
|
1165
|
+
)
|
|
1166
|
+
return tool_calls
|
|
1167
|
+
|
|
1168
|
+
def extract_state_prepopulation_data(self, step_dir, step_data, pre_image_meta):
|
|
1169
|
+
"""Dynamically extracts state prepopulation fields from chunked test step files."""
|
|
1170
|
+
latest_ui_hierarchy = None
|
|
1171
|
+
if pre_image_meta and "ui_tree" in pre_image_meta:
|
|
1172
|
+
latest_ui_hierarchy = pre_image_meta["ui_tree"]
|
|
1173
|
+
|
|
1174
|
+
structured_decisions = None
|
|
1175
|
+
traces_json_path = step_dir / "traces.json"
|
|
1176
|
+
if traces_json_path.exists():
|
|
1177
|
+
try:
|
|
1178
|
+
with open(traces_json_path, encoding="utf-8") as f:
|
|
1179
|
+
traces = json.load(f)
|
|
1180
|
+
operator_trace = next(
|
|
1181
|
+
(t for t in traces if t.get("type") == "agent" and t.get("name") == "operator"),
|
|
1182
|
+
None,
|
|
1183
|
+
)
|
|
1184
|
+
if operator_trace:
|
|
1185
|
+
payload = operator_trace.get("payload") or {}
|
|
1186
|
+
result_val = payload.get("result")
|
|
1187
|
+
if isinstance(result_val, str):
|
|
1188
|
+
try:
|
|
1189
|
+
res_dict = json.loads(result_val)
|
|
1190
|
+
structured_decisions = res_dict.get("structured_decisions")
|
|
1191
|
+
except Exception as e:
|
|
1192
|
+
print(f"Warning: Failed to parse operator trace result JSON: {e}")
|
|
1193
|
+
elif isinstance(result_val, dict):
|
|
1194
|
+
structured_decisions = result_val.get("structured_decisions")
|
|
1195
|
+
except Exception as e:
|
|
1196
|
+
print(f"Warning: Failed to read structured_decisions from traces.json: {e}")
|
|
1197
|
+
pass
|
|
1198
|
+
|
|
1199
|
+
if not structured_decisions and step_data:
|
|
1200
|
+
action_taken = step_data.get("action_taken")
|
|
1201
|
+
if action_taken:
|
|
1202
|
+
try:
|
|
1203
|
+
structured_decisions = json.dumps(action_taken)
|
|
1204
|
+
except Exception as e:
|
|
1205
|
+
print(f"Warning: Failed to serialize action_taken fallback to JSON: {e}")
|
|
1206
|
+
pass
|
|
1207
|
+
|
|
1208
|
+
focused_app_info = None
|
|
1209
|
+
if latest_ui_hierarchy:
|
|
1210
|
+
system_packages = {
|
|
1211
|
+
"com.android.systemui",
|
|
1212
|
+
"android",
|
|
1213
|
+
"com.google.android.apps.nexuslauncher",
|
|
1214
|
+
}
|
|
1215
|
+
packages = []
|
|
1216
|
+
for node in latest_ui_hierarchy:
|
|
1217
|
+
if isinstance(node, dict):
|
|
1218
|
+
pkg = node.get("package")
|
|
1219
|
+
if pkg and pkg not in system_packages and "launcher" not in pkg.lower():
|
|
1220
|
+
packages.append(pkg)
|
|
1221
|
+
if packages:
|
|
1222
|
+
from collections import Counter
|
|
1223
|
+
|
|
1224
|
+
focused_app_info = Counter(packages).most_common(1)[0][0]
|
|
1225
|
+
|
|
1226
|
+
device_date = None
|
|
1227
|
+
timestamp = None
|
|
1228
|
+
if step_data and "timestamp" in step_data:
|
|
1229
|
+
timestamp = step_data["timestamp"]
|
|
1230
|
+
elif pre_image_meta and "timestamp" in pre_image_meta:
|
|
1231
|
+
timestamp = pre_image_meta["timestamp"]
|
|
1232
|
+
|
|
1233
|
+
if timestamp is not None:
|
|
1234
|
+
from datetime import datetime, UTC
|
|
1235
|
+
|
|
1236
|
+
try:
|
|
1237
|
+
dt = datetime.fromtimestamp(timestamp, UTC)
|
|
1238
|
+
device_date = dt.strftime("%a %b %d %H:%M:%S UTC %Y")
|
|
1239
|
+
except Exception as e:
|
|
1240
|
+
print(
|
|
1241
|
+
f"Warning: Failed to parse timestamp {timestamp} into device date string: {e}"
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
return (
|
|
1245
|
+
latest_ui_hierarchy,
|
|
1246
|
+
structured_decisions,
|
|
1247
|
+
focused_app_info,
|
|
1248
|
+
device_date,
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
def _ensure_session_chunked(self, session_id: str) -> Path:
|
|
1252
|
+
"""Ensures that the session traces are chunked and ready for replay."""
|
|
1253
|
+
session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
|
|
1254
|
+
|
|
1255
|
+
db_steps_count = 0
|
|
1256
|
+
if self.db_path.exists():
|
|
1257
|
+
try:
|
|
1258
|
+
conn = sqlite3.connect(self.db_path)
|
|
1259
|
+
cursor = conn.cursor()
|
|
1260
|
+
cursor.execute(
|
|
1261
|
+
"SELECT COUNT(*) FROM steps WHERE session_id = ?",
|
|
1262
|
+
(session_id,),
|
|
1263
|
+
)
|
|
1264
|
+
db_steps_count = cursor.fetchone()[0]
|
|
1265
|
+
conn.close()
|
|
1266
|
+
except Exception as e:
|
|
1267
|
+
print(f"Warning: Failed to query step count from DB: {e}")
|
|
1268
|
+
|
|
1269
|
+
chunked_steps_count = 0
|
|
1270
|
+
if session_chunked_dir.exists():
|
|
1271
|
+
chunked_steps_count = len(list(session_chunked_dir.glob("step_*")))
|
|
1272
|
+
|
|
1273
|
+
# Check if directory does not exist, does not contain the sentinel file, or count of chunked steps is out of sync
|
|
1274
|
+
needs_chunking = (
|
|
1275
|
+
not session_chunked_dir.exists()
|
|
1276
|
+
or not (session_chunked_dir / ".chunked").exists()
|
|
1277
|
+
or db_steps_count != chunked_steps_count
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
if needs_chunking:
|
|
1281
|
+
try:
|
|
1282
|
+
print(
|
|
1283
|
+
f"Chunked data needs update for session {session_id} (DB"
|
|
1284
|
+
f" steps: {db_steps_count}, chunked steps:"
|
|
1285
|
+
f" {chunked_steps_count}). Chunking on-the-fly..."
|
|
1286
|
+
)
|
|
1287
|
+
self.chunk_session_traces(session_id, session_chunked_dir)
|
|
1288
|
+
except Exception as e:
|
|
1289
|
+
print(f"Failed to chunk session {session_id} on-the-fly: {e}")
|
|
1290
|
+
return session_chunked_dir
|
|
1291
|
+
|
|
1292
|
+
def resolve_master_video_path(self, db_recorded_path_str: str) -> Path | None:
|
|
1293
|
+
"""Resolves the physical master video recording path on disk, searching for renamed folders."""
|
|
1294
|
+
db_video_path = Path(db_recorded_path_str)
|
|
1295
|
+
|
|
1296
|
+
# 1. Direct absolute path check
|
|
1297
|
+
if db_video_path.exists():
|
|
1298
|
+
return db_video_path
|
|
1299
|
+
|
|
1300
|
+
# 2. Check if relative to workspace
|
|
1301
|
+
workspace_rel_path = self.workspace_root / db_video_path
|
|
1302
|
+
if workspace_rel_path.exists():
|
|
1303
|
+
return workspace_rel_path.resolve()
|
|
1304
|
+
|
|
1305
|
+
# 3. Check if under traces_path
|
|
1306
|
+
traces_rel_path = self.traces_path / db_video_path.name
|
|
1307
|
+
if traces_rel_path.exists():
|
|
1308
|
+
return traces_rel_path.resolve()
|
|
1309
|
+
|
|
1310
|
+
# 4. Resolve folder renaming prefix
|
|
1311
|
+
parent_dir_name = db_video_path.parent.name
|
|
1312
|
+
filename = db_video_path.name
|
|
1313
|
+
for search_dir in [
|
|
1314
|
+
self.traces_path,
|
|
1315
|
+
self.workspace_root / "artemis-traces",
|
|
1316
|
+
]:
|
|
1317
|
+
if parent_dir_name and search_dir.exists():
|
|
1318
|
+
try:
|
|
1319
|
+
for child in search_dir.iterdir():
|
|
1320
|
+
if child.is_dir() and (
|
|
1321
|
+
child.name.startswith(parent_dir_name) or parent_dir_name in child.name
|
|
1322
|
+
):
|
|
1323
|
+
candidate = child / filename
|
|
1324
|
+
if candidate.exists():
|
|
1325
|
+
return candidate.resolve()
|
|
1326
|
+
except Exception as e:
|
|
1327
|
+
print(f"Warning: Error while scanning {search_dir} for renamed folder: {e}")
|
|
1328
|
+
|
|
1329
|
+
# 5. Check if under traces_path or artemis-traces with parent_dir_name
|
|
1330
|
+
for search_dir in [
|
|
1331
|
+
self.traces_path,
|
|
1332
|
+
self.workspace_root / "artemis-traces",
|
|
1333
|
+
]:
|
|
1334
|
+
fallback_path = search_dir / parent_dir_name / filename
|
|
1335
|
+
if fallback_path.exists():
|
|
1336
|
+
return fallback_path.resolve()
|
|
1337
|
+
|
|
1338
|
+
return None
|
|
1339
|
+
|
|
1340
|
+
def calculate_virtual_start_time(self, session_id: str, step_number: int) -> float | None:
|
|
1341
|
+
"""Calculates the virtual start time based on the database's final response timestamp."""
|
|
1342
|
+
if not self.db_path.exists():
|
|
1343
|
+
return None
|
|
1344
|
+
|
|
1345
|
+
try:
|
|
1346
|
+
conn = sqlite3.connect(self.db_path)
|
|
1347
|
+
cursor = conn.cursor()
|
|
1348
|
+
|
|
1349
|
+
# Fetch fallback current step timestamp
|
|
1350
|
+
cursor.execute(
|
|
1351
|
+
"SELECT timestamp FROM steps WHERE session_id = ? AND step_number = ?",
|
|
1352
|
+
(session_id, step_number),
|
|
1353
|
+
)
|
|
1354
|
+
row = cursor.fetchone()
|
|
1355
|
+
step_timestamp = row[0] if (row and row[0] is not None) else None
|
|
1356
|
+
|
|
1357
|
+
if step_number <= 1:
|
|
1358
|
+
# First step starts at session start
|
|
1359
|
+
cursor.execute(
|
|
1360
|
+
"SELECT start_time FROM sessions WHERE session_id = ?",
|
|
1361
|
+
(session_id,),
|
|
1362
|
+
)
|
|
1363
|
+
row = cursor.fetchone()
|
|
1364
|
+
db_session_start_time = row[0] if (row and row[0] is not None) else step_timestamp
|
|
1365
|
+
conn.close()
|
|
1366
|
+
return db_session_start_time
|
|
1367
|
+
|
|
1368
|
+
# Get step_id of previous step
|
|
1369
|
+
cursor.execute(
|
|
1370
|
+
"SELECT step_id FROM steps WHERE session_id = ? AND step_number = ?",
|
|
1371
|
+
(session_id, step_number - 1),
|
|
1372
|
+
)
|
|
1373
|
+
row = cursor.fetchone()
|
|
1374
|
+
if not row:
|
|
1375
|
+
conn.close()
|
|
1376
|
+
return step_timestamp
|
|
1377
|
+
|
|
1378
|
+
prev_step_id = row[0]
|
|
1379
|
+
|
|
1380
|
+
# Get maximum trace timestamp for that step (final response timestamp)
|
|
1381
|
+
cursor.execute(
|
|
1382
|
+
"SELECT MAX(timestamp) FROM traces WHERE step_id = ?",
|
|
1383
|
+
(prev_step_id,),
|
|
1384
|
+
)
|
|
1385
|
+
row = cursor.fetchone()
|
|
1386
|
+
conn.close()
|
|
1387
|
+
|
|
1388
|
+
if row and row[0] is not None:
|
|
1389
|
+
db_final_response_timestamp = row[0]
|
|
1390
|
+
return db_final_response_timestamp - 0.001
|
|
1391
|
+
|
|
1392
|
+
return step_timestamp
|
|
1393
|
+
except Exception as e:
|
|
1394
|
+
print(f"Warning: Failed to calculate virtual start time from database: {e}")
|
|
1395
|
+
return None
|
|
1396
|
+
|
|
1397
|
+
def _preemptive_clip_video(
|
|
1398
|
+
self,
|
|
1399
|
+
session_id: str,
|
|
1400
|
+
step_number: int,
|
|
1401
|
+
step_data: dict,
|
|
1402
|
+
temp_db_path: Path,
|
|
1403
|
+
outputs_dir: Path,
|
|
1404
|
+
replay_id: str = None,
|
|
1405
|
+
):
|
|
1406
|
+
"""Locates the master recording, calculates step time bounds, trims the video and updates the temp DB."""
|
|
1407
|
+
import subprocess
|
|
1408
|
+
import uuid
|
|
1409
|
+
|
|
1410
|
+
# 1. Query the master database's video_recordings table
|
|
1411
|
+
if not self.db_path.exists():
|
|
1412
|
+
print(
|
|
1413
|
+
f"Warning: Master database not found at {self.db_path}."
|
|
1414
|
+
" Skipping preemptive video clip."
|
|
1415
|
+
)
|
|
1416
|
+
return
|
|
1417
|
+
|
|
1418
|
+
try:
|
|
1419
|
+
conn_master = sqlite3.connect(self.db_path)
|
|
1420
|
+
cursor = conn_master.cursor()
|
|
1421
|
+
cursor.execute(
|
|
1422
|
+
"SELECT video_id, device_id, start_time, end_time,"
|
|
1423
|
+
" local_video_path FROM video_recordings WHERE session_id = ?",
|
|
1424
|
+
(session_id,),
|
|
1425
|
+
)
|
|
1426
|
+
row = cursor.fetchone()
|
|
1427
|
+
conn_master.close()
|
|
1428
|
+
except Exception as e:
|
|
1429
|
+
print(f"Warning: Failed to query video_recordings from master database: {e}")
|
|
1430
|
+
return
|
|
1431
|
+
|
|
1432
|
+
if not row:
|
|
1433
|
+
print(
|
|
1434
|
+
f"No video recording found for session {session_id} in master"
|
|
1435
|
+
" database. Skipping clip."
|
|
1436
|
+
)
|
|
1437
|
+
return
|
|
1438
|
+
|
|
1439
|
+
(
|
|
1440
|
+
video_id,
|
|
1441
|
+
device_id,
|
|
1442
|
+
video_start_time,
|
|
1443
|
+
video_end_time,
|
|
1444
|
+
db_recorded_path,
|
|
1445
|
+
) = row
|
|
1446
|
+
if not db_recorded_path:
|
|
1447
|
+
print("Warning: Recorded path is empty in video_recordings. Skipping clip.")
|
|
1448
|
+
return
|
|
1449
|
+
|
|
1450
|
+
# 2. Locate master recording on disk (renaming logic)
|
|
1451
|
+
master_video_path = self.resolve_master_video_path(db_recorded_path)
|
|
1452
|
+
if not master_video_path:
|
|
1453
|
+
print(
|
|
1454
|
+
f"Warning: Could not locate master recording on disk for path: {db_recorded_path}"
|
|
1455
|
+
)
|
|
1456
|
+
return
|
|
1457
|
+
|
|
1458
|
+
# 3. Calculate step bounds using virtual start time
|
|
1459
|
+
step_start = self.calculate_virtual_start_time(session_id, step_number)
|
|
1460
|
+
if step_start is None:
|
|
1461
|
+
step_start = step_data.get("timestamp")
|
|
1462
|
+
|
|
1463
|
+
if step_start is None:
|
|
1464
|
+
print("Warning: Step start timestamp could not be resolved. Skipping clip.")
|
|
1465
|
+
return
|
|
1466
|
+
|
|
1467
|
+
# Look for next step start
|
|
1468
|
+
step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
|
|
1469
|
+
next_step_dir = step_dir.parent / f"step_{step_number + 1:02d}"
|
|
1470
|
+
next_step_json_path = next_step_dir / "step.json"
|
|
1471
|
+
|
|
1472
|
+
if next_step_json_path.exists():
|
|
1473
|
+
try:
|
|
1474
|
+
with open(next_step_json_path, encoding="utf-8") as f:
|
|
1475
|
+
next_step_data = json.load(f)
|
|
1476
|
+
next_step_start = next_step_data.get("timestamp", step_start + 15.0)
|
|
1477
|
+
except Exception as e:
|
|
1478
|
+
print(f"Warning: Failed to read next step json: {e}")
|
|
1479
|
+
next_step_start = step_start + 15.0
|
|
1480
|
+
else:
|
|
1481
|
+
next_step_start = step_start + 15.0
|
|
1482
|
+
|
|
1483
|
+
# Calculate offsets
|
|
1484
|
+
start_offset = step_start - video_start_time
|
|
1485
|
+
# clamp start_offset to be non-negative
|
|
1486
|
+
start_offset = max(0.0, start_offset)
|
|
1487
|
+
end_offset = next_step_start - video_start_time
|
|
1488
|
+
duration = max(0.1, end_offset - start_offset)
|
|
1489
|
+
|
|
1490
|
+
trimmed_video_path = outputs_dir / "recording_trimmed.mp4"
|
|
1491
|
+
|
|
1492
|
+
# 4. Execute FFmpeg trim
|
|
1493
|
+
cmd = [
|
|
1494
|
+
"ffmpeg",
|
|
1495
|
+
"-y",
|
|
1496
|
+
"-i",
|
|
1497
|
+
str(master_video_path),
|
|
1498
|
+
"-ss",
|
|
1499
|
+
f"{start_offset:.3f}",
|
|
1500
|
+
"-t",
|
|
1501
|
+
f"{duration:.3f}",
|
|
1502
|
+
"-c:v",
|
|
1503
|
+
"libx264",
|
|
1504
|
+
str(trimmed_video_path),
|
|
1505
|
+
]
|
|
1506
|
+
print(
|
|
1507
|
+
f"Trimming video segment from {master_video_path}:"
|
|
1508
|
+
f" offset={start_offset:.3f}s, duration={duration:.3f}s"
|
|
1509
|
+
)
|
|
1510
|
+
try:
|
|
1511
|
+
res = subprocess.run(cmd, capture_output=True, text=True)
|
|
1512
|
+
if res.returncode != 0:
|
|
1513
|
+
print(
|
|
1514
|
+
f"Warning: ffmpeg failed with exit code {res.returncode}. stderr: {res.stderr}"
|
|
1515
|
+
)
|
|
1516
|
+
return
|
|
1517
|
+
except Exception as e:
|
|
1518
|
+
print(f"Warning: Failed to execute ffmpeg command: {e}")
|
|
1519
|
+
return
|
|
1520
|
+
|
|
1521
|
+
# 5. Update Sandbox Database
|
|
1522
|
+
try:
|
|
1523
|
+
conn_temp = sqlite3.connect(temp_db_path)
|
|
1524
|
+
conn_temp.execute("""
|
|
1525
|
+
CREATE TABLE IF NOT EXISTS video_recordings (
|
|
1526
|
+
video_id TEXT PRIMARY KEY,
|
|
1527
|
+
session_id TEXT,
|
|
1528
|
+
device_id TEXT,
|
|
1529
|
+
start_time REAL,
|
|
1530
|
+
end_time REAL,
|
|
1531
|
+
local_video_path TEXT,
|
|
1532
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
|
1533
|
+
)
|
|
1534
|
+
""")
|
|
1535
|
+
|
|
1536
|
+
# Insert original metadata first if not present
|
|
1537
|
+
conn_temp.execute(
|
|
1538
|
+
"""
|
|
1539
|
+
INSERT OR IGNORE INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path)
|
|
1540
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1541
|
+
""",
|
|
1542
|
+
(
|
|
1543
|
+
video_id,
|
|
1544
|
+
session_id,
|
|
1545
|
+
device_id,
|
|
1546
|
+
video_start_time,
|
|
1547
|
+
video_end_time,
|
|
1548
|
+
db_recorded_path,
|
|
1549
|
+
),
|
|
1550
|
+
)
|
|
1551
|
+
|
|
1552
|
+
# Update values
|
|
1553
|
+
conn_temp.execute(
|
|
1554
|
+
"""
|
|
1555
|
+
UPDATE video_recordings
|
|
1556
|
+
SET local_video_path = ?, start_time = ?, end_time = ?
|
|
1557
|
+
WHERE session_id = ?
|
|
1558
|
+
""",
|
|
1559
|
+
(
|
|
1560
|
+
str(trimmed_video_path),
|
|
1561
|
+
step_start,
|
|
1562
|
+
next_step_start,
|
|
1563
|
+
session_id,
|
|
1564
|
+
),
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
if replay_id:
|
|
1568
|
+
# Also copy / update for replay_id
|
|
1569
|
+
conn_temp.execute(
|
|
1570
|
+
"""
|
|
1571
|
+
INSERT OR IGNORE INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path)
|
|
1572
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1573
|
+
""",
|
|
1574
|
+
(
|
|
1575
|
+
str(uuid.uuid4()),
|
|
1576
|
+
replay_id,
|
|
1577
|
+
device_id,
|
|
1578
|
+
video_start_time,
|
|
1579
|
+
video_end_time,
|
|
1580
|
+
db_recorded_path,
|
|
1581
|
+
),
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
conn_temp.execute(
|
|
1585
|
+
"""
|
|
1586
|
+
UPDATE video_recordings
|
|
1587
|
+
SET local_video_path = ?, start_time = ?, end_time = ?
|
|
1588
|
+
WHERE session_id = ?
|
|
1589
|
+
""",
|
|
1590
|
+
(
|
|
1591
|
+
str(trimmed_video_path),
|
|
1592
|
+
step_start,
|
|
1593
|
+
next_step_start,
|
|
1594
|
+
replay_id,
|
|
1595
|
+
),
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
conn_temp.commit()
|
|
1599
|
+
conn_temp.close()
|
|
1600
|
+
print(
|
|
1601
|
+
f"Successfully updated sandbox database at {temp_db_path} with"
|
|
1602
|
+
" trimmed video segment."
|
|
1603
|
+
)
|
|
1604
|
+
except Exception as e:
|
|
1605
|
+
print(f"Warning: Failed to update sandbox SQLite database: {e}")
|
|
1606
|
+
|
|
1607
|
+
def create_ctx(
|
|
1608
|
+
self,
|
|
1609
|
+
session_id: str,
|
|
1610
|
+
step_number: int,
|
|
1611
|
+
agent_name: str = "explorer",
|
|
1612
|
+
replay_id: str = None,
|
|
1613
|
+
) -> ArtemisContext:
|
|
1614
|
+
"""Creates a sandboxed ArtemisContext and DataEngine for the given session and step."""
|
|
1615
|
+
self._ensure_session_chunked(session_id)
|
|
1616
|
+
step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
|
|
1617
|
+
if not step_dir.exists():
|
|
1618
|
+
raise FileNotFoundError(f"Step directory not found at {step_dir}")
|
|
1619
|
+
|
|
1620
|
+
step_json_path = step_dir / "step.json"
|
|
1621
|
+
pre_image_meta_path = step_dir / "pre_image_meta.json"
|
|
1622
|
+
post_image_meta_path = step_dir / "post_image_meta.json"
|
|
1623
|
+
|
|
1624
|
+
with open(step_json_path, encoding="utf-8") as f:
|
|
1625
|
+
step_data = json.load(f)
|
|
1626
|
+
|
|
1627
|
+
pre_image_meta = None
|
|
1628
|
+
if pre_image_meta_path.exists():
|
|
1629
|
+
with open(pre_image_meta_path, encoding="utf-8") as f:
|
|
1630
|
+
pre_image_meta = json.load(f)
|
|
1631
|
+
|
|
1632
|
+
post_image_meta = None
|
|
1633
|
+
if post_image_meta_path.exists():
|
|
1634
|
+
with open(post_image_meta_path, encoding="utf-8") as f:
|
|
1635
|
+
post_image_meta = json.load(f)
|
|
1636
|
+
|
|
1637
|
+
outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
|
|
1638
|
+
outputs_dir.mkdir(parents=True, exist_ok=True)
|
|
1639
|
+
|
|
1640
|
+
temp_traces_dir = outputs_dir / "temp_traces"
|
|
1641
|
+
temp_db_path = temp_traces_dir / "data_engine.db"
|
|
1642
|
+
|
|
1643
|
+
_, _, traces_to_preload = self.extract_replay_data(
|
|
1644
|
+
step_dir, str(self.db_path), agent_name=agent_name
|
|
1645
|
+
)
|
|
1646
|
+
initial_goal = self.load_session_goal(session_id, step_dir, str(self.db_path))
|
|
1647
|
+
|
|
1648
|
+
self.setup_temporary_database(
|
|
1649
|
+
temp_db_path,
|
|
1650
|
+
temp_traces_dir,
|
|
1651
|
+
session_id,
|
|
1652
|
+
step_data,
|
|
1653
|
+
pre_image_meta,
|
|
1654
|
+
initial_goal,
|
|
1655
|
+
traces_to_preload,
|
|
1656
|
+
post_image_meta=post_image_meta,
|
|
1657
|
+
replay_id=replay_id,
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
try:
|
|
1661
|
+
self._preemptive_clip_video(
|
|
1662
|
+
session_id=session_id,
|
|
1663
|
+
step_number=step_number,
|
|
1664
|
+
step_data=step_data,
|
|
1665
|
+
temp_db_path=temp_db_path,
|
|
1666
|
+
outputs_dir=outputs_dir,
|
|
1667
|
+
replay_id=replay_id,
|
|
1668
|
+
)
|
|
1669
|
+
except Exception as e:
|
|
1670
|
+
print(f"Warning: Pre-emptive video clipping failed: {e}")
|
|
1671
|
+
|
|
1672
|
+
try:
|
|
1673
|
+
from artemis.context import (
|
|
1674
|
+
ArtemisContext,
|
|
1675
|
+
DeviceContext,
|
|
1676
|
+
DevicePlatform,
|
|
1677
|
+
ExecutionSetup,
|
|
1678
|
+
)
|
|
1679
|
+
from artemis.data_engine.engine import DataEngine
|
|
1680
|
+
|
|
1681
|
+
ArtemisContext.model_rebuild()
|
|
1682
|
+
except ImportError as import_err:
|
|
1683
|
+
raise ImportError(f"Failed to import Artemis core modules: {import_err}")
|
|
1684
|
+
|
|
1685
|
+
import sqlite3
|
|
1686
|
+
|
|
1687
|
+
conn = sqlite3.connect(self.db_path)
|
|
1688
|
+
cursor = conn.cursor()
|
|
1689
|
+
cursor.execute(
|
|
1690
|
+
"SELECT device_info FROM sessions WHERE session_id = ?",
|
|
1691
|
+
(session_id,),
|
|
1692
|
+
)
|
|
1693
|
+
row = cursor.fetchone()
|
|
1694
|
+
conn.close()
|
|
1695
|
+
|
|
1696
|
+
device_info = {}
|
|
1697
|
+
if row and row[0]:
|
|
1698
|
+
try:
|
|
1699
|
+
device_info = json.loads(row[0])
|
|
1700
|
+
except (ValueError, TypeError):
|
|
1701
|
+
# Malformed device_info: simulate with the defaults below.
|
|
1702
|
+
pass
|
|
1703
|
+
|
|
1704
|
+
sim_device_id = device_info.get("device_id", "replay-device")
|
|
1705
|
+
sim_w = device_info.get("device_width", 1080)
|
|
1706
|
+
sim_h = device_info.get("device_height", 2400)
|
|
1707
|
+
|
|
1708
|
+
device_context = DeviceContext(
|
|
1709
|
+
host_platform="LINUX",
|
|
1710
|
+
mobile_platform=DevicePlatform.ANDROID,
|
|
1711
|
+
device_id=sim_device_id,
|
|
1712
|
+
device_width=sim_w,
|
|
1713
|
+
device_height=sim_h,
|
|
1714
|
+
)
|
|
1715
|
+
print(f"Replay simulated device: {sim_device_id} ({sim_w}x{sim_h})")
|
|
1716
|
+
|
|
1717
|
+
from artemis.config import get_default_llm_config
|
|
1718
|
+
|
|
1719
|
+
try:
|
|
1720
|
+
llm_cfg = get_default_llm_config()
|
|
1721
|
+
except Exception as e:
|
|
1722
|
+
print(f"Warning: Failed to load default LLM config: {e}")
|
|
1723
|
+
llm_cfg = None
|
|
1724
|
+
|
|
1725
|
+
ctx = ArtemisContext(
|
|
1726
|
+
device=device_context,
|
|
1727
|
+
adb_client=None,
|
|
1728
|
+
ui_adb_client=None,
|
|
1729
|
+
execution_setup=ExecutionSetup(traces_path=temp_traces_dir),
|
|
1730
|
+
llm_config=llm_cfg,
|
|
1731
|
+
)
|
|
1732
|
+
|
|
1733
|
+
data_engine = DataEngine(ctx)
|
|
1734
|
+
data_engine.current_session_id = replay_id or session_id
|
|
1735
|
+
data_engine.current_step_id = step_data["step_id"]
|
|
1736
|
+
data_engine.current_step_dir = outputs_dir
|
|
1737
|
+
ctx.data_engine = data_engine
|
|
1738
|
+
|
|
1739
|
+
settings.DATA_ENGINE_DB_PATH = temp_db_path
|
|
1740
|
+
settings.TRACES_PATH = temp_traces_dir
|
|
1741
|
+
os.environ["DATA_ENGINE_DB_PATH"] = str(temp_db_path)
|
|
1742
|
+
|
|
1743
|
+
return ctx
|
|
1744
|
+
|
|
1745
|
+
def instantiate_state(
|
|
1746
|
+
self,
|
|
1747
|
+
session_id: str,
|
|
1748
|
+
step_number: int,
|
|
1749
|
+
) -> State:
|
|
1750
|
+
"""Instantiates the State object for the given session and step number."""
|
|
1751
|
+
self._ensure_session_chunked(session_id)
|
|
1752
|
+
step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
|
|
1753
|
+
if not step_dir.exists():
|
|
1754
|
+
raise FileNotFoundError(f"Step directory not found at {step_dir}")
|
|
1755
|
+
|
|
1756
|
+
step_json_path = step_dir / "step.json"
|
|
1757
|
+
pre_image_meta_path = step_dir / "pre_image_meta.json"
|
|
1758
|
+
|
|
1759
|
+
with open(step_json_path, encoding="utf-8") as f:
|
|
1760
|
+
step_data = json.load(f)
|
|
1761
|
+
|
|
1762
|
+
pre_image_meta = None
|
|
1763
|
+
if pre_image_meta_path.exists():
|
|
1764
|
+
with open(pre_image_meta_path, encoding="utf-8") as f:
|
|
1765
|
+
pre_image_meta = json.load(f)
|
|
1766
|
+
|
|
1767
|
+
initial_goal = self.load_session_goal(session_id, step_dir, str(self.db_path))
|
|
1768
|
+
|
|
1769
|
+
# State has no fields for the app info or device date (extra="forbid").
|
|
1770
|
+
ui_hier, decisions, _app_info, _dev_date = self.extract_state_prepopulation_data(
|
|
1771
|
+
step_dir, step_data, pre_image_meta
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
try:
|
|
1775
|
+
from artemis.graph.state import State
|
|
1776
|
+
except ImportError as import_err:
|
|
1777
|
+
raise ImportError(f"Failed to import State module: {import_err}")
|
|
1778
|
+
|
|
1779
|
+
state = State(
|
|
1780
|
+
initial_goal=initial_goal,
|
|
1781
|
+
latest_screenshot=str(step_dir / "pre.jpg")
|
|
1782
|
+
if (step_dir / "pre.jpg").exists()
|
|
1783
|
+
else str(step_dir / "post.jpg"),
|
|
1784
|
+
operator_raw_data={"width": self.w, "height": self.h},
|
|
1785
|
+
current_step_id=step_data["step_id"],
|
|
1786
|
+
subagent_calls=step_data.get("subagent_calls") or [],
|
|
1787
|
+
latest_ui_hierarchy=ui_hier,
|
|
1788
|
+
structured_decisions=decisions,
|
|
1789
|
+
)
|
|
1790
|
+
return state
|
|
1791
|
+
|
|
1792
|
+
def generate_replay_id(self) -> str:
|
|
1793
|
+
"""Generates a new unique replay ID."""
|
|
1794
|
+
import uuid
|
|
1795
|
+
|
|
1796
|
+
return str(uuid.uuid4())
|
|
1797
|
+
|
|
1798
|
+
async def replay_step_tool(
|
|
1799
|
+
self,
|
|
1800
|
+
session_id: str,
|
|
1801
|
+
step_number: int,
|
|
1802
|
+
override_device_id: str = None,
|
|
1803
|
+
user_submits: dict = None,
|
|
1804
|
+
tool_name: str = "ask_explorer",
|
|
1805
|
+
replay_id: str = None,
|
|
1806
|
+
) -> list[dict]:
|
|
1807
|
+
"""Replays a specific step tool in a sandboxed execution environment."""
|
|
1808
|
+
if not replay_id:
|
|
1809
|
+
replay_id = self.generate_replay_id()
|
|
1810
|
+
|
|
1811
|
+
replay_result = {
|
|
1812
|
+
"session_id": session_id,
|
|
1813
|
+
"step_number": step_number,
|
|
1814
|
+
"replay_id": replay_id,
|
|
1815
|
+
"success": False,
|
|
1816
|
+
}
|
|
1817
|
+
tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
|
|
1818
|
+
if not tool_cfg:
|
|
1819
|
+
raise ValueError(f"Unknown tool for replay: {tool_name}")
|
|
1820
|
+
agent_name = tool_cfg["agent_name"]
|
|
1821
|
+
|
|
1822
|
+
outputs_parent = self.test_outputs_dir
|
|
1823
|
+
if outputs_parent.exists():
|
|
1824
|
+
for item in outputs_parent.iterdir():
|
|
1825
|
+
if item.is_dir() and item.name.startswith(f"{session_id}_step_"):
|
|
1826
|
+
archive_dir = self.replay_base_dir / "data" / "older"
|
|
1827
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
1828
|
+
|
|
1829
|
+
from datetime import datetime
|
|
1830
|
+
|
|
1831
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1832
|
+
target_name = f"{item.name}_{timestamp}"
|
|
1833
|
+
target_path = archive_dir / target_name
|
|
1834
|
+
|
|
1835
|
+
counter = 1
|
|
1836
|
+
while target_path.exists():
|
|
1837
|
+
target_name = f"{item.name}_{timestamp}_{counter}"
|
|
1838
|
+
target_path = archive_dir / target_name
|
|
1839
|
+
counter += 1
|
|
1840
|
+
|
|
1841
|
+
print(
|
|
1842
|
+
f"Archiving previous run output directory {item.name} to {target_path}..."
|
|
1843
|
+
)
|
|
1844
|
+
try:
|
|
1845
|
+
shutil.move(str(item), str(target_path))
|
|
1846
|
+
except Exception as archive_err:
|
|
1847
|
+
print(
|
|
1848
|
+
f"Warning: Failed to archive {item.name}: {archive_err}",
|
|
1849
|
+
file=sys.stderr,
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
|
|
1853
|
+
temp_db_path = outputs_dir / "temp_traces" / "data_engine.db"
|
|
1854
|
+
|
|
1855
|
+
# Load step data locally for fallback mapping resolutions
|
|
1856
|
+
step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
|
|
1857
|
+
step_json_path = step_dir / "step.json"
|
|
1858
|
+
with open(step_json_path, encoding="utf-8") as f:
|
|
1859
|
+
step_data = json.load(f)
|
|
1860
|
+
|
|
1861
|
+
# Retrieve preloaded trace IDs from chunked trace files to filter new tool calls
|
|
1862
|
+
_, _, traces_to_preload = self.extract_replay_data(
|
|
1863
|
+
step_dir, str(self.db_path), agent_name=agent_name
|
|
1864
|
+
)
|
|
1865
|
+
preloaded_trace_ids = {t.get("trace_id") for t in traces_to_preload if t.get("trace_id")}
|
|
1866
|
+
llm_calls_log = []
|
|
1867
|
+
|
|
1868
|
+
# Device initialization removed for step replay
|
|
1869
|
+
|
|
1870
|
+
ctx = self.create_ctx(
|
|
1871
|
+
session_id=session_id,
|
|
1872
|
+
step_number=step_number,
|
|
1873
|
+
agent_name=agent_name,
|
|
1874
|
+
replay_id=replay_id,
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
state = self.instantiate_state(
|
|
1878
|
+
session_id=session_id,
|
|
1879
|
+
step_number=step_number,
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
data_engine = ctx.data_engine
|
|
1883
|
+
|
|
1884
|
+
print("Executing using Live Gemini Client...")
|
|
1885
|
+
|
|
1886
|
+
from google.genai.models import AsyncModels
|
|
1887
|
+
from unittest.mock import patch
|
|
1888
|
+
|
|
1889
|
+
original_generate_content = AsyncModels.generate_content
|
|
1890
|
+
|
|
1891
|
+
async def wrapped_generate_content(self, *, model, contents, config=None, **kwargs):
|
|
1892
|
+
system_instruction = None
|
|
1893
|
+
if config and config.system_instruction:
|
|
1894
|
+
if isinstance(config.system_instruction, str):
|
|
1895
|
+
system_instruction = config.system_instruction
|
|
1896
|
+
elif hasattr(config.system_instruction, "parts"):
|
|
1897
|
+
parts = []
|
|
1898
|
+
for p in config.system_instruction.parts:
|
|
1899
|
+
if hasattr(p, "text") and p.text:
|
|
1900
|
+
parts.append(p.text)
|
|
1901
|
+
system_instruction = " ".join(parts)
|
|
1902
|
+
else:
|
|
1903
|
+
system_instruction = str(config.system_instruction)
|
|
1904
|
+
|
|
1905
|
+
prompt_data = {
|
|
1906
|
+
"model": model,
|
|
1907
|
+
"system_instruction": system_instruction,
|
|
1908
|
+
"contents": [],
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
contents_list = contents if isinstance(contents, list) else [contents]
|
|
1912
|
+
for content in contents_list:
|
|
1913
|
+
parts_desc = []
|
|
1914
|
+
if hasattr(content, "parts") and content.parts:
|
|
1915
|
+
for part in content.parts:
|
|
1916
|
+
if hasattr(part, "text") and part.text:
|
|
1917
|
+
parts_desc.append({"type": "text", "text": part.text})
|
|
1918
|
+
elif hasattr(part, "inline_data") and part.inline_data:
|
|
1919
|
+
parts_desc.append(
|
|
1920
|
+
{
|
|
1921
|
+
"type": "image",
|
|
1922
|
+
"mime_type": part.inline_data.mime_type,
|
|
1923
|
+
"length": len(part.inline_data.data),
|
|
1924
|
+
}
|
|
1925
|
+
)
|
|
1926
|
+
elif hasattr(part, "function_call") and part.function_call:
|
|
1927
|
+
parts_desc.append(
|
|
1928
|
+
{
|
|
1929
|
+
"type": "function_call",
|
|
1930
|
+
"name": part.function_call.name,
|
|
1931
|
+
"args": (
|
|
1932
|
+
dict(part.function_call.args)
|
|
1933
|
+
if part.function_call.args
|
|
1934
|
+
else {}
|
|
1935
|
+
),
|
|
1936
|
+
}
|
|
1937
|
+
)
|
|
1938
|
+
elif hasattr(part, "function_call") and part.function_response:
|
|
1939
|
+
parts_desc.append(
|
|
1940
|
+
{
|
|
1941
|
+
"type": "function_response",
|
|
1942
|
+
"name": part.function_response.name,
|
|
1943
|
+
"response": (
|
|
1944
|
+
dict(part.function_response.response)
|
|
1945
|
+
if part.function_response.response
|
|
1946
|
+
else {}
|
|
1947
|
+
),
|
|
1948
|
+
}
|
|
1949
|
+
)
|
|
1950
|
+
else:
|
|
1951
|
+
parts_desc.append({"type": "raw", "value": str(content)})
|
|
1952
|
+
|
|
1953
|
+
prompt_data["contents"].append(
|
|
1954
|
+
{
|
|
1955
|
+
"role": getattr(content, "role", "unknown"),
|
|
1956
|
+
"parts": parts_desc,
|
|
1957
|
+
}
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
record = {"prompt": prompt_data, "response": None, "error": None}
|
|
1961
|
+
llm_calls_log.append(record)
|
|
1962
|
+
|
|
1963
|
+
try:
|
|
1964
|
+
res = await original_generate_content(
|
|
1965
|
+
self,
|
|
1966
|
+
model=model,
|
|
1967
|
+
contents=contents,
|
|
1968
|
+
config=config,
|
|
1969
|
+
**kwargs,
|
|
1970
|
+
)
|
|
1971
|
+
|
|
1972
|
+
thinking_parts = []
|
|
1973
|
+
if hasattr(res, "candidates") and res.candidates and res.candidates[0].content:
|
|
1974
|
+
for part in res.candidates[0].content.parts:
|
|
1975
|
+
if getattr(part, "thought", False):
|
|
1976
|
+
thinking_parts.append(part.text)
|
|
1977
|
+
|
|
1978
|
+
resp_data = {
|
|
1979
|
+
"text": res.text if hasattr(res, "text") else None,
|
|
1980
|
+
"function_calls": [],
|
|
1981
|
+
"thought": ("\n".join(thinking_parts) if thinking_parts else None),
|
|
1982
|
+
}
|
|
1983
|
+
if hasattr(res, "function_calls") and res.function_calls:
|
|
1984
|
+
for fc in res.function_calls:
|
|
1985
|
+
resp_data["function_calls"].append(
|
|
1986
|
+
{
|
|
1987
|
+
"name": fc.name,
|
|
1988
|
+
"args": dict(fc.args) if fc.args else {},
|
|
1989
|
+
}
|
|
1990
|
+
)
|
|
1991
|
+
record["response"] = resp_data
|
|
1992
|
+
return res
|
|
1993
|
+
except Exception as err:
|
|
1994
|
+
err_str = str(err)
|
|
1995
|
+
record["error"] = (
|
|
1996
|
+
f"{err.__class__.__name__}: {err_str}" if err_str else err.__class__.__name__
|
|
1997
|
+
)
|
|
1998
|
+
raise err
|
|
1999
|
+
|
|
2000
|
+
try:
|
|
2001
|
+
try:
|
|
2002
|
+
import inspect
|
|
2003
|
+
import importlib
|
|
2004
|
+
|
|
2005
|
+
mod = importlib.import_module(tool_cfg["module"])
|
|
2006
|
+
tool_fn = getattr(mod, tool_cfg["function"])
|
|
2007
|
+
|
|
2008
|
+
user_submits = user_submits or {}
|
|
2009
|
+
sig = inspect.signature(tool_fn)
|
|
2010
|
+
tool_args = {}
|
|
2011
|
+
denylist = tool_cfg.get("denylist_args", [])
|
|
2012
|
+
fallback_mappings = tool_cfg.get("fallback_mappings", {})
|
|
2013
|
+
|
|
2014
|
+
user_submits = user_submits or {}
|
|
2015
|
+
sig = inspect.signature(tool_fn)
|
|
2016
|
+
tool_args = {}
|
|
2017
|
+
for name, param in sig.parameters.items():
|
|
2018
|
+
if name in denylist:
|
|
2019
|
+
if name == "ctx":
|
|
2020
|
+
tool_args["ctx"] = ctx
|
|
2021
|
+
elif name == "state":
|
|
2022
|
+
tool_args["state"] = state
|
|
2023
|
+
continue
|
|
2024
|
+
|
|
2025
|
+
if name in user_submits:
|
|
2026
|
+
val = user_submits[name]
|
|
2027
|
+
if param.annotation is bool and isinstance(val, str):
|
|
2028
|
+
val = val.lower() == "true"
|
|
2029
|
+
tool_args[name] = val
|
|
2030
|
+
else:
|
|
2031
|
+
val = ""
|
|
2032
|
+
if param.default is not inspect.Parameter.empty:
|
|
2033
|
+
val = param.default
|
|
2034
|
+
if name in fallback_mappings:
|
|
2035
|
+
fallback_path = fallback_mappings[name]
|
|
2036
|
+
resolved_val = self.resolve_step_data_path(step_data, fallback_path)
|
|
2037
|
+
if resolved_val is not None:
|
|
2038
|
+
val = resolved_val
|
|
2039
|
+
tool_args[name] = val
|
|
2040
|
+
|
|
2041
|
+
with patch.object(AsyncModels, "generate_content", wrapped_generate_content):
|
|
2042
|
+
outcome = await tool_fn(**tool_args)
|
|
2043
|
+
|
|
2044
|
+
replay_result["success"] = True
|
|
2045
|
+
replay_result["result"] = outcome
|
|
2046
|
+
print(f"Replay Execution complete. Outcome: {outcome}")
|
|
2047
|
+
|
|
2048
|
+
except Exception as replay_err:
|
|
2049
|
+
print(f"Error during step replay: {replay_err}", file=sys.stderr)
|
|
2050
|
+
traceback.print_exc()
|
|
2051
|
+
|
|
2052
|
+
replay_result["success"] = False
|
|
2053
|
+
replay_result["error"] = (
|
|
2054
|
+
f"{replay_err.__class__.__name__}: {str(replay_err)}"
|
|
2055
|
+
if str(replay_err)
|
|
2056
|
+
else replay_err.__class__.__name__
|
|
2057
|
+
)
|
|
2058
|
+
raise replay_err
|
|
2059
|
+
finally:
|
|
2060
|
+
await data_engine.shutdown()
|
|
2061
|
+
|
|
2062
|
+
tool_calls = []
|
|
2063
|
+
if temp_db_path.exists():
|
|
2064
|
+
try:
|
|
2065
|
+
tool_calls = self.extract_tool_calls_from_db(temp_db_path, preloaded_trace_ids)
|
|
2066
|
+
except Exception as ex:
|
|
2067
|
+
print(
|
|
2068
|
+
f"Warning: Failed to extract tool calls: {ex}",
|
|
2069
|
+
file=sys.stderr,
|
|
2070
|
+
)
|
|
2071
|
+
|
|
2072
|
+
replay_result["tool_calls"] = tool_calls
|
|
2073
|
+
replay_result["llm_calls"] = llm_calls_log
|
|
2074
|
+
|
|
2075
|
+
outcome_path = outputs_dir / "result.json"
|
|
2076
|
+
try:
|
|
2077
|
+
with open(outcome_path, "w", encoding="utf-8") as f:
|
|
2078
|
+
json.dump(replay_result, f, indent=2, ensure_ascii=False)
|
|
2079
|
+
except Exception as write_err:
|
|
2080
|
+
print(
|
|
2081
|
+
f"Warning: Failed to write result.json: {write_err}",
|
|
2082
|
+
file=sys.stderr,
|
|
2083
|
+
)
|
|
2084
|
+
|
|
2085
|
+
return llm_calls_log
|
|
2086
|
+
|
|
2087
|
+
def get_replay_config(self, tool_name: str = "ask_explorer") -> list[dict]:
|
|
2088
|
+
"""Returns the parameter list and types of the selected tool logic."""
|
|
2089
|
+
tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
|
|
2090
|
+
if not tool_cfg:
|
|
2091
|
+
raise ValueError(f"Unknown tool for replay: {tool_name}")
|
|
2092
|
+
|
|
2093
|
+
try:
|
|
2094
|
+
import inspect
|
|
2095
|
+
import importlib
|
|
2096
|
+
|
|
2097
|
+
mod = importlib.import_module(tool_cfg["module"])
|
|
2098
|
+
tool_fn = getattr(mod, tool_cfg["function"])
|
|
2099
|
+
sig = inspect.signature(tool_fn)
|
|
2100
|
+
|
|
2101
|
+
denylist = tool_cfg.get("denylist_args", [])
|
|
2102
|
+
explorer_params = []
|
|
2103
|
+
for name, param in sig.parameters.items():
|
|
2104
|
+
if name in denylist:
|
|
2105
|
+
continue
|
|
2106
|
+
default_val = ""
|
|
2107
|
+
if param.default is not inspect.Parameter.empty:
|
|
2108
|
+
default_val = param.default
|
|
2109
|
+
|
|
2110
|
+
input_type = "text"
|
|
2111
|
+
if any(
|
|
2112
|
+
keyword in name.lower()
|
|
2113
|
+
for keyword in (
|
|
2114
|
+
"feedback",
|
|
2115
|
+
"context",
|
|
2116
|
+
"description",
|
|
2117
|
+
"note",
|
|
2118
|
+
)
|
|
2119
|
+
):
|
|
2120
|
+
input_type = "textarea"
|
|
2121
|
+
elif param.annotation is bool:
|
|
2122
|
+
input_type = "checkbox"
|
|
2123
|
+
elif param.annotation in (int, float):
|
|
2124
|
+
input_type = "number"
|
|
2125
|
+
|
|
2126
|
+
explorer_params.append(
|
|
2127
|
+
{
|
|
2128
|
+
"name": name,
|
|
2129
|
+
"default": default_val,
|
|
2130
|
+
"input_type": input_type,
|
|
2131
|
+
}
|
|
2132
|
+
)
|
|
2133
|
+
return explorer_params
|
|
2134
|
+
except Exception as e:
|
|
2135
|
+
raise Exception(f"Failed to load signature for {tool_name}: {str(e)}")
|
|
2136
|
+
|
|
2137
|
+
def get_replay_tools(self) -> list[dict]:
|
|
2138
|
+
"""Returns the list of registered tools available for replay."""
|
|
2139
|
+
return [
|
|
2140
|
+
{
|
|
2141
|
+
"name": name,
|
|
2142
|
+
"display_name": cfg["display_name"],
|
|
2143
|
+
"description": cfg["description"],
|
|
2144
|
+
}
|
|
2145
|
+
for name, cfg in REPLAY_TOOLS_CONFIG.items()
|
|
2146
|
+
]
|
|
2147
|
+
|
|
2148
|
+
def get_replay_steps(self, session_id: str) -> list[dict]:
|
|
2149
|
+
"""Loads and formats metadata for all chunked steps in the session."""
|
|
2150
|
+
session_chunked_dir = self._ensure_session_chunked(session_id)
|
|
2151
|
+
if not session_chunked_dir.exists():
|
|
2152
|
+
raise FileNotFoundError("Step replay chunked data not found for this session.")
|
|
2153
|
+
|
|
2154
|
+
try:
|
|
2155
|
+
steps_metadata = self.load_steps_metadata(session_id)
|
|
2156
|
+
step_numbers = sorted(list(steps_metadata.keys()))
|
|
2157
|
+
|
|
2158
|
+
import inspect
|
|
2159
|
+
import importlib
|
|
2160
|
+
|
|
2161
|
+
# Pre-load signatures and configs for all registered tools
|
|
2162
|
+
tool_signatures = {}
|
|
2163
|
+
for t_name, t_cfg in REPLAY_TOOLS_CONFIG.items():
|
|
2164
|
+
try:
|
|
2165
|
+
mod = importlib.import_module(t_cfg["module"])
|
|
2166
|
+
fn = getattr(mod, t_cfg["function"])
|
|
2167
|
+
tool_signatures[t_name] = inspect.signature(fn)
|
|
2168
|
+
except Exception as e:
|
|
2169
|
+
print(
|
|
2170
|
+
f"Warning: Failed to load signature for tool {t_name}: {e}",
|
|
2171
|
+
file=sys.stderr,
|
|
2172
|
+
)
|
|
2173
|
+
|
|
2174
|
+
steps_list = []
|
|
2175
|
+
for num in step_numbers:
|
|
2176
|
+
s_data = steps_metadata.get(num, {})
|
|
2177
|
+
sid = s_data.get("step_id")
|
|
2178
|
+
|
|
2179
|
+
pre_url = None
|
|
2180
|
+
post_url = None
|
|
2181
|
+
step_dir = session_chunked_dir / f"step_{num:02d}"
|
|
2182
|
+
if step_dir.exists():
|
|
2183
|
+
pre_jpg = step_dir / "pre.jpg"
|
|
2184
|
+
if pre_jpg.exists():
|
|
2185
|
+
pre_url = f"file://{pre_jpg.resolve()}"
|
|
2186
|
+
post_jpg = step_dir / "post.jpg"
|
|
2187
|
+
if post_jpg.exists():
|
|
2188
|
+
post_url = f"file://{post_jpg.resolve()}"
|
|
2189
|
+
if pre_url and post_url and pre_url == post_url:
|
|
2190
|
+
post_url = None
|
|
2191
|
+
|
|
2192
|
+
# Generate param_defaults for all registered tools
|
|
2193
|
+
all_tool_defaults = {}
|
|
2194
|
+
for t_name, sig in tool_signatures.items():
|
|
2195
|
+
t_cfg = REPLAY_TOOLS_CONFIG[t_name]
|
|
2196
|
+
denylist = t_cfg.get("denylist_args", [])
|
|
2197
|
+
fallback_mappings = t_cfg.get("fallback_mappings", {})
|
|
2198
|
+
|
|
2199
|
+
defaults = {}
|
|
2200
|
+
for name, param in sig.parameters.items():
|
|
2201
|
+
if name in denylist:
|
|
2202
|
+
continue
|
|
2203
|
+
|
|
2204
|
+
val = ""
|
|
2205
|
+
if param.default is not inspect.Parameter.empty:
|
|
2206
|
+
val = param.default
|
|
2207
|
+
|
|
2208
|
+
# Resolve fallback using mappings
|
|
2209
|
+
if name in fallback_mappings:
|
|
2210
|
+
mapping_path = fallback_mappings[name]
|
|
2211
|
+
parts = mapping_path.split(".")
|
|
2212
|
+
curr = s_data
|
|
2213
|
+
for part in parts:
|
|
2214
|
+
if isinstance(curr, dict):
|
|
2215
|
+
curr = curr.get(part)
|
|
2216
|
+
elif isinstance(curr, list):
|
|
2217
|
+
try:
|
|
2218
|
+
idx = int(part)
|
|
2219
|
+
if idx < len(curr):
|
|
2220
|
+
curr = curr[idx]
|
|
2221
|
+
else:
|
|
2222
|
+
curr = None
|
|
2223
|
+
except ValueError:
|
|
2224
|
+
curr = None
|
|
2225
|
+
else:
|
|
2226
|
+
curr = None
|
|
2227
|
+
|
|
2228
|
+
if curr is not None:
|
|
2229
|
+
val = curr
|
|
2230
|
+
|
|
2231
|
+
defaults[name] = val
|
|
2232
|
+
all_tool_defaults[t_name] = defaults
|
|
2233
|
+
|
|
2234
|
+
# Check if this step has already been replayed by scanning outputs folder
|
|
2235
|
+
step_outputs_dir = self.test_outputs_dir / f"{session_id}_step_{num:02d}"
|
|
2236
|
+
step_db_path = step_outputs_dir / "temp_traces" / "data_engine.db"
|
|
2237
|
+
is_replayed = step_db_path.exists()
|
|
2238
|
+
|
|
2239
|
+
steps_list.append(
|
|
2240
|
+
{
|
|
2241
|
+
"step_id": str(sid).lower() if sid else None,
|
|
2242
|
+
"step_number": num,
|
|
2243
|
+
"timestamp": s_data.get("timestamp"),
|
|
2244
|
+
"summary": s_data.get("summary", "No summary available."),
|
|
2245
|
+
"action_taken": s_data.get("action_taken", []),
|
|
2246
|
+
"query": "",
|
|
2247
|
+
"param_defaults": all_tool_defaults,
|
|
2248
|
+
"pre_screenshot": pre_url,
|
|
2249
|
+
"post_screenshot": post_url,
|
|
2250
|
+
"operator_raw_thinking": s_data.get("operator_raw_thinking"),
|
|
2251
|
+
"operator_native_thinking": s_data.get("operator_native_thinking"),
|
|
2252
|
+
"is_replayed": is_replayed,
|
|
2253
|
+
}
|
|
2254
|
+
)
|
|
2255
|
+
|
|
2256
|
+
return steps_list
|
|
2257
|
+
except Exception as e:
|
|
2258
|
+
traceback.print_exc()
|
|
2259
|
+
raise e
|
|
2260
|
+
|
|
2261
|
+
async def run_step_replay(
|
|
2262
|
+
self,
|
|
2263
|
+
session_id: str,
|
|
2264
|
+
step_number: int,
|
|
2265
|
+
device_id: str,
|
|
2266
|
+
user_submits: dict,
|
|
2267
|
+
tool_name: str = "ask_explorer",
|
|
2268
|
+
replay_id: str = None,
|
|
2269
|
+
) -> dict:
|
|
2270
|
+
"""Triggers sandbox execution of step replay and returns the resulting traces trees."""
|
|
2271
|
+
tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
|
|
2272
|
+
if not tool_cfg:
|
|
2273
|
+
raise ValueError(f"Unknown tool for replay: {tool_name}")
|
|
2274
|
+
agent_name = tool_cfg["agent_name"]
|
|
2275
|
+
|
|
2276
|
+
# 1. Run live step replay
|
|
2277
|
+
llm_calls_log = await self.replay_step_tool(
|
|
2278
|
+
session_id=session_id,
|
|
2279
|
+
step_number=step_number,
|
|
2280
|
+
override_device_id=device_id,
|
|
2281
|
+
user_submits=user_submits,
|
|
2282
|
+
tool_name=tool_name,
|
|
2283
|
+
replay_id=replay_id,
|
|
2284
|
+
)
|
|
2285
|
+
|
|
2286
|
+
# 2. Extract freshly generated traces
|
|
2287
|
+
outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
|
|
2288
|
+
temp_db_path = outputs_dir / "temp_traces" / "data_engine.db"
|
|
2289
|
+
|
|
2290
|
+
self._ensure_session_chunked(session_id)
|
|
2291
|
+
step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
|
|
2292
|
+
_, _, traces_to_preload = self.extract_replay_data(
|
|
2293
|
+
step_dir, str(self.db_path), agent_name=agent_name
|
|
2294
|
+
)
|
|
2295
|
+
preloaded_trace_ids = {t.get("trace_id") for t in traces_to_preload if t.get("trace_id")}
|
|
2296
|
+
|
|
2297
|
+
all_traces = self.load_all_session_traces(temp_db_path, preloaded_trace_ids, step_number)
|
|
2298
|
+
|
|
2299
|
+
# Align LLM calls and extract final tool result from result.json if available
|
|
2300
|
+
replay_outcome = None
|
|
2301
|
+
replay_id = None
|
|
2302
|
+
result_json_path = outputs_dir / "result.json"
|
|
2303
|
+
if result_json_path.exists():
|
|
2304
|
+
try:
|
|
2305
|
+
with open(result_json_path, encoding="utf-8") as f:
|
|
2306
|
+
res_data = json.load(f)
|
|
2307
|
+
replay_outcome = res_data.get("result")
|
|
2308
|
+
replay_id = res_data.get("replay_id")
|
|
2309
|
+
except (OSError, ValueError) as e:
|
|
2310
|
+
print(
|
|
2311
|
+
f"Warning: Failed to read replay result from {result_json_path}: {e}",
|
|
2312
|
+
file=sys.stderr,
|
|
2313
|
+
)
|
|
2314
|
+
|
|
2315
|
+
# Update virtual tool trace result with the actual formatted outcome if available
|
|
2316
|
+
if replay_outcome is not None:
|
|
2317
|
+
for node in all_traces:
|
|
2318
|
+
if (
|
|
2319
|
+
not node.get("is_preloaded")
|
|
2320
|
+
and node["type"] == "tool"
|
|
2321
|
+
and node["name"] == tool_name
|
|
2322
|
+
):
|
|
2323
|
+
node["payload"]["result"] = replay_outcome
|
|
2324
|
+
|
|
2325
|
+
# Collect all valid LLM span names from REPLAY_TOOLS_CONFIG to align subagent calls too
|
|
2326
|
+
all_llm_span_names = {
|
|
2327
|
+
cfg.get("llm_span_name")
|
|
2328
|
+
for cfg in REPLAY_TOOLS_CONFIG.values()
|
|
2329
|
+
if cfg.get("llm_span_name")
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
llm_call_index = 0
|
|
2333
|
+
for node in all_traces:
|
|
2334
|
+
if (
|
|
2335
|
+
not node.get("is_preloaded")
|
|
2336
|
+
and node["type"] == "span"
|
|
2337
|
+
and node["name"] in all_llm_span_names
|
|
2338
|
+
and llm_calls_log
|
|
2339
|
+
and llm_call_index < len(llm_calls_log)
|
|
2340
|
+
):
|
|
2341
|
+
node["type"] = "llm_call"
|
|
2342
|
+
node["llm_call"] = llm_calls_log[llm_call_index]
|
|
2343
|
+
llm_call_index += 1
|
|
2344
|
+
|
|
2345
|
+
# Clean payload paths
|
|
2346
|
+
for node in all_traces:
|
|
2347
|
+
if node.get("payload"):
|
|
2348
|
+
node["payload"] = self.clean_payload_paths(node["payload"])
|
|
2349
|
+
if node.get("llm_call"):
|
|
2350
|
+
node["llm_call"] = self.clean_payload_paths(node["llm_call"])
|
|
2351
|
+
|
|
2352
|
+
# Filter all_traces for ONLY this step's traces
|
|
2353
|
+
step_traces = [t for t in all_traces if t.get("step_number") == step_number]
|
|
2354
|
+
|
|
2355
|
+
preloaded_traces = [t for t in step_traces if t.get("is_preloaded")]
|
|
2356
|
+
live_traces = [t for t in step_traces if not t.get("is_preloaded")]
|
|
2357
|
+
|
|
2358
|
+
# Build preloaded hierarchy
|
|
2359
|
+
preloaded_nodes = {
|
|
2360
|
+
str(t["trace_id"]).lower(): {**t, "children": []} for t in preloaded_traces
|
|
2361
|
+
}
|
|
2362
|
+
preloaded_root_nodes = []
|
|
2363
|
+
for node_id, node in preloaded_nodes.items():
|
|
2364
|
+
parent_id = node.get("parent_trace_id")
|
|
2365
|
+
if parent_id:
|
|
2366
|
+
parent_id_str = str(parent_id).lower()
|
|
2367
|
+
if parent_id_str in preloaded_nodes:
|
|
2368
|
+
preloaded_nodes[parent_id_str]["children"].append(node)
|
|
2369
|
+
else:
|
|
2370
|
+
preloaded_root_nodes.append(node)
|
|
2371
|
+
else:
|
|
2372
|
+
preloaded_root_nodes.append(node)
|
|
2373
|
+
preloaded_root_nodes = self.sanitize_nodes(preloaded_root_nodes)
|
|
2374
|
+
preloaded_root_nodes = self.sort_tree_nodes_chronologically(preloaded_root_nodes)
|
|
2375
|
+
|
|
2376
|
+
# Build live hierarchy
|
|
2377
|
+
live_nodes = {str(t["trace_id"]).lower(): {**t, "children": []} for t in live_traces}
|
|
2378
|
+
|
|
2379
|
+
# Add a virtual "Response" child node at the end of the virtual tool trace to show the final outcome at the bottom of the tree
|
|
2380
|
+
if replay_outcome is not None:
|
|
2381
|
+
virtual_tool_node_id = None
|
|
2382
|
+
for node_id, node in live_nodes.items():
|
|
2383
|
+
if node.get("type") == "tool" and node.get("name") == tool_name:
|
|
2384
|
+
virtual_tool_node_id = node_id
|
|
2385
|
+
break
|
|
2386
|
+
|
|
2387
|
+
if virtual_tool_node_id:
|
|
2388
|
+
import uuid
|
|
2389
|
+
|
|
2390
|
+
outcome_node_id = str(uuid.uuid4())
|
|
2391
|
+
max_ts = (
|
|
2392
|
+
max([t.get("timestamp", 0.0) for t in live_traces])
|
|
2393
|
+
if live_traces
|
|
2394
|
+
else live_nodes[virtual_tool_node_id].get("timestamp", 0.0)
|
|
2395
|
+
)
|
|
2396
|
+
|
|
2397
|
+
outcome_node = {
|
|
2398
|
+
"trace_id": outcome_node_id,
|
|
2399
|
+
"session_id": session_id,
|
|
2400
|
+
"step_id": live_nodes[virtual_tool_node_id].get("step_id"),
|
|
2401
|
+
"parent_trace_id": live_nodes[virtual_tool_node_id]["trace_id"],
|
|
2402
|
+
"type": "tool",
|
|
2403
|
+
"name": f"{tool_name} Response",
|
|
2404
|
+
"status": (live_nodes[virtual_tool_node_id].get("status", "success")),
|
|
2405
|
+
"timestamp": max_ts + 0.001,
|
|
2406
|
+
"duration": 0.0,
|
|
2407
|
+
"payload": {"result": replay_outcome},
|
|
2408
|
+
"is_preloaded": False,
|
|
2409
|
+
"step_number": step_number,
|
|
2410
|
+
"children": [],
|
|
2411
|
+
}
|
|
2412
|
+
live_nodes[outcome_node_id] = outcome_node
|
|
2413
|
+
|
|
2414
|
+
live_root_nodes = []
|
|
2415
|
+
for node_id, node in live_nodes.items():
|
|
2416
|
+
parent_id = node.get("parent_trace_id")
|
|
2417
|
+
if parent_id:
|
|
2418
|
+
parent_id_str = str(parent_id).lower()
|
|
2419
|
+
if parent_id_str in live_nodes:
|
|
2420
|
+
live_nodes[parent_id_str]["children"].append(node)
|
|
2421
|
+
else:
|
|
2422
|
+
live_root_nodes.append(node)
|
|
2423
|
+
else:
|
|
2424
|
+
live_root_nodes.append(node)
|
|
2425
|
+
live_root_nodes = self.sanitize_nodes(live_root_nodes)
|
|
2426
|
+
live_root_nodes = self.sort_tree_nodes_chronologically(live_root_nodes)
|
|
2427
|
+
|
|
2428
|
+
return {
|
|
2429
|
+
"success": True,
|
|
2430
|
+
"preloaded": preloaded_root_nodes,
|
|
2431
|
+
"live": live_root_nodes,
|
|
2432
|
+
"replay_id": replay_id,
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
def get_step_replay_traces(
|
|
2436
|
+
self, session_id: str, step_number: int, tool_name: str = "ask_explorer"
|
|
2437
|
+
) -> dict:
|
|
2438
|
+
"""Retrieves previously generated step replay traces if they exist."""
|
|
2439
|
+
tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
|
|
2440
|
+
if not tool_cfg:
|
|
2441
|
+
raise ValueError(f"Unknown tool for replay: {tool_name}")
|
|
2442
|
+
agent_name = tool_cfg["agent_name"]
|
|
2443
|
+
|
|
2444
|
+
outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
|
|
2445
|
+
temp_db_path = outputs_dir / "temp_traces" / "data_engine.db"
|
|
2446
|
+
|
|
2447
|
+
if not temp_db_path.exists():
|
|
2448
|
+
return {"success": True, "live": []}
|
|
2449
|
+
|
|
2450
|
+
self._ensure_session_chunked(session_id)
|
|
2451
|
+
step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
|
|
2452
|
+
preloaded_trace_ids = set()
|
|
2453
|
+
if step_dir.exists():
|
|
2454
|
+
_, _, traces_to_preload = self.extract_replay_data(
|
|
2455
|
+
step_dir,
|
|
2456
|
+
str(self.db_path),
|
|
2457
|
+
agent_name=agent_name,
|
|
2458
|
+
tool_name=tool_name,
|
|
2459
|
+
)
|
|
2460
|
+
preloaded_trace_ids = {
|
|
2461
|
+
t.get("trace_id") for t in traces_to_preload if t.get("trace_id")
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
all_traces = self.load_all_session_traces(temp_db_path, preloaded_trace_ids, step_number)
|
|
2465
|
+
|
|
2466
|
+
llm_span_name = tool_cfg.get("llm_span_name")
|
|
2467
|
+
if not llm_span_name:
|
|
2468
|
+
raise ValueError(f"Tool {tool_name} config is missing 'llm_span_name'")
|
|
2469
|
+
|
|
2470
|
+
# Align LLM calls and extract final tool result from result.json if available
|
|
2471
|
+
llm_calls_log = []
|
|
2472
|
+
replay_outcome = None
|
|
2473
|
+
replay_id = None
|
|
2474
|
+
result_json_path = outputs_dir / "result.json"
|
|
2475
|
+
if result_json_path.exists():
|
|
2476
|
+
try:
|
|
2477
|
+
with open(result_json_path, encoding="utf-8") as f:
|
|
2478
|
+
res_data = json.load(f)
|
|
2479
|
+
llm_calls_log = res_data.get("llm_calls") or []
|
|
2480
|
+
replay_outcome = res_data.get("result")
|
|
2481
|
+
replay_id = res_data.get("replay_id")
|
|
2482
|
+
except (OSError, ValueError) as e:
|
|
2483
|
+
print(
|
|
2484
|
+
f"Warning: Failed to read replay result from {result_json_path}: {e}",
|
|
2485
|
+
file=sys.stderr,
|
|
2486
|
+
)
|
|
2487
|
+
|
|
2488
|
+
# Update virtual tool trace result with the actual formatted outcome if available
|
|
2489
|
+
if replay_outcome is not None:
|
|
2490
|
+
for node in all_traces:
|
|
2491
|
+
if (
|
|
2492
|
+
not node.get("is_preloaded")
|
|
2493
|
+
and node["type"] == "tool"
|
|
2494
|
+
and node["name"] == tool_name
|
|
2495
|
+
):
|
|
2496
|
+
node["payload"]["result"] = replay_outcome
|
|
2497
|
+
|
|
2498
|
+
# Collect all valid LLM span names from REPLAY_TOOLS_CONFIG to align subagent calls too
|
|
2499
|
+
all_llm_span_names = {
|
|
2500
|
+
cfg.get("llm_span_name")
|
|
2501
|
+
for cfg in REPLAY_TOOLS_CONFIG.values()
|
|
2502
|
+
if cfg.get("llm_span_name")
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
llm_call_index = 0
|
|
2506
|
+
for node in all_traces:
|
|
2507
|
+
if (
|
|
2508
|
+
not node.get("is_preloaded")
|
|
2509
|
+
and node["type"] == "span"
|
|
2510
|
+
and node["name"] in all_llm_span_names
|
|
2511
|
+
and llm_calls_log
|
|
2512
|
+
and llm_call_index < len(llm_calls_log)
|
|
2513
|
+
):
|
|
2514
|
+
node["type"] = "llm_call"
|
|
2515
|
+
node["llm_call"] = llm_calls_log[llm_call_index]
|
|
2516
|
+
llm_call_index += 1
|
|
2517
|
+
|
|
2518
|
+
# Clean payload paths
|
|
2519
|
+
for node in all_traces:
|
|
2520
|
+
if node.get("payload"):
|
|
2521
|
+
node["payload"] = self.clean_payload_paths(node["payload"])
|
|
2522
|
+
if node.get("llm_call"):
|
|
2523
|
+
node["llm_call"] = self.clean_payload_paths(node["llm_call"])
|
|
2524
|
+
|
|
2525
|
+
# Filter all_traces for ONLY this step's traces
|
|
2526
|
+
step_traces = [t for t in all_traces if t.get("step_number") == step_number]
|
|
2527
|
+
live_traces = [t for t in step_traces if not t.get("is_preloaded")]
|
|
2528
|
+
|
|
2529
|
+
# Build live hierarchy
|
|
2530
|
+
live_nodes = {str(t["trace_id"]).lower(): {**t, "children": []} for t in live_traces}
|
|
2531
|
+
|
|
2532
|
+
# Add a virtual "Response" child node at the end of the virtual tool trace to show the final outcome at the bottom of the tree
|
|
2533
|
+
if replay_outcome is not None:
|
|
2534
|
+
virtual_tool_node_id = None
|
|
2535
|
+
for node_id, node in live_nodes.items():
|
|
2536
|
+
if node.get("type") == "tool" and node.get("name") == tool_name:
|
|
2537
|
+
virtual_tool_node_id = node_id
|
|
2538
|
+
break
|
|
2539
|
+
|
|
2540
|
+
if virtual_tool_node_id:
|
|
2541
|
+
import uuid
|
|
2542
|
+
|
|
2543
|
+
outcome_node_id = str(uuid.uuid4())
|
|
2544
|
+
max_ts = (
|
|
2545
|
+
max([t.get("timestamp", 0.0) for t in live_traces])
|
|
2546
|
+
if live_traces
|
|
2547
|
+
else live_nodes[virtual_tool_node_id].get("timestamp", 0.0)
|
|
2548
|
+
)
|
|
2549
|
+
|
|
2550
|
+
outcome_node = {
|
|
2551
|
+
"trace_id": outcome_node_id,
|
|
2552
|
+
"session_id": session_id,
|
|
2553
|
+
"step_id": live_nodes[virtual_tool_node_id].get("step_id"),
|
|
2554
|
+
"parent_trace_id": live_nodes[virtual_tool_node_id]["trace_id"],
|
|
2555
|
+
"type": "tool",
|
|
2556
|
+
"name": f"{tool_name} Response",
|
|
2557
|
+
"status": (live_nodes[virtual_tool_node_id].get("status", "success")),
|
|
2558
|
+
"timestamp": max_ts + 0.001,
|
|
2559
|
+
"duration": 0.0,
|
|
2560
|
+
"payload": {"result": replay_outcome},
|
|
2561
|
+
"is_preloaded": False,
|
|
2562
|
+
"step_number": step_number,
|
|
2563
|
+
"children": [],
|
|
2564
|
+
}
|
|
2565
|
+
live_nodes[outcome_node_id] = outcome_node
|
|
2566
|
+
|
|
2567
|
+
live_root_nodes = []
|
|
2568
|
+
for node_id, node in live_nodes.items():
|
|
2569
|
+
parent_id = node.get("parent_trace_id")
|
|
2570
|
+
if parent_id:
|
|
2571
|
+
parent_id_str = str(parent_id).lower()
|
|
2572
|
+
if parent_id_str in live_nodes:
|
|
2573
|
+
live_nodes[parent_id_str]["children"].append(node)
|
|
2574
|
+
else:
|
|
2575
|
+
live_root_nodes.append(node)
|
|
2576
|
+
else:
|
|
2577
|
+
live_root_nodes.append(node)
|
|
2578
|
+
live_root_nodes = self.sanitize_nodes(live_root_nodes)
|
|
2579
|
+
live_root_nodes = self.sort_tree_nodes_chronologically(live_root_nodes)
|
|
2580
|
+
|
|
2581
|
+
return {
|
|
2582
|
+
"success": True,
|
|
2583
|
+
"live": live_root_nodes,
|
|
2584
|
+
"replay_id": replay_id,
|
|
2585
|
+
}
|