minimal-agent 0.13.0 → 0.15.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/README.md +6 -5
- package/package.json +60 -4
- package/plugins/computer_use/.claude-plugin/plugin.json +1 -1
- package/plugins/cron-runner/src/commands.js +4 -4
- package/plugins/cron-runner/src/commands.ts +4 -4
- package/plugins/workflow-runner/.claude-plugin/plugin.json +2 -2
- package/plugins/workflow-runner/commands/workflow.md +57 -90
- package/plugins/workflow-runner/commands/workflows.md +4 -4
- package/plugins/workflow-runner/plugin.js +24 -13
- package/plugins/workflow-runner/plugin.ts +28 -13
- package/plugins/workflow-runner/src/discovery.js +160 -0
- package/plugins/workflow-runner/src/discovery.ts +194 -0
- package/plugins/workflow-runner/src/goalAnchor.js +53 -0
- package/plugins/workflow-runner/src/goalAnchor.ts +59 -0
- package/plugins/workflow-runner/src/index.js +197 -206
- package/plugins/workflow-runner/src/index.ts +248 -249
- package/plugins/workflow-runner/src/inputCoercion.js +40 -0
- package/plugins/workflow-runner/src/inputCoercion.ts +43 -0
- package/plugins/workflow-runner/src/journal.js +224 -0
- package/plugins/workflow-runner/src/journal.ts +274 -0
- package/plugins/workflow-runner/src/jsonSchema.js +132 -0
- package/plugins/workflow-runner/src/jsonSchema.ts +162 -0
- package/plugins/workflow-runner/src/moduleResolution.js +64 -0
- package/plugins/workflow-runner/src/moduleResolution.ts +71 -0
- package/plugins/workflow-runner/src/resourceInputs.js +220 -0
- package/plugins/workflow-runner/src/resourceInputs.ts +300 -0
- package/plugins/workflow-runner/src/runner.js +400 -489
- package/plugins/workflow-runner/src/runner.ts +488 -560
- package/plugins/workflow-runner/src/runtime/config.js +46 -0
- package/plugins/workflow-runner/src/runtime/config.ts +69 -0
- package/plugins/workflow-runner/src/runtime/context.js +1304 -0
- package/plugins/workflow-runner/src/runtime/context.ts +1786 -0
- package/plugins/workflow-runner/src/runtime/exec.js +34 -0
- package/plugins/workflow-runner/src/runtime/exec.ts +53 -0
- package/plugins/workflow-runner/src/runtime/flow.js +8 -0
- package/plugins/workflow-runner/src/runtime/flow.ts +12 -0
- package/plugins/workflow-runner/src/runtime/fs.js +27 -0
- package/plugins/workflow-runner/src/runtime/fs.ts +41 -0
- package/plugins/workflow-runner/src/runtime/model.js +6 -0
- package/plugins/workflow-runner/src/runtime/model.ts +8 -0
- package/plugins/workflow-runner/src/runtime/output.js +7 -0
- package/plugins/workflow-runner/src/runtime/output.ts +21 -0
- package/plugins/workflow-runner/src/runtime/scheduler.js +141 -0
- package/plugins/workflow-runner/src/runtime/scheduler.ts +166 -0
- package/plugins/workflow-runner/src/runtime/state.js +16 -0
- package/plugins/workflow-runner/src/runtime/state.ts +19 -0
- package/plugins/workflow-runner/src/runtime/tools.js +3 -0
- package/plugins/workflow-runner/src/runtime/tools.ts +6 -0
- package/plugins/workflow-runner/src/sandbox.js +141 -0
- package/plugins/workflow-runner/src/sandbox.ts +182 -0
- package/plugins/workflow-runner/src/scriptGuard.d.mts +38 -0
- package/plugins/workflow-runner/src/scriptGuard.mjs +449 -0
- package/plugins/workflow-runner/src/taskIdentity.js +151 -307
- package/plugins/workflow-runner/src/taskIdentity.ts +207 -355
- package/plugins/workflow-runner/src/toolRuntime.js +29 -8
- package/plugins/workflow-runner/src/toolRuntime.ts +36 -11
- package/plugins/workflow-runner/src/types.js +7 -211
- package/plugins/workflow-runner/src/types.ts +154 -623
- package/plugins/workflow-runner/src/util.js +47 -0
- package/plugins/workflow-runner/src/util.ts +51 -0
- package/plugins/workflow-runner/src/workflowContext.js +20 -0
- package/plugins/workflow-runner/src/workflowContext.ts +58 -0
- package/plugins/workflow-runner/src/workflowDependencies.js +36 -0
- package/plugins/workflow-runner/src/workflowDependencies.ts +59 -0
- package/plugins/workflow-runner/src/workflowFingerprint.js +118 -0
- package/plugins/workflow-runner/src/workflowFingerprint.ts +122 -0
- package/plugins/workflow-runner/src/workflowState.js +474 -802
- package/plugins/workflow-runner/src/workflowState.ts +575 -970
- package/plugins/workflow-runner/test/api.test.ts +223 -0
- package/plugins/workflow-runner/test/apiCapabilities.test.ts +614 -0
- package/plugins/workflow-runner/test/apiConcurrencyGate.test.ts +104 -0
- package/plugins/workflow-runner/test/apiEcho.test.ts +210 -0
- package/plugins/workflow-runner/test/apiReadText.test.ts +178 -0
- package/plugins/workflow-runner/test/apiResourceLedger.test.ts +184 -0
- package/plugins/workflow-runner/test/childWorkflow.test.ts +304 -0
- package/plugins/workflow-runner/test/discovery.test.ts +234 -0
- package/plugins/workflow-runner/test/goalAnchor.test.ts +106 -0
- package/plugins/workflow-runner/test/goalController.test.ts +317 -0
- package/plugins/workflow-runner/test/journalLanes.test.ts +198 -0
- package/plugins/workflow-runner/test/listResources.test.ts +106 -0
- package/plugins/workflow-runner/test/resourceInputs.test.ts +294 -0
- package/plugins/workflow-runner/test/sandbox.test.ts +126 -0
- package/plugins/workflow-runner/test/scheduler.test.ts +70 -0
- package/plugins/workflow-runner/test/scriptGuard.test.ts +276 -0
- package/plugins/workflow-runner/test/skillDocSync.test.ts +78 -0
- package/plugins/workflow-runner/test/taskIdentity.test.ts +288 -179
- package/plugins/workflow-runner/test/workflowFingerprint.test.ts +106 -0
- package/plugins/workflow-runner/test/workflowState.test.ts +429 -0
- package/skills/pptx/examples/assets/cover-bg.svg +23 -0
- package/skills/pptx/examples/assets/icon-spark.svg +5 -0
- package/skills/ui-design/SKILL.md +5 -2
- package/skills/ui-design/references/layout-composition.md +1 -1
- package/skills/workflow-creator/SKILL.md +344 -0
- package/skills/workflow-creator/assets/template/workflow.mjs +48 -0
- package/skills/workflow-creator/references/api.md +216 -0
- package/skills/workflow-creator/references/migration.md +103 -0
- package/skills/workflow-creator/references/patterns.md +779 -0
- package/skills/workflow-creator/scripts/list-resources.mjs +205 -0
- package/skills/workflow-creator/scripts/scaffold.mjs +148 -0
- package/skills/workflow-creator/scripts/validate.mjs +275 -0
- package/skills/workflow-creator/scripts/workflow-creator.test.mjs +82 -0
- package/src/advisor/advisorCoordinator.js +536 -0
- package/src/advisor/advisorCoordinator.ts +693 -0
- package/src/cli/configMenu.tsx +0 -1
- package/src/cli/print.js +27 -6
- package/src/cli/print.ts +48 -4
- package/src/cli/quickModelSetup.js +12 -1
- package/src/cli/quickModelSetup.tsx +15 -3
- package/src/cli/streamJson.js +5 -6
- package/src/cli/streamJson.ts +8 -4
- package/src/cli/wizards/advisorWizard.js +4 -2
- package/src/cli/wizards/advisorWizard.tsx +8 -2
- package/src/cli/wizards/contextAdvisorWizard.js +5 -4
- package/src/cli/wizards/contextAdvisorWizard.tsx +7 -6
- package/src/cli/wizards/providerPresets.js +18 -8
- package/src/cli/wizards/providerPresets.ts +18 -8
- package/src/collaboration/taskFlow.js +115 -0
- package/src/collaboration/taskFlow.ts +169 -0
- package/src/config/configDefaults.js +41 -1
- package/src/config/configDefaults.ts +43 -1
- package/src/config/configDiagnostics.ts +2 -2
- package/src/config/configFile.js +40 -9
- package/src/config/configFile.ts +60 -11
- package/src/config/configService.js +419 -74
- package/src/config/configService.ts +703 -145
- package/src/config/numericLimits.js +22 -0
- package/src/config/numericLimits.ts +24 -0
- package/src/config.js +1 -0
- package/src/config.ts +3 -2
- package/src/context/budgetPlan.js +1 -1
- package/src/context/budgetPlan.ts +1 -1
- package/src/context/compact.js +7 -2
- package/src/context/compact.ts +9 -2
- package/src/context/microCompactLite.js +212 -13
- package/src/context/microCompactLite.ts +261 -12
- package/src/context/sessionRegistry.js +8 -1
- package/src/context/sessionRegistry.ts +18 -5
- package/src/context/toolResultArchive.js +72 -0
- package/src/context/toolResultArchive.ts +95 -0
- package/src/goal/errors.js +8 -0
- package/src/goal/errors.ts +18 -0
- package/src/goal/goalRuntime.js +344 -0
- package/src/goal/goalRuntime.ts +466 -0
- package/src/goal/goalStore.js +200 -0
- package/src/goal/goalStore.ts +219 -0
- package/src/goal/index.js +4 -0
- package/src/goal/index.ts +4 -0
- package/src/goal/statusMapping.js +71 -0
- package/src/goal/statusMapping.ts +95 -0
- package/src/goal/types.js +7 -0
- package/src/goal/types.ts +179 -0
- package/src/goal/workflowController.js +306 -0
- package/src/goal/workflowController.ts +360 -0
- package/src/harness/evidenceLedger.js +2 -0
- package/src/harness/evidenceLedger.ts +1 -0
- package/src/harness/stallGuard.js +9 -3
- package/src/harness/stallGuard.ts +7 -3
- package/src/images/imageResolver.js +161 -0
- package/src/images/imageResolver.ts +189 -0
- package/src/infra/verification.js +34 -5
- package/src/infra/verification.ts +39 -4
- package/src/llm/client.js +79 -21
- package/src/llm/client.ts +182 -35
- package/src/llm/gatewayClient.js +201 -0
- package/src/llm/gatewayClient.ts +299 -0
- package/src/llm/messageProvenance.js +46 -0
- package/src/llm/messageProvenance.ts +58 -0
- package/src/llm/modelProvider.js +1 -0
- package/src/llm/modelProvider.ts +22 -0
- package/src/llm/providers/anthropicFormat.js +108 -1
- package/src/llm/providers/anthropicFormat.ts +115 -4
- package/src/llm/providers/openaiFormat.js +9 -0
- package/src/llm/providers/openaiFormat.ts +12 -3
- package/src/loop.js +328 -55
- package/src/loop.ts +382 -58
- package/src/main.js +4 -1
- package/src/main.tsx +4 -1
- package/src/managedExecution/index.js +2 -0
- package/src/managedExecution/index.ts +2 -0
- package/src/managedExecution/registry.js +15 -0
- package/src/managedExecution/registry.ts +20 -0
- package/src/managedExecution/types.js +1 -0
- package/src/managedExecution/types.ts +86 -0
- package/src/modelRef.js +9 -0
- package/src/modelRef.ts +31 -0
- package/src/multimodal.js +14 -28
- package/src/multimodal.ts +17 -33
- package/src/net/lanDiscovery.js +65 -0
- package/src/net/lanDiscovery.ts +82 -0
- package/src/plugin-sdk.js +28 -3
- package/src/plugin-sdk.ts +39 -6
- package/src/plugins/pluginApi.ts +16 -1
- package/src/plugins/pluginLoader.js +4 -0
- package/src/plugins/pluginLoader.ts +6 -0
- package/src/plugins/pluginRunner.js +139 -7
- package/src/plugins/pluginRunner.ts +173 -9
- package/src/plugins/slashFallback.js +113 -0
- package/src/plugins/slashFallback.ts +137 -0
- package/src/prompts/policyRegistry.js +12 -32
- package/src/prompts/policyRegistry.ts +15 -32
- package/src/prompts/promptAssembly.js +103 -12
- package/src/prompts/promptAssembly.ts +114 -12
- package/src/prompts/skillList.js +21 -3
- package/src/prompts/skillList.ts +27 -2
- package/src/prompts/stableSystemPrompt.js +18 -0
- package/src/prompts/stableSystemPrompt.ts +33 -0
- package/src/runtime/agentRuntimeContext.js +8 -2
- package/src/runtime/agentRuntimeContext.ts +17 -4
- package/src/session/runAgentTurn.js +78 -12
- package/src/session/runAgentTurn.ts +105 -23
- package/src/session/runTurn.js +10 -9
- package/src/session/runTurn.ts +17 -8
- package/src/session/sessionState.ts +2 -0
- package/src/team/auth.js +186 -0
- package/src/team/auth.ts +298 -0
- package/src/team/cli.js +105 -0
- package/src/team/cli.ts +122 -0
- package/src/team/onlineToolContract.js +64 -0
- package/src/team/onlineToolContract.ts +109 -0
- package/src/team/toolPolicy.js +36 -0
- package/src/team/toolPolicy.ts +75 -0
- package/src/tools/advisor/advisor.js +115 -50
- package/src/tools/advisor/advisor.ts +124 -51
- package/src/tools/advisor/advisorDecision.js +71 -0
- package/src/tools/advisor/advisorDecision.ts +85 -0
- package/src/tools/agent/agent.js +18 -5
- package/src/tools/agent/agent.ts +21 -7
- package/src/tools/agent/agentControl.js +246 -0
- package/src/tools/agent/agentControl.ts +284 -0
- package/src/tools/agent/agentTaskService.js +40 -1
- package/src/tools/agent/agentTaskService.ts +44 -1
- package/src/tools/agent/agentTypes.js +14 -2
- package/src/tools/agent/agentTypes.ts +15 -2
- package/src/tools/applypatch/applyPatch.js +362 -0
- package/src/tools/applypatch/applyPatch.ts +428 -0
- package/src/tools/bash/bash.js +22 -15
- package/src/tools/bash/bash.ts +23 -17
- package/src/tools/capabilities.js +10 -5
- package/src/tools/capabilities.ts +10 -5
- package/src/tools/computeruse/computerIntent.js +32 -0
- package/src/tools/computeruse/computerIntent.ts +46 -0
- package/src/tools/computeruse/telemetryLog.js +12 -0
- package/src/tools/computeruse/telemetryLog.ts +16 -0
- package/src/tools/computeruse/vision/telemetry.js +1 -0
- package/src/tools/computeruse/vision/telemetry.ts +1 -0
- package/src/tools/effectiveToolSet.js +4 -0
- package/src/tools/effectiveToolSet.ts +4 -0
- package/src/tools/index.js +21 -2
- package/src/tools/index.ts +33 -2
- package/src/tools/presentresult/presentresult.js +6 -15
- package/src/tools/presentresult/presentresult.ts +6 -15
- package/src/tools/read/read.js +62 -9
- package/src/tools/read/read.ts +63 -9
- package/src/tools/shared/fileUtils.js +1 -2
- package/src/tools/shared/fileUtils.ts +1 -2
- package/src/tools/shared/toolOutputStore.js +23 -1
- package/src/tools/shared/toolOutputStore.ts +22 -1
- package/src/tools/skill/skill.js +9 -3
- package/src/tools/skill/skill.ts +8 -4
- package/src/tools/toolPromptGuides.js +125 -0
- package/src/tools/toolPromptGuides.ts +146 -0
- package/src/tools/webbrowser/agentBrowserPath.js +19 -9
- package/src/tools/webbrowser/agentBrowserPath.ts +70 -28
- package/src/tools/webbrowser/browser.js +27 -11
- package/src/tools/webbrowser/browser.ts +68 -24
- package/src/tools/webbrowser/browserAutomation.js +329 -0
- package/src/tools/webbrowser/browserAutomation.ts +467 -0
- package/src/tools/webbrowser/browserAxState.js +97 -0
- package/src/tools/webbrowser/browserAxState.ts +142 -0
- package/src/tools/webbrowser/browserBatch.js +1578 -0
- package/src/tools/webbrowser/browserBatch.ts +2382 -0
- package/src/tools/webbrowser/browserCdp.js +208 -0
- package/src/tools/webbrowser/browserCdp.ts +255 -0
- package/src/tools/webbrowser/browserConditions.js +24 -0
- package/src/tools/webbrowser/browserConditions.ts +45 -0
- package/src/tools/webbrowser/browserContent.js +281 -0
- package/src/tools/webbrowser/browserContent.ts +438 -0
- package/src/tools/webbrowser/browserControl.js +131 -34
- package/src/tools/webbrowser/browserControl.ts +210 -56
- package/src/tools/webbrowser/browserDaemon.js +22 -14
- package/src/tools/webbrowser/browserDaemon.ts +54 -21
- package/src/tools/webbrowser/browserFrames.js +47 -0
- package/src/tools/webbrowser/browserFrames.ts +54 -0
- package/src/tools/webbrowser/browserInteractionResolver.js +17 -0
- package/src/tools/webbrowser/browserInteractionResolver.ts +40 -0
- package/src/tools/webbrowser/browserLocator.js +140 -0
- package/src/tools/webbrowser/browserLocator.ts +194 -0
- package/src/tools/webbrowser/browserOutcome.js +10 -17
- package/src/tools/webbrowser/browserOutcome.ts +48 -31
- package/src/tools/webbrowser/browserRecovery.js +58 -0
- package/src/tools/webbrowser/browserRecovery.ts +83 -0
- package/src/tools/webbrowser/browserRunRegistry.js +35 -3
- package/src/tools/webbrowser/browserRunRegistry.ts +91 -15
- package/src/tools/webbrowser/browserService.js +28 -2
- package/src/tools/webbrowser/browserService.ts +49 -9
- package/src/tools/webbrowser/browserStream.js +132 -33
- package/src/tools/webbrowser/browserStream.ts +224 -50
- package/src/tools/webbrowser/browserTabs.js +48 -15
- package/src/tools/webbrowser/browserTabs.ts +178 -96
- package/src/tools/webbrowser/webbrowser.js +679 -144
- package/src/tools/webbrowser/webbrowser.ts +975 -236
- package/src/tools/webfetch/preapproved.js +0 -2
- package/src/tools/webfetch/preapproved.ts +0 -2
- package/src/tools/websearch/websearch.js +129 -8
- package/src/tools/websearch/websearch.ts +138 -6
- package/src/types.ts +459 -40
- package/src/ui/App.js +377 -58
- package/src/ui/App.tsx +576 -111
- package/src/ui/LiveArea.js +14 -8
- package/src/ui/LiveArea.tsx +14 -10
- package/src/ui/MessageList.js +63 -20
- package/src/ui/MessageList.tsx +101 -16
- package/src/ui/ModelPicker.js +7 -4
- package/src/ui/ModelPicker.tsx +55 -8
- package/src/ui/hooks/useChat.js +152 -163
- package/src/ui/hooks/useChat.ts +396 -334
- package/src/ui/loginCommand.js +56 -0
- package/src/ui/loginCommand.ts +63 -0
- package/src/ui/toolProgress.js +41 -0
- package/src/ui/toolProgress.ts +44 -0
- package/src/web/browserBridge.js +20 -2
- package/src/web/browserBridge.ts +20 -2
- package/src/web/protocol.ts +1897 -194
- package/workflows/adaptive-research/workflow.mjs +86 -0
- package/workflows/e2e-capabilities/workflow.mjs +102 -0
- package/workflows/e2e-llm/workflow.mjs +52 -0
- package/workflows/e2e-smoke/workflow.mjs +59 -0
- package/workflows/e2e-vision-schema/README.md +48 -0
- package/workflows/e2e-vision-schema/workflow.mjs +149 -0
- package/workflows/e2e-write-greet/workflow.mjs +37 -0
- package/workflows/file-census/README.md +14 -0
- package/workflows/file-census/scripts/.gitkeep +2 -0
- package/workflows/file-census/workflow.mjs +110 -0
- package/workflows/goal/README.md +166 -0
- package/workflows/goal/prompts/audit.md +57 -0
- package/workflows/goal/prompts/contract.md +56 -0
- package/workflows/goal/prompts/iterate.md +37 -0
- package/workflows/goal/workflow.mjs +501 -0
- package/workflows/illustrate/README.md +90 -0
- package/workflows/illustrate/assets/engines.json +20 -0
- package/workflows/illustrate/assets/extract-baidu.js +21 -0
- package/workflows/illustrate/assets/extract-bing.js +33 -0
- package/workflows/illustrate/assets/extract-google.js +31 -0
- package/workflows/illustrate/workflow.mjs +301 -0
- package/workflows/plan/workflow.mjs +389 -0
- package/workflows/question-crops/README.md +35 -0
- package/workflows/question-crops/prompts/inventory.md +23 -0
- package/workflows/question-crops/scripts/image-ops.mjs +305 -0
- package/workflows/question-crops/scripts/image-ops.test.mjs +95 -0
- package/workflows/question-crops/workflow.mjs +172 -0
- package/workflows/ralph-loop/workflow.mjs +162 -0
- package/workflows/research/README.md +74 -0
- package/workflows/research/prompts/s0-goal.md +43 -0
- package/workflows/research/prompts/s1-early-exit.md +22 -0
- package/workflows/research/prompts/s1-map.md +58 -0
- package/workflows/research/prompts/s2-options.md +53 -0
- package/workflows/research/prompts/s3-deep-dive.md +56 -0
- package/workflows/research/prompts/s4-verify.md +74 -0
- package/workflows/research/prompts/s5-synthesis.md +81 -0
- package/workflows/research/prompts/s5b-fill-gaps.md +32 -0
- package/workflows/research/prompts/s6-package.md +32 -0
- package/workflows/research/scripts/build-report.mjs +98 -0
- package/workflows/research/workflow.mjs +288 -0
- package/workflows/wechat-reading-shots/prompts/collect-params.md +16 -0
- package/workflows/wechat-reading-shots/prompts/evaluate.md +20 -0
- package/workflows/wechat-reading-shots/prompts/open-book.md +28 -0
- package/workflows/wechat-reading-shots/prompts/read-page.md +13 -0
- package/workflows/wechat-reading-shots/prompts/turn-batch.md +24 -0
- package/workflows/wechat-reading-shots/test/control-flow.test.ts +329 -0
- package/workflows/wechat-reading-shots/workflow.mjs +266 -0
- package/workflows/wrong-question-docx/README.md +55 -0
- package/workflows/wrong-question-docx/prompts/figure-qa.md +8 -0
- package/workflows/wrong-question-docx/prompts/transcribe.md +25 -0
- package/workflows/wrong-question-docx/scripts/workbook.mjs +857 -0
- package/workflows/wrong-question-docx/test/control-flow.test.ts +165 -0
- package/workflows/wrong-question-docx/test/workbook.test.mjs +303 -0
- package/workflows/wrong-question-docx/workflow.mjs +485 -0
- package/plugins/workflow-runner/src/agentCommon.js +0 -354
- package/plugins/workflow-runner/src/agentCommon.ts +0 -435
- package/plugins/workflow-runner/src/controlFlow/branch.js +0 -40
- package/plugins/workflow-runner/src/controlFlow/branch.ts +0 -46
- package/plugins/workflow-runner/src/controlFlow/loop.js +0 -211
- package/plugins/workflow-runner/src/controlFlow/loop.ts +0 -248
- package/plugins/workflow-runner/src/controlFlow/pause.js +0 -129
- package/plugins/workflow-runner/src/controlFlow/pause.ts +0 -141
- package/plugins/workflow-runner/src/controlFlow/signal.js +0 -21
- package/plugins/workflow-runner/src/controlFlow/signal.ts +0 -29
- package/plugins/workflow-runner/src/controlFlow/types.js +0 -18
- package/plugins/workflow-runner/src/controlFlow/types.ts +0 -63
- package/plugins/workflow-runner/src/dslManifest.js +0 -72
- package/plugins/workflow-runner/src/dslManifest.ts +0 -127
- package/plugins/workflow-runner/src/expressions.js +0 -639
- package/plugins/workflow-runner/src/expressions.ts +0 -640
- package/plugins/workflow-runner/src/loader.js +0 -573
- package/plugins/workflow-runner/src/loader.ts +0 -721
- package/plugins/workflow-runner/src/multimodal.js +0 -38
- package/plugins/workflow-runner/src/multimodal.ts +0 -64
- package/plugins/workflow-runner/src/recovery.js +0 -126
- package/plugins/workflow-runner/src/recovery.ts +0 -156
- package/plugins/workflow-runner/src/stepExecutors/agent.js +0 -173
- package/plugins/workflow-runner/src/stepExecutors/agent.ts +0 -234
- package/plugins/workflow-runner/src/stepExecutors/assert.js +0 -42
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -52
- package/plugins/workflow-runner/src/stepExecutors/echo.js +0 -31
- package/plugins/workflow-runner/src/stepExecutors/echo.ts +0 -38
- package/plugins/workflow-runner/src/stepExecutors/llm.js +0 -99
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -133
- package/plugins/workflow-runner/src/stepExecutors/set.js +0 -49
- package/plugins/workflow-runner/src/stepExecutors/set.ts +0 -56
- package/plugins/workflow-runner/src/stepExecutors/skill.js +0 -111
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -140
- package/plugins/workflow-runner/src/stepExecutors/tool.js +0 -227
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +0 -274
- package/plugins/workflow-runner/src/stepExecutors/verify.js +0 -61
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +0 -72
- package/plugins/workflow-runner/src/stepPipeline.js +0 -277
- package/plugins/workflow-runner/src/stepPipeline.ts +0 -366
- package/plugins/workflow-runner/test/agent.test.ts +0 -587
- package/plugins/workflow-runner/test/agentLlmError.test.ts +0 -373
- package/plugins/workflow-runner/test/bashBlacklist.sweep.test.ts +0 -65
- package/plugins/workflow-runner/test/captureMisbind.test.ts +0 -99
- package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -167
- package/plugins/workflow-runner/test/context-files.test.ts +0 -320
- package/plugins/workflow-runner/test/e2e.test.ts +0 -370
- package/plugins/workflow-runner/test/e2eNewLayout.test.ts +0 -314
- package/plugins/workflow-runner/test/engineRobustness.test.ts +0 -234
- package/plugins/workflow-runner/test/expressions.test.ts +0 -439
- package/plugins/workflow-runner/test/foundation.test.ts +0 -126
- package/plugins/workflow-runner/test/graceful.test.ts +0 -139
- package/plugins/workflow-runner/test/greedyInputs.load.test.ts +0 -47
- package/plugins/workflow-runner/test/hostCapability.test.ts +0 -108
- package/plugins/workflow-runner/test/illustrate.load.test.ts +0 -183
- package/plugins/workflow-runner/test/jsonPayload.test.ts +0 -83
- package/plugins/workflow-runner/test/loader.test.ts +0 -617
- package/plugins/workflow-runner/test/maxTurnsRetryDelay.test.ts +0 -33
- package/plugins/workflow-runner/test/multimodal.test.ts +0 -342
- package/plugins/workflow-runner/test/output-schema.test.ts +0 -213
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +0 -106
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -269
- package/plugins/workflow-runner/test/ppt.load.test.ts +0 -1220
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +0 -221
- package/plugins/workflow-runner/test/recovery.test.ts +0 -265
- package/plugins/workflow-runner/test/research.load.test.ts +0 -328
- package/plugins/workflow-runner/test/runner.test.ts +0 -911
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +0 -436
- package/plugins/workflow-runner/test/skillTurnDone.test.ts +0 -71
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +0 -198
- package/plugins/workflow-runner/test/stepPipeline.test.ts +0 -114
- package/plugins/workflow-runner/test/taskArbitration.test.ts +0 -354
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +0 -324
- package/plugins/workflow-runner/test/toolArtifactOverwrite.test.ts +0 -221
- package/plugins/workflow-runner/test/toolCapabilityBoundary.test.ts +0 -29
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +0 -168
- package/plugins/workflow-runner/test/webppt.load.test.ts +0 -250
- package/plugins/workflow-runner/test/word.load.test.ts +0 -208
- package/plugins/workflow-runner/test/workflows.smoke.test.ts +0 -76
- package/plugins/workflow-runner/test/yamlGuardConditions.test.ts +0 -207
- package/src/advisorState.js +0 -22
- package/src/cli/configWizard.js +0 -293
- package/src/tools/toolProgressBridge.js +0 -48
- package/workflows/adaptive-research.yaml +0 -50
- package/workflows/book-review-short.yaml +0 -125
- package/workflows/e2e-write-greet.yaml +0 -31
- package/workflows/illustrate.yaml +0 -227
- package/workflows/plan.yaml +0 -470
- package/workflows/ppt.yaml +0 -1670
- package/workflows/ralph-loop.yaml +0 -201
- package/workflows/research.yaml +0 -873
- package/workflows/schema.json +0 -176
- package/workflows/webppt.yaml +0 -496
- package/workflows/wechat-reading-shots.yaml +0 -315
- package/workflows/word.yaml +0 -720
- package/workflows/youtube-shorts.yaml +0 -202
- /package/workflows/{research.md → research/DESIGN.md} +0 -0
|
@@ -0,0 +1,1578 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { copyFile, readdir, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
4
|
+
import { basename, extname, isAbsolute, join, relative } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { getWorkingDir } from '../../bootstrap/workingDir.js';
|
|
7
|
+
import { configService } from '../../config/configService.js';
|
|
8
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
9
|
+
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
10
|
+
import { resolveAgentBrowserPath } from './agentBrowserPath.js';
|
|
11
|
+
import { BROWSER_SESSION, browserArtifactDir, createArtifactPath, sessionArgs, spawnRunner, } from './browser.js';
|
|
12
|
+
import { BROWSER_TAB_MARKER_KEY, configureDownloads, getBrowserAutomation, } from './browserAutomation.js';
|
|
13
|
+
import { conciseAxTree, diffAxTree, normalizeAxTree, } from './browserAxState.js';
|
|
14
|
+
import { conditionToScript, } from './browserConditions.js';
|
|
15
|
+
import { CLIPBOARD_READ_EXPRESSION, CLIPBOARD_READ_TEXT_EXPRESSION, PAGE_ASSETS_EXPRESSION, browserAssetFetchExpression, bundlePageAssets, clipboardWriteExpression, clipboardWriteTextExpression, googleWorkspaceExportExpression, markdownFromPageContent, pageContentExpression, readUrlsInBackground, } from './browserContent.js';
|
|
16
|
+
import { detectBrowserChallenge } from './browserControl.js';
|
|
17
|
+
import { ensureDaemon } from './browserDaemon.js';
|
|
18
|
+
import { normalizeFrameInfo, normalizeFrameTree, } from './browserFrames.js';
|
|
19
|
+
import { resolveInteractionStrategy } from './browserInteractionResolver.js';
|
|
20
|
+
import { buildLocatorExpression, buildTargetExpression, } from './browserLocator.js';
|
|
21
|
+
import { withBrowserRecovery } from './browserRecovery.js';
|
|
22
|
+
import { browserRunRegistry } from './browserRunRegistry.js';
|
|
23
|
+
import { browserService } from './browserService.js';
|
|
24
|
+
import { getBrowserStream } from './browserStream.js';
|
|
25
|
+
import { claimConversationTabById, ensureConversationTab, parseTabList, } from './browserTabs.js';
|
|
26
|
+
const locatorLeafSchema = z.object({
|
|
27
|
+
by: z.enum([
|
|
28
|
+
'css',
|
|
29
|
+
'ax',
|
|
30
|
+
'role',
|
|
31
|
+
'text',
|
|
32
|
+
'label',
|
|
33
|
+
'placeholder',
|
|
34
|
+
'alt',
|
|
35
|
+
'title',
|
|
36
|
+
'testid',
|
|
37
|
+
]),
|
|
38
|
+
value: z.string(),
|
|
39
|
+
name: z.string().optional(),
|
|
40
|
+
exact: z.boolean().optional(),
|
|
41
|
+
strict: z.boolean().optional(),
|
|
42
|
+
nth: z.number().int().nonnegative().optional(),
|
|
43
|
+
has_text: z.string().optional(),
|
|
44
|
+
generation: z.number().int().positive().optional(),
|
|
45
|
+
frameId: z.string().min(1).optional(),
|
|
46
|
+
frame_id: z.string().min(1).optional(),
|
|
47
|
+
});
|
|
48
|
+
// Keep the public JSON Schema finite. A single nested scope and a finite frame
|
|
49
|
+
// chain cover the browser pages we support while avoiding recursive-schema
|
|
50
|
+
// fallbacks that turn the model contract into `any`.
|
|
51
|
+
const locatorSchema = z.object({
|
|
52
|
+
...locatorLeafSchema.shape,
|
|
53
|
+
within: locatorLeafSchema.optional(),
|
|
54
|
+
frame: z.array(locatorLeafSchema).optional(),
|
|
55
|
+
framePath: z.array(locatorLeafSchema).optional(),
|
|
56
|
+
frame_path: z.array(locatorLeafSchema).optional(),
|
|
57
|
+
});
|
|
58
|
+
const expectationSchema = z.object({
|
|
59
|
+
navigation: z
|
|
60
|
+
.object({
|
|
61
|
+
url: z.string().optional(),
|
|
62
|
+
wait_until: z.enum(['commit', 'domcontentloaded', 'load']).optional(),
|
|
63
|
+
})
|
|
64
|
+
.optional(),
|
|
65
|
+
new_tab: z.object({ url: z.string().optional() }).optional(),
|
|
66
|
+
download: z.object({ filename: z.string().optional() }).optional(),
|
|
67
|
+
file_chooser: z.object({ files: z.array(z.string()).optional() }).optional(),
|
|
68
|
+
conditions: z
|
|
69
|
+
.array(z.union([
|
|
70
|
+
z.object({ type: z.literal('url'), pattern: z.string() }),
|
|
71
|
+
z.object({
|
|
72
|
+
type: z.literal('text'),
|
|
73
|
+
value: z.string(),
|
|
74
|
+
exact: z.boolean().optional(),
|
|
75
|
+
}),
|
|
76
|
+
z.object({
|
|
77
|
+
type: z.literal('element-visible'),
|
|
78
|
+
locator: locatorSchema.optional(),
|
|
79
|
+
selector: z.string().optional(),
|
|
80
|
+
}),
|
|
81
|
+
z.object({
|
|
82
|
+
type: z.literal('element-hidden'),
|
|
83
|
+
locator: locatorSchema.optional(),
|
|
84
|
+
selector: z.string().optional(),
|
|
85
|
+
}),
|
|
86
|
+
z.object({
|
|
87
|
+
type: z.literal('load-state'),
|
|
88
|
+
state: z.enum(['domcontentloaded', 'load', 'networkidle']),
|
|
89
|
+
}),
|
|
90
|
+
z.object({ type: z.literal('page-change') }),
|
|
91
|
+
z.object({
|
|
92
|
+
type: z.literal('download'),
|
|
93
|
+
filename: z.string().optional(),
|
|
94
|
+
}),
|
|
95
|
+
z.object({ type: z.literal('new-tab'), url: z.string().optional() }),
|
|
96
|
+
z.object({
|
|
97
|
+
type: z.literal('dialog'),
|
|
98
|
+
dialogType: z
|
|
99
|
+
.enum(['alert', 'confirm', 'prompt', 'beforeunload'])
|
|
100
|
+
.optional(),
|
|
101
|
+
}),
|
|
102
|
+
]))
|
|
103
|
+
.max(8)
|
|
104
|
+
.optional(),
|
|
105
|
+
});
|
|
106
|
+
const stepSchema = z.object({
|
|
107
|
+
id: z.string().min(1).max(100).optional(),
|
|
108
|
+
action: z.enum([
|
|
109
|
+
'navigate',
|
|
110
|
+
'click',
|
|
111
|
+
'doubleClick',
|
|
112
|
+
'fill',
|
|
113
|
+
'type',
|
|
114
|
+
'hover',
|
|
115
|
+
'press',
|
|
116
|
+
'check',
|
|
117
|
+
'uncheck',
|
|
118
|
+
'setChecked',
|
|
119
|
+
'getState',
|
|
120
|
+
'drag',
|
|
121
|
+
'wait',
|
|
122
|
+
'waitForURL',
|
|
123
|
+
'waitForDownload',
|
|
124
|
+
'waitForFileChooser',
|
|
125
|
+
'snapshot',
|
|
126
|
+
'frameInfo',
|
|
127
|
+
'visibleDom',
|
|
128
|
+
'clickNode',
|
|
129
|
+
'typeNode',
|
|
130
|
+
'scrollNode',
|
|
131
|
+
'upload',
|
|
132
|
+
'pageAssets',
|
|
133
|
+
'listPageAssets',
|
|
134
|
+
'bundlePageAssets',
|
|
135
|
+
'exportContent',
|
|
136
|
+
'readUrls',
|
|
137
|
+
'clipboardReadText',
|
|
138
|
+
'clipboardWriteText',
|
|
139
|
+
'clipboardRead',
|
|
140
|
+
'clipboardWrite',
|
|
141
|
+
]),
|
|
142
|
+
selector: z.string().optional(),
|
|
143
|
+
locator: locatorSchema.optional(),
|
|
144
|
+
trigger_selector: z.string().optional(),
|
|
145
|
+
trigger_locator: locatorSchema.optional(),
|
|
146
|
+
value: z.string().optional(),
|
|
147
|
+
files: z.array(z.string()).optional(),
|
|
148
|
+
key: z.string().optional(),
|
|
149
|
+
node_id: z.number().int().nonnegative().optional(),
|
|
150
|
+
checked: z.boolean().optional(),
|
|
151
|
+
url: z.string().optional(),
|
|
152
|
+
wait_ms: z.number().int().min(1).max(120_000).optional(),
|
|
153
|
+
drag_path: z
|
|
154
|
+
.array(z.object({ x: z.number(), y: z.number() }))
|
|
155
|
+
.min(2)
|
|
156
|
+
.optional(),
|
|
157
|
+
expect: expectationSchema.optional(),
|
|
158
|
+
intent: z.enum(['normal', 'submit']).optional(),
|
|
159
|
+
export_format: z
|
|
160
|
+
.enum([
|
|
161
|
+
'text',
|
|
162
|
+
'txt',
|
|
163
|
+
'markdown',
|
|
164
|
+
'md',
|
|
165
|
+
'html',
|
|
166
|
+
'json',
|
|
167
|
+
'page',
|
|
168
|
+
'google-workspace',
|
|
169
|
+
'youtube-transcript',
|
|
170
|
+
'pdf',
|
|
171
|
+
'docx',
|
|
172
|
+
'xlsx',
|
|
173
|
+
'csv',
|
|
174
|
+
'pptx',
|
|
175
|
+
])
|
|
176
|
+
.optional(),
|
|
177
|
+
urls: z.array(z.string().url()).max(20).optional(),
|
|
178
|
+
concurrency: z.number().int().min(1).max(8).optional(),
|
|
179
|
+
content_type: z.enum(['text', 'html', 'domSnapshot']).optional(),
|
|
180
|
+
bundle_dir: z.string().optional(),
|
|
181
|
+
bundle_assets: z.boolean().optional(),
|
|
182
|
+
clipboard_text: z.string().optional(),
|
|
183
|
+
clipboard_items: z
|
|
184
|
+
.array(z.object({ type: z.string().min(1), base64: z.string() }))
|
|
185
|
+
.optional(),
|
|
186
|
+
is_multiple: z.boolean().optional(),
|
|
187
|
+
artifact: z
|
|
188
|
+
.object({
|
|
189
|
+
save_as: z.string().min(1).max(255),
|
|
190
|
+
label: z.string().max(200).optional(),
|
|
191
|
+
})
|
|
192
|
+
.optional(),
|
|
193
|
+
});
|
|
194
|
+
const inputSchema = z.object({
|
|
195
|
+
tab_id: z.string().optional(),
|
|
196
|
+
retention: z
|
|
197
|
+
.enum(['ephemeral', 'conversation', 'handoff', 'deliverable'])
|
|
198
|
+
.optional(),
|
|
199
|
+
tab_title: z.string().max(200).optional(),
|
|
200
|
+
steps: z.array(stepSchema).min(1).max(50),
|
|
201
|
+
stop_on_error: z.boolean().default(true),
|
|
202
|
+
timeout_ms: z.number().int().min(1_000).max(300_000).default(120_000),
|
|
203
|
+
final_snapshot: z.enum(['none', 'auto', 'full', 'diff']).default('auto'),
|
|
204
|
+
capture: z.enum(['none', 'state', 'screenshot', 'both']).default('state'),
|
|
205
|
+
});
|
|
206
|
+
const snapshots = new Map();
|
|
207
|
+
const MAX_SNAPSHOTS = 256;
|
|
208
|
+
const SNAPSHOT_TTL_MS = 15 * 60_000;
|
|
209
|
+
function jsString(value) {
|
|
210
|
+
return JSON.stringify(value);
|
|
211
|
+
}
|
|
212
|
+
const DOWNLOAD_MIME_BY_EXT = {
|
|
213
|
+
'.csv': 'text/csv',
|
|
214
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
215
|
+
'.pdf': 'application/pdf',
|
|
216
|
+
'.png': 'image/png',
|
|
217
|
+
'.jpg': 'image/jpeg',
|
|
218
|
+
'.jpeg': 'image/jpeg',
|
|
219
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
220
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
221
|
+
'.zip': 'application/zip',
|
|
222
|
+
};
|
|
223
|
+
async function hashFile(path) {
|
|
224
|
+
const hash = createHash('sha256');
|
|
225
|
+
for await (const chunk of createReadStream(path))
|
|
226
|
+
hash.update(chunk);
|
|
227
|
+
return hash.digest('hex');
|
|
228
|
+
}
|
|
229
|
+
async function materializeDownloadArtifact(sourcePath, saveAs) {
|
|
230
|
+
const name = basename(saveAs);
|
|
231
|
+
if (!name || name !== saveAs || name === '.' || name === '..')
|
|
232
|
+
throw new Error('artifact.save_as 只能是工作区根目录下的文件名');
|
|
233
|
+
const root = await realpath(getWorkingDir()).catch(() => getWorkingDir());
|
|
234
|
+
const destination = join(root, name);
|
|
235
|
+
const source = await realpath(sourcePath);
|
|
236
|
+
await stat(source);
|
|
237
|
+
const temporary = join(root, `.${name}.${randomUUID()}.partial`);
|
|
238
|
+
try {
|
|
239
|
+
await copyFile(source, temporary);
|
|
240
|
+
await rename(temporary, destination);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
244
|
+
}
|
|
245
|
+
const info = await stat(destination);
|
|
246
|
+
const relativePath = name;
|
|
247
|
+
return {
|
|
248
|
+
status: 'final',
|
|
249
|
+
summary: `已下载并提交 ${name}`,
|
|
250
|
+
files: [
|
|
251
|
+
{
|
|
252
|
+
relativePath,
|
|
253
|
+
sha256: await hashFile(destination),
|
|
254
|
+
mime: DOWNLOAD_MIME_BY_EXT[extname(name).toLowerCase()] ??
|
|
255
|
+
'application/octet-stream',
|
|
256
|
+
size: info.size,
|
|
257
|
+
modifiedAt: info.mtimeMs,
|
|
258
|
+
verification: 'not_run',
|
|
259
|
+
purpose: 'deliverable',
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
warnings: [],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function waitForDownloadedFile(directory, item, timeout, signal) {
|
|
266
|
+
const deadline = Date.now() + timeout;
|
|
267
|
+
let previousPath = '';
|
|
268
|
+
let previousSize = -1;
|
|
269
|
+
let stableSamples = 0;
|
|
270
|
+
while (Date.now() < deadline) {
|
|
271
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
272
|
+
const candidates = await Promise.all(entries
|
|
273
|
+
.filter((entry) => entry.isFile() && !entry.name.endsWith('.crdownload'))
|
|
274
|
+
.map(async (entry) => {
|
|
275
|
+
const path = join(directory, entry.name);
|
|
276
|
+
const info = await stat(path).catch(() => null);
|
|
277
|
+
return info
|
|
278
|
+
? { path, name: entry.name, mtimeMs: info.mtimeMs, size: info.size }
|
|
279
|
+
: null;
|
|
280
|
+
}));
|
|
281
|
+
const usable = candidates
|
|
282
|
+
.filter((candidate) => candidate !== null)
|
|
283
|
+
.filter((candidate) => candidate.mtimeMs >= item.startedAt - 1_000)
|
|
284
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
285
|
+
const exact = usable.find((candidate) => candidate.name === item.filename);
|
|
286
|
+
const candidate = exact ?? usable[0];
|
|
287
|
+
if (candidate) {
|
|
288
|
+
const expectedSize = item.totalBytes;
|
|
289
|
+
const sizeMatches = expectedSize === undefined ||
|
|
290
|
+
expectedSize <= 0 ||
|
|
291
|
+
candidate.size === expectedSize;
|
|
292
|
+
if (sizeMatches) {
|
|
293
|
+
if (candidate.path === previousPath && candidate.size === previousSize)
|
|
294
|
+
stableSamples += 1;
|
|
295
|
+
else {
|
|
296
|
+
previousPath = candidate.path;
|
|
297
|
+
previousSize = candidate.size;
|
|
298
|
+
stableSamples = 1;
|
|
299
|
+
}
|
|
300
|
+
if (stableSamples >= 2)
|
|
301
|
+
return candidate.path;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await sleepWithSignal(100, signal);
|
|
305
|
+
}
|
|
306
|
+
throw new Error(`下载已完成但文件未稳定落盘:${item.filename}`);
|
|
307
|
+
}
|
|
308
|
+
async function materializeExportArtifact(value, format, saveAs) {
|
|
309
|
+
const name = basename(saveAs);
|
|
310
|
+
if (!name || name !== saveAs || name === '.' || name === '..')
|
|
311
|
+
throw new Error('artifact.save_as 只能是工作区根目录下的文件名');
|
|
312
|
+
const root = await realpath(getWorkingDir()).catch(() => getWorkingDir());
|
|
313
|
+
const destination = join(root, name);
|
|
314
|
+
let bytes;
|
|
315
|
+
if (value &&
|
|
316
|
+
typeof value === 'object' &&
|
|
317
|
+
'__browser_pdf_base64' in value &&
|
|
318
|
+
typeof value
|
|
319
|
+
.__browser_pdf_base64 === 'string') {
|
|
320
|
+
bytes = Buffer.from(value.__browser_pdf_base64, 'base64');
|
|
321
|
+
}
|
|
322
|
+
else if (value &&
|
|
323
|
+
typeof value === 'object' &&
|
|
324
|
+
'__browser_binary_base64' in value &&
|
|
325
|
+
typeof value
|
|
326
|
+
.__browser_binary_base64 === 'string') {
|
|
327
|
+
bytes = Buffer.from(value.__browser_binary_base64, 'base64');
|
|
328
|
+
}
|
|
329
|
+
else if (format === 'html' && value && typeof value === 'object') {
|
|
330
|
+
bytes = Buffer.from(String(value.html ?? ''), 'utf8');
|
|
331
|
+
}
|
|
332
|
+
else if (format === 'json') {
|
|
333
|
+
bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
334
|
+
}
|
|
335
|
+
else if (typeof value === 'string') {
|
|
336
|
+
bytes = Buffer.from(value.endsWith('\n') ? value : `${value}\n`, 'utf8');
|
|
337
|
+
}
|
|
338
|
+
else if (value && typeof value === 'object' && 'text' in value) {
|
|
339
|
+
bytes = Buffer.from(`${String(value.text ?? '')}\n`, 'utf8');
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
343
|
+
}
|
|
344
|
+
const temporary = join(root, `.${name}.${randomUUID()}.partial`);
|
|
345
|
+
try {
|
|
346
|
+
await writeFile(temporary, bytes, { mode: 0o600 });
|
|
347
|
+
await rename(temporary, destination);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
351
|
+
}
|
|
352
|
+
const info = await stat(destination);
|
|
353
|
+
return {
|
|
354
|
+
status: 'final',
|
|
355
|
+
summary: `已导出并提交 ${name}`,
|
|
356
|
+
files: [
|
|
357
|
+
{
|
|
358
|
+
relativePath: name,
|
|
359
|
+
sha256: await hashFile(destination),
|
|
360
|
+
mime: DOWNLOAD_MIME_BY_EXT[extname(name).toLowerCase()] ??
|
|
361
|
+
'application/octet-stream',
|
|
362
|
+
size: info.size,
|
|
363
|
+
modifiedAt: info.mtimeMs,
|
|
364
|
+
verification: 'not_run',
|
|
365
|
+
purpose: 'deliverable',
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
warnings: [],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
export function mergeArtifactManifests(current, next) {
|
|
372
|
+
if (!current)
|
|
373
|
+
return next;
|
|
374
|
+
const files = new Map();
|
|
375
|
+
for (const file of [...current.files, ...next.files]) {
|
|
376
|
+
// A repeated save_as represents the latest version of the same artifact;
|
|
377
|
+
// distinct paths remain distinct formal deliverables.
|
|
378
|
+
files.set(file.relativePath, file);
|
|
379
|
+
}
|
|
380
|
+
const links = new Map();
|
|
381
|
+
for (const link of [...(current.links ?? []), ...(next.links ?? [])])
|
|
382
|
+
links.set(link.url, link);
|
|
383
|
+
const allFiles = [...files.values()];
|
|
384
|
+
const warnings = [...new Set([...current.warnings, ...next.warnings])];
|
|
385
|
+
return {
|
|
386
|
+
status: current.status === 'final' && next.status === 'final' && !warnings.length
|
|
387
|
+
? 'final'
|
|
388
|
+
: 'draft',
|
|
389
|
+
summary: `已提交 ${allFiles.length} 个成果文件`,
|
|
390
|
+
files: allFiles,
|
|
391
|
+
...(links.size ? { links: [...links.values()] } : {}),
|
|
392
|
+
warnings,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async function evaluate(cdp, expression, signal, frameId) {
|
|
396
|
+
if (frameId) {
|
|
397
|
+
const result = (await cdp.evaluateInFrame(frameId, expression, signal));
|
|
398
|
+
return result.result?.value ?? result.result?.description;
|
|
399
|
+
}
|
|
400
|
+
const result = (await cdp.send('Runtime.evaluate', {
|
|
401
|
+
expression,
|
|
402
|
+
returnByValue: true,
|
|
403
|
+
awaitPromise: true,
|
|
404
|
+
}, undefined, 30_000, signal));
|
|
405
|
+
return result.result?.value ?? result.result?.description;
|
|
406
|
+
}
|
|
407
|
+
async function waitFor(cdp, predicate, timeout, afterSequence = 0, signal) {
|
|
408
|
+
const pageEvent = (event) => {
|
|
409
|
+
// Browser.* events are browser-scoped and intentionally remain visible
|
|
410
|
+
// (downloads do not carry a page session in all Chromium versions).
|
|
411
|
+
// Page/DOM/Input/etc. events must belong to this connection's attached
|
|
412
|
+
// page, otherwise another tab can satisfy a navigation or dialog wait.
|
|
413
|
+
const pageDomain = /^(Accessibility|DOM|Input|Log|Network|Page|Performance|Runtime|Security)\./;
|
|
414
|
+
return (!pageDomain.test(event.method) ||
|
|
415
|
+
event.sessionId === cdp.attachedPageSessionId);
|
|
416
|
+
};
|
|
417
|
+
return new Promise((resolve, reject) => {
|
|
418
|
+
if (signal?.aborted) {
|
|
419
|
+
reject(signal.reason instanceof Error
|
|
420
|
+
? signal.reason
|
|
421
|
+
: new Error('Browser event wait aborted'));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const existing = cdp
|
|
425
|
+
.read(afterSequence)
|
|
426
|
+
.find((event) => pageEvent(event) && predicate(event));
|
|
427
|
+
if (existing)
|
|
428
|
+
return resolve(existing);
|
|
429
|
+
let settled = false;
|
|
430
|
+
let off = () => { };
|
|
431
|
+
const cleanup = () => {
|
|
432
|
+
clearTimeout(timer);
|
|
433
|
+
off();
|
|
434
|
+
signal?.removeEventListener('abort', onAbort);
|
|
435
|
+
};
|
|
436
|
+
const finish = (callback) => {
|
|
437
|
+
if (settled)
|
|
438
|
+
return;
|
|
439
|
+
settled = true;
|
|
440
|
+
cleanup();
|
|
441
|
+
callback();
|
|
442
|
+
};
|
|
443
|
+
const onAbort = () => finish(() => reject(signal?.reason instanceof Error
|
|
444
|
+
? signal.reason
|
|
445
|
+
: new Error('Browser event wait aborted')));
|
|
446
|
+
const timer = setTimeout(() => finish(() => reject(new Error('Browser event wait timed out'))), timeout);
|
|
447
|
+
off = cdp.subscribe((event) => {
|
|
448
|
+
if (pageEvent(event) && predicate(event))
|
|
449
|
+
finish(() => resolve(event));
|
|
450
|
+
});
|
|
451
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
452
|
+
// Close the tiny read/subscribe race without changing the event cursor contract.
|
|
453
|
+
const raced = cdp
|
|
454
|
+
.read(afterSequence)
|
|
455
|
+
.find((event) => pageEvent(event) && predicate(event));
|
|
456
|
+
if (raced)
|
|
457
|
+
finish(() => resolve(raced));
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// Some agent-browser/CDP endpoints expose Runtime/Page but not the optional
|
|
461
|
+
// Accessibility domain. Keep a deterministic DOM-semantic fallback so a
|
|
462
|
+
// missing AX domain does not make an otherwise usable supervised browser fail.
|
|
463
|
+
const domSemanticSnapshotExpression = `(() => Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,a,button,input,textarea,select,[role],[contenteditable="true"]')).map((el,index) => { const r=el.getBoundingClientRect(); const s=getComputedStyle(el); const visible=!!(r.width||r.height)&&s.display!=='none'&&s.visibility!=='hidden'&&s.pointerEvents!=='none'; const tag=el.tagName.toLowerCase(); const explicit=el.getAttribute('role'); const role=explicit||(['button','a','textarea','select'].includes(tag)?({button:'button',a:'link',textarea:'textbox',select:'combobox'})[tag]:(tag==='input'?(['checkbox','radio','button','submit','reset'].includes((el.getAttribute('type')||'text').toLowerCase())?(el.getAttribute('type')||'text').toLowerCase():'textbox'):tag)); const name=(el.getAttribute('aria-label')||el.textContent||el.getAttribute('placeholder')||'').trim().replace(/\\s+/g,' ').slice(0,240); const value=el instanceof HTMLInputElement||el instanceof HTMLTextAreaElement||el instanceof HTMLSelectElement?el.value:undefined; return {node_id:index,role,text:name,value,visible,x:r.x,y:r.y,width:r.width,height:r.height}; }).filter((node) => node.visible).slice(0,500))()`;
|
|
464
|
+
async function readBrowserAxTree(cdp, signal) {
|
|
465
|
+
try {
|
|
466
|
+
return normalizeAxTree(await cdp.send('Accessibility.getFullAXTree', {}, undefined, 30_000, signal));
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
470
|
+
if (!/-32601|not found|unknown method/i.test(message))
|
|
471
|
+
throw error;
|
|
472
|
+
const domNodes = await evaluate(cdp, domSemanticSnapshotExpression, signal);
|
|
473
|
+
if (!Array.isArray(domNodes))
|
|
474
|
+
return [];
|
|
475
|
+
return normalizeAxTree({
|
|
476
|
+
nodes: domNodes.map((value) => {
|
|
477
|
+
const node = value && typeof value === 'object'
|
|
478
|
+
? value
|
|
479
|
+
: {};
|
|
480
|
+
const number = (key) => typeof node[key] === 'number' ? node[key] : undefined;
|
|
481
|
+
return {
|
|
482
|
+
nodeId: `dom-${String(node.node_id ?? '')}`,
|
|
483
|
+
role: { value: String(node.role ?? 'generic') },
|
|
484
|
+
name: { value: String(node.text ?? '') },
|
|
485
|
+
...(node.value !== undefined
|
|
486
|
+
? { properties: [{ name: 'value', value: { value: node.value } }] }
|
|
487
|
+
: {}),
|
|
488
|
+
bounds: {
|
|
489
|
+
x: number('x') ?? 0,
|
|
490
|
+
y: number('y') ?? 0,
|
|
491
|
+
width: number('width') ?? 0,
|
|
492
|
+
height: number('height') ?? 0,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function snapshot(cdp, key, mode, signal) {
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
for (const [staleKey, stale] of snapshots) {
|
|
502
|
+
if (now - stale.updatedAt > SNAPSHOT_TTL_MS)
|
|
503
|
+
snapshots.delete(staleKey);
|
|
504
|
+
}
|
|
505
|
+
const tree = await readBrowserAxTree(cdp, signal);
|
|
506
|
+
const url = String(await evaluate(cdp, 'location.href', signal).catch(() => ''));
|
|
507
|
+
const frameResult = (await cdp
|
|
508
|
+
.send('Page.getFrameTree', {}, undefined, 30_000, signal)
|
|
509
|
+
.catch(() => ({})));
|
|
510
|
+
const frames = normalizeFrameTree(frameResult.frameTree);
|
|
511
|
+
const prior = snapshots.get(key);
|
|
512
|
+
const revision = (prior?.revision ?? 0) + 1;
|
|
513
|
+
const id = randomUUID();
|
|
514
|
+
const generation = prior && prior.url !== url
|
|
515
|
+
? prior.generation + 1
|
|
516
|
+
: (prior?.generation ?? 1);
|
|
517
|
+
const samePageNodes = prior && prior.url === url ? prior.nodes : undefined;
|
|
518
|
+
const diff = diffAxTree(samePageNodes, tree, generation, revision, mode === 'full' || (!samePageNodes && mode !== 'diff'));
|
|
519
|
+
snapshots.set(key, {
|
|
520
|
+
id,
|
|
521
|
+
revision,
|
|
522
|
+
generation,
|
|
523
|
+
url,
|
|
524
|
+
text: JSON.stringify(tree),
|
|
525
|
+
nodes: new Map(tree.map((node) => [node.id, node])),
|
|
526
|
+
updatedAt: now,
|
|
527
|
+
});
|
|
528
|
+
if (snapshots.size > MAX_SNAPSHOTS) {
|
|
529
|
+
const stale = [...snapshots.entries()]
|
|
530
|
+
.sort((left, right) => left[1].updatedAt - right[1].updatedAt)
|
|
531
|
+
.slice(0, snapshots.size - MAX_SNAPSHOTS);
|
|
532
|
+
for (const [staleKey] of stale)
|
|
533
|
+
snapshots.delete(staleKey);
|
|
534
|
+
}
|
|
535
|
+
const body = mode === 'full' || !samePageNodes ? { nodes: tree, ...diff } : diff;
|
|
536
|
+
const compact = JSON.stringify(body);
|
|
537
|
+
return {
|
|
538
|
+
mode: diff.full ? 'full' : 'diff',
|
|
539
|
+
snapshot_id: id,
|
|
540
|
+
...(prior ? { base_snapshot_id: prior.id } : {}),
|
|
541
|
+
page_revision: revision,
|
|
542
|
+
generation,
|
|
543
|
+
tree: (mode === 'full' || !prior ? conciseAxTree(tree) : compact).slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS),
|
|
544
|
+
frames,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
async function resolveAxLocator(cdp, locator, snapshotKey, signal) {
|
|
548
|
+
if (!locator || locator.by !== 'ax')
|
|
549
|
+
return locator;
|
|
550
|
+
const current = await readBrowserAxTree(cdp, signal);
|
|
551
|
+
const prior = snapshots.get(snapshotKey);
|
|
552
|
+
if (locator.generation !== undefined && !prior) {
|
|
553
|
+
throw new Error('AX 引用缺少对应 snapshot;请先 snapshot 后再使用 generation');
|
|
554
|
+
}
|
|
555
|
+
if (locator.generation !== undefined &&
|
|
556
|
+
prior &&
|
|
557
|
+
locator.generation !== prior.generation) {
|
|
558
|
+
throw new Error(`AX 引用已过期:引用 generation=${locator.generation},当前页面 generation=${prior.generation};请重新 snapshot`);
|
|
559
|
+
}
|
|
560
|
+
if (locator.generation !== undefined && prior) {
|
|
561
|
+
const currentUrl = String(await evaluate(cdp, 'location.href', signal).catch(() => ''));
|
|
562
|
+
if (currentUrl !== prior.url) {
|
|
563
|
+
throw new Error(`AX 引用已过期:页面已从 ${prior.url} 导航到 ${currentUrl};请重新 snapshot`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const indexMatch = /^index:(\d+)$/.exec(locator.value);
|
|
567
|
+
const numeric = indexMatch ? Number(indexMatch[1]) : Number(locator.value);
|
|
568
|
+
const node = current.find((item) => item.id === locator.value) ??
|
|
569
|
+
(Number.isInteger(numeric) && numeric >= 0 && numeric < current.length
|
|
570
|
+
? current[numeric]
|
|
571
|
+
: undefined);
|
|
572
|
+
if (!node)
|
|
573
|
+
throw new Error(`AX 引用未找到:${locator.value};请重新 snapshot`);
|
|
574
|
+
if (!node.name && !node.role)
|
|
575
|
+
throw new Error(`AX 引用没有可执行的语义信息:${locator.value}`);
|
|
576
|
+
const resolved = node.name
|
|
577
|
+
? {
|
|
578
|
+
by: 'role',
|
|
579
|
+
value: node.role ?? 'generic',
|
|
580
|
+
name: node.name,
|
|
581
|
+
exact: true,
|
|
582
|
+
strict: true,
|
|
583
|
+
}
|
|
584
|
+
: { by: 'text', value: node.value ?? '', exact: true, strict: true };
|
|
585
|
+
const framePath = locator.frame ?? locator.framePath ?? locator.frame_path;
|
|
586
|
+
const frameId = locator.frameId ?? locator.frame_id;
|
|
587
|
+
return {
|
|
588
|
+
...resolved,
|
|
589
|
+
...(framePath?.length ? { frame: framePath } : {}),
|
|
590
|
+
...(frameId ? { frameId } : {}),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function eventMatchesCondition(condition, event) {
|
|
594
|
+
if (condition.type === 'download') {
|
|
595
|
+
return (event.method === 'Browser.downloadWillBegin' &&
|
|
596
|
+
(!condition.filename ||
|
|
597
|
+
String(event.params.suggestedFilename ?? '') === condition.filename));
|
|
598
|
+
}
|
|
599
|
+
if (condition.type === 'new-tab') {
|
|
600
|
+
return (event.method === 'Target.targetCreated' &&
|
|
601
|
+
(!condition.url || JSON.stringify(event.params).includes(condition.url)));
|
|
602
|
+
}
|
|
603
|
+
if (condition.type === 'dialog') {
|
|
604
|
+
return (event.method === 'Page.javascriptDialogOpening' &&
|
|
605
|
+
(!condition.dialogType ||
|
|
606
|
+
String(event.params.type ?? '') === condition.dialogType));
|
|
607
|
+
}
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
const visibleDomExpression = `(() => Array.from(document.querySelectorAll('a,button,input,textarea,select,[role],[contenteditable="true"]')).map((el, index) => { const r=el.getBoundingClientRect(); const s=getComputedStyle(el); const visible=!!(r.width||r.height)&&s.display!=='none'&&s.visibility!=='hidden'&&s.pointerEvents!=='none'; const label=el.labels?.length?Array.from(el.labels).map((item)=>String(item.textContent||'').trim()).filter(Boolean).join(' '):undefined; const role=el.getAttribute('role')||el.tagName.toLowerCase(); const editable=el instanceof HTMLInputElement||el instanceof HTMLTextAreaElement||el.isContentEditable; const clickable=el instanceof HTMLButtonElement||el instanceof HTMLAnchorElement||role==='button'||typeof el.onclick==='function'; return {node_id:index,tag:el.tagName.toLowerCase(),role,text:(el.innerText||el.getAttribute('aria-label')||label||el.getAttribute('placeholder')||'').trim().slice(0,240),label,placeholder:el.getAttribute('placeholder')||undefined,testId:el.getAttribute('data-testid')||undefined,cssCandidates:[el.id?'#'+CSS.escape(el.id):'',el.getAttribute('data-testid')?'[data-testid="'+CSS.escape(el.getAttribute('data-testid'))+'"]':''].filter(Boolean),framePath:[],x:r.x,y:r.y,width:r.width,height:r.height,visible,enabled:!el.disabled,editable,clickable,confidence:el.id||el.getAttribute('data-testid')?'high':'medium'}; }).filter((node) => node.visible).slice(0,500))()`;
|
|
611
|
+
export const visibleDomTarget = (nodeId) => `(() => { const nodes=${visibleDomExpression}; const n=nodes[${nodeId}]; if(!n) throw new Error('visible DOM node not found'); const all=Array.from(document.querySelectorAll('a,button,input,textarea,select,[role],[contenteditable="true"]')); const e=all[n.node_id]; if(!e) throw new Error('visible DOM node is stale'); return e; })()`;
|
|
612
|
+
async function waitForCondition(cdp, condition, timeout, baseline, signal) {
|
|
613
|
+
const deadline = Date.now() + timeout;
|
|
614
|
+
const script = conditionToScript(condition, (locator, selector) => buildTargetExpression(locator, selector));
|
|
615
|
+
let lastValue;
|
|
616
|
+
while (Date.now() < deadline) {
|
|
617
|
+
if (signal?.aborted)
|
|
618
|
+
throw signal.reason instanceof Error
|
|
619
|
+
? signal.reason
|
|
620
|
+
: new Error('Browser condition wait aborted');
|
|
621
|
+
const challenge = await readBrowserChallenge(cdp, signal);
|
|
622
|
+
if (challenge)
|
|
623
|
+
throw new Error(`页面需要人工验证:${challenge.summary}`);
|
|
624
|
+
const value = await evaluate(cdp, script, signal);
|
|
625
|
+
lastValue = value;
|
|
626
|
+
if (condition.type === 'page-change' ? value !== baseline : value === true)
|
|
627
|
+
return value;
|
|
628
|
+
await new Promise((resolve, reject) => {
|
|
629
|
+
const timer = setTimeout(() => {
|
|
630
|
+
signal?.removeEventListener('abort', onAbort);
|
|
631
|
+
resolve();
|
|
632
|
+
}, 100);
|
|
633
|
+
const onAbort = () => {
|
|
634
|
+
clearTimeout(timer);
|
|
635
|
+
reject(signal?.reason instanceof Error
|
|
636
|
+
? signal.reason
|
|
637
|
+
: new Error('Browser condition wait aborted'));
|
|
638
|
+
};
|
|
639
|
+
if (signal?.aborted)
|
|
640
|
+
onAbort();
|
|
641
|
+
else
|
|
642
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
throw new Error(`Browser condition timed out: ${condition.type} (last=${JSON.stringify(lastValue)})`);
|
|
646
|
+
}
|
|
647
|
+
async function readBrowserChallenge(cdp, signal) {
|
|
648
|
+
const text = String(await evaluate(cdp, `location.href + '\\n' + (document.body?.innerText || '').slice(0, 20000)`, signal).catch(() => ''));
|
|
649
|
+
return detectBrowserChallenge(text);
|
|
650
|
+
}
|
|
651
|
+
async function waitForBrowserChallenge(cdp, timeout, signal) {
|
|
652
|
+
const deadline = Date.now() + timeout;
|
|
653
|
+
while (Date.now() < deadline && !signal?.aborted) {
|
|
654
|
+
const challenge = await readBrowserChallenge(cdp, signal);
|
|
655
|
+
if (challenge)
|
|
656
|
+
return challenge;
|
|
657
|
+
await sleepWithSignal(250, signal).catch(() => undefined);
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
async function waitForEventOrBrowserChallenge(cdp, predicate, timeout, afterSequence = 0, signal) {
|
|
662
|
+
const challengeAbort = new AbortController();
|
|
663
|
+
const challengeRace = waitForBrowserChallenge(cdp, timeout, challengeAbort.signal).then((challenge) => {
|
|
664
|
+
if (challenge)
|
|
665
|
+
throw new Error(`页面需要人工验证:${challenge.summary}`);
|
|
666
|
+
return new Promise(() => undefined);
|
|
667
|
+
});
|
|
668
|
+
try {
|
|
669
|
+
return await Promise.race([
|
|
670
|
+
waitFor(cdp, predicate, timeout, afterSequence, signal),
|
|
671
|
+
challengeRace,
|
|
672
|
+
]);
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
challengeAbort.abort();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function elementPoint(cdp, target, framePath = [], signal, frameId) {
|
|
679
|
+
if (frameId) {
|
|
680
|
+
const value = await evaluate(cdp, `(() => { const e=${target}; e.scrollIntoView({block:'center',inline:'center'}); const r=e.getBoundingClientRect(); const s=getComputedStyle(e); if (!r.width || !r.height || s.visibility==='hidden' || s.pointerEvents==='none') throw new Error('Locator matched a non-actionable element'); return {x:r.left+r.width/2,y:r.top+r.height/2}; })()`, signal, frameId);
|
|
681
|
+
if (!value || typeof value !== 'object')
|
|
682
|
+
throw new Error('无法取得浏览器元素坐标');
|
|
683
|
+
const point = value;
|
|
684
|
+
if (typeof point.x !== 'number' || typeof point.y !== 'number')
|
|
685
|
+
throw new Error('浏览器元素坐标无效');
|
|
686
|
+
const offset = await cdp.frameOwnerOffset(frameId, signal);
|
|
687
|
+
return { x: point.x + offset.x, y: point.y + offset.y };
|
|
688
|
+
}
|
|
689
|
+
let frameOffsets = '';
|
|
690
|
+
let frameRoot = 'document';
|
|
691
|
+
for (const [index, frame] of framePath.entries()) {
|
|
692
|
+
const frameExpression = buildLocatorExpression(frame, undefined, frameRoot);
|
|
693
|
+
frameOffsets += `const __frame${index}=(${frameExpression})[0]; if(!__frame${index}||!__frame${index}.contentDocument) throw new Error('frameLocator could not resolve a same-origin iframe'); const __frameRect${index}=__frame${index}.getBoundingClientRect(); __offsetX+=__frameRect${index}.left; __offsetY+=__frameRect${index}.top; __frameRoot=__frame${index}.contentDocument;`;
|
|
694
|
+
frameRoot = '__frameRoot';
|
|
695
|
+
}
|
|
696
|
+
const value = await evaluate(cdp, `(() => { let __offsetX=0,__offsetY=0,__frameRoot=document; ${frameOffsets} const e=${target}; e.scrollIntoView({block:'center',inline:'center'}); const r=e.getBoundingClientRect(); const s=getComputedStyle(e); if (!r.width || !r.height || s.visibility==='hidden' || s.pointerEvents==='none') throw new Error('Locator matched a non-actionable element'); return {x:r.left+r.width/2+__offsetX,y:r.top+r.height/2+__offsetY}; })()`, signal);
|
|
697
|
+
if (!value || typeof value !== 'object')
|
|
698
|
+
throw new Error('无法取得浏览器元素坐标');
|
|
699
|
+
const point = value;
|
|
700
|
+
if (typeof point.x !== 'number' || typeof point.y !== 'number')
|
|
701
|
+
throw new Error('浏览器元素坐标无效');
|
|
702
|
+
return { x: point.x, y: point.y };
|
|
703
|
+
}
|
|
704
|
+
async function trustedClick(cdp, target, framePath = [], signal, clickCount = 1, frameId) {
|
|
705
|
+
const point = await elementPoint(cdp, target, framePath, signal, frameId);
|
|
706
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
707
|
+
type: 'mouseMoved',
|
|
708
|
+
x: point.x,
|
|
709
|
+
y: point.y,
|
|
710
|
+
}, undefined, 30_000, signal);
|
|
711
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
712
|
+
type: 'mousePressed',
|
|
713
|
+
x: point.x,
|
|
714
|
+
y: point.y,
|
|
715
|
+
button: 'left',
|
|
716
|
+
buttons: 1,
|
|
717
|
+
clickCount,
|
|
718
|
+
}, undefined, 30_000, signal);
|
|
719
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
720
|
+
type: 'mouseReleased',
|
|
721
|
+
x: point.x,
|
|
722
|
+
y: point.y,
|
|
723
|
+
button: 'left',
|
|
724
|
+
buttons: 0,
|
|
725
|
+
clickCount,
|
|
726
|
+
}, undefined, 30_000, signal);
|
|
727
|
+
}
|
|
728
|
+
async function focusTarget(cdp, target, signal, frameId) {
|
|
729
|
+
await evaluate(cdp, `(()=>{const e=${target};e.scrollIntoView({block:'center',inline:'nearest'});e.focus();})()`, signal, frameId);
|
|
730
|
+
}
|
|
731
|
+
async function insertText(cdp, target, value, replace, signal, frameId) {
|
|
732
|
+
await focusTarget(cdp, target, signal, frameId);
|
|
733
|
+
if (replace) {
|
|
734
|
+
const mac = await evaluate(cdp, `navigator.platform.includes('Mac')`, signal, frameId);
|
|
735
|
+
const modifiers = mac === true ? 4 : 2;
|
|
736
|
+
await cdp.send('Input.dispatchKeyEvent', {
|
|
737
|
+
type: 'keyDown',
|
|
738
|
+
key: 'a',
|
|
739
|
+
code: 'KeyA',
|
|
740
|
+
modifiers,
|
|
741
|
+
}, undefined, 30_000, signal);
|
|
742
|
+
await cdp.send('Input.dispatchKeyEvent', {
|
|
743
|
+
type: 'keyUp',
|
|
744
|
+
key: 'a',
|
|
745
|
+
code: 'KeyA',
|
|
746
|
+
modifiers,
|
|
747
|
+
}, undefined, 30_000, signal);
|
|
748
|
+
}
|
|
749
|
+
await cdp.send('Input.insertText', { text: value }, undefined, 30_000, signal);
|
|
750
|
+
}
|
|
751
|
+
function sleepWithSignal(ms, signal) {
|
|
752
|
+
return new Promise((resolve, reject) => {
|
|
753
|
+
if (signal?.aborted) {
|
|
754
|
+
reject(signal.reason instanceof Error
|
|
755
|
+
? signal.reason
|
|
756
|
+
: new Error('Browser step aborted'));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const timer = setTimeout(() => {
|
|
760
|
+
signal?.removeEventListener('abort', onAbort);
|
|
761
|
+
resolve();
|
|
762
|
+
}, ms);
|
|
763
|
+
const onAbort = () => {
|
|
764
|
+
clearTimeout(timer);
|
|
765
|
+
reject(signal?.reason instanceof Error
|
|
766
|
+
? signal.reason
|
|
767
|
+
: new Error('Browser step aborted'));
|
|
768
|
+
};
|
|
769
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
async function resumeBatchAfterHuman(stream, ctx, signal, summary) {
|
|
773
|
+
ctx.reportProgress?.(`${summary},等待你在浏览器中处理`);
|
|
774
|
+
const resolution = await stream.waitForHuman(signal);
|
|
775
|
+
if (resolution === 'resumed')
|
|
776
|
+
ctx.reportProgress?.('人工处理完成,正在重新读取页面状态并继续批量任务');
|
|
777
|
+
return resolution;
|
|
778
|
+
}
|
|
779
|
+
async function executeStep(cdp, step, timeout, afterSequence = 0, snapshotKey = 'step', signal) {
|
|
780
|
+
await cdp
|
|
781
|
+
.send('Page.enable', {}, undefined, 30_000, signal)
|
|
782
|
+
.catch(() => undefined);
|
|
783
|
+
const resolvedLocator = await resolveAxLocator(cdp, step.locator, snapshotKey, signal);
|
|
784
|
+
const frameId = resolvedLocator?.frameId ?? resolvedLocator?.frame_id;
|
|
785
|
+
const targetLocator = resolvedLocator && frameId
|
|
786
|
+
? {
|
|
787
|
+
...resolvedLocator,
|
|
788
|
+
frame: undefined,
|
|
789
|
+
framePath: undefined,
|
|
790
|
+
frame_path: undefined,
|
|
791
|
+
}
|
|
792
|
+
: resolvedLocator;
|
|
793
|
+
const target = () => {
|
|
794
|
+
if (targetLocator && step.selector) {
|
|
795
|
+
const semantic = buildTargetExpression(targetLocator, undefined);
|
|
796
|
+
const cssFallback = buildTargetExpression(undefined, step.selector);
|
|
797
|
+
return `(() => { try { return ${semantic}; } catch { return ${cssFallback}; } })()`;
|
|
798
|
+
}
|
|
799
|
+
return buildTargetExpression(targetLocator, step.selector);
|
|
800
|
+
};
|
|
801
|
+
switch (step.action) {
|
|
802
|
+
case 'navigate':
|
|
803
|
+
if (!step.url)
|
|
804
|
+
throw new Error('navigate requires url');
|
|
805
|
+
return cdp.send('Page.navigate', { url: step.url }, undefined, 30_000, signal);
|
|
806
|
+
case 'click':
|
|
807
|
+
return trustedClick(cdp, target(), resolvedLocator?.frame ??
|
|
808
|
+
resolvedLocator?.framePath ??
|
|
809
|
+
resolvedLocator?.frame_path ??
|
|
810
|
+
[], signal, 1, frameId);
|
|
811
|
+
case 'doubleClick':
|
|
812
|
+
return trustedClick(cdp, target(), resolvedLocator?.frame ??
|
|
813
|
+
resolvedLocator?.framePath ??
|
|
814
|
+
resolvedLocator?.frame_path ??
|
|
815
|
+
[], signal, 2, frameId);
|
|
816
|
+
case 'fill':
|
|
817
|
+
if (step.value === undefined)
|
|
818
|
+
throw new Error('fill requires value');
|
|
819
|
+
return insertText(cdp, target(), step.value, true, signal, frameId);
|
|
820
|
+
case 'type':
|
|
821
|
+
if (step.value === undefined)
|
|
822
|
+
throw new Error('type requires value');
|
|
823
|
+
return insertText(cdp, target(), step.value, false, signal, frameId);
|
|
824
|
+
case 'hover': {
|
|
825
|
+
const point = await elementPoint(cdp, target(), resolvedLocator?.frame ??
|
|
826
|
+
resolvedLocator?.framePath ??
|
|
827
|
+
resolvedLocator?.frame_path ??
|
|
828
|
+
[], signal, frameId);
|
|
829
|
+
return cdp.send('Input.dispatchMouseEvent', {
|
|
830
|
+
type: 'mouseMoved',
|
|
831
|
+
x: point.x,
|
|
832
|
+
y: point.y,
|
|
833
|
+
}, undefined, 30_000, signal);
|
|
834
|
+
}
|
|
835
|
+
case 'press':
|
|
836
|
+
if (!step.key)
|
|
837
|
+
throw new Error('press requires key');
|
|
838
|
+
if (step.locator || step.selector)
|
|
839
|
+
await focusTarget(cdp, target(), signal, frameId);
|
|
840
|
+
return cdp
|
|
841
|
+
.send('Input.dispatchKeyEvent', { type: 'keyDown', key: step.key }, undefined, 30_000, signal)
|
|
842
|
+
.then(() => cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: step.key }, undefined, 30_000, signal));
|
|
843
|
+
case 'check':
|
|
844
|
+
return evaluate(cdp, `Boolean(${target()}.checked)`, signal, frameId).then((checked) => checked
|
|
845
|
+
? undefined
|
|
846
|
+
: trustedClick(cdp, target(), resolvedLocator?.frame ??
|
|
847
|
+
resolvedLocator?.framePath ??
|
|
848
|
+
resolvedLocator?.frame_path ??
|
|
849
|
+
[], signal, 1, frameId));
|
|
850
|
+
case 'uncheck':
|
|
851
|
+
return evaluate(cdp, `Boolean(${target()}.checked)`, signal, frameId).then((checked) => checked
|
|
852
|
+
? trustedClick(cdp, target(), resolvedLocator?.frame ??
|
|
853
|
+
resolvedLocator?.framePath ??
|
|
854
|
+
resolvedLocator?.frame_path ??
|
|
855
|
+
[], signal, 1, frameId)
|
|
856
|
+
: undefined);
|
|
857
|
+
case 'setChecked':
|
|
858
|
+
if (step.checked === undefined)
|
|
859
|
+
throw new Error('setChecked requires checked');
|
|
860
|
+
return evaluate(cdp, `Boolean(${target()}.checked)`, signal, frameId).then((checked) => checked === step.checked
|
|
861
|
+
? undefined
|
|
862
|
+
: trustedClick(cdp, target(), resolvedLocator?.frame ??
|
|
863
|
+
resolvedLocator?.framePath ??
|
|
864
|
+
resolvedLocator?.frame_path ??
|
|
865
|
+
[], signal, 1, frameId));
|
|
866
|
+
case 'getState':
|
|
867
|
+
return evaluate(cdp, `(()=>{const e=${target()};const s=getComputedStyle(e);return {visible:!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)&&s.visibility!=='hidden',enabled:!e.disabled,checked:!!e.checked}})()`, signal, frameId);
|
|
868
|
+
case 'drag': {
|
|
869
|
+
const points = step.drag_path;
|
|
870
|
+
if (!points?.length)
|
|
871
|
+
throw new Error('drag requires drag_path');
|
|
872
|
+
for (const [index, point] of points.entries())
|
|
873
|
+
await cdp.send('Input.dispatchMouseEvent', {
|
|
874
|
+
type: index === 0
|
|
875
|
+
? 'mousePressed'
|
|
876
|
+
: index === points.length - 1
|
|
877
|
+
? 'mouseReleased'
|
|
878
|
+
: 'mouseMoved',
|
|
879
|
+
x: point.x,
|
|
880
|
+
y: point.y,
|
|
881
|
+
button: 'left',
|
|
882
|
+
buttons: index === 0 ? 1 : index === points.length - 1 ? 0 : 1,
|
|
883
|
+
}, undefined, 30_000, signal);
|
|
884
|
+
return { points: points.length };
|
|
885
|
+
}
|
|
886
|
+
case 'wait':
|
|
887
|
+
await sleepWithSignal(step.wait_ms ?? 250, signal);
|
|
888
|
+
return { waited_ms: step.wait_ms ?? 250 };
|
|
889
|
+
case 'waitForURL':
|
|
890
|
+
if (!step.url)
|
|
891
|
+
throw new Error('waitForURL requires url');
|
|
892
|
+
return waitFor(cdp, (event) => /Page\.frame(Navigated|StartedLoading)/.test(event.method) &&
|
|
893
|
+
JSON.stringify(event.params).includes(step.url), timeout, afterSequence, signal);
|
|
894
|
+
case 'waitForDownload':
|
|
895
|
+
return waitFor(cdp, (event) => event.method === 'Browser.downloadWillBegin' ||
|
|
896
|
+
event.method === 'Browser.downloadProgress', timeout, afterSequence, signal);
|
|
897
|
+
case 'waitForFileChooser':
|
|
898
|
+
return waitFor(cdp, (event) => event.method === 'Page.fileChooserOpened', timeout, afterSequence, signal);
|
|
899
|
+
case 'snapshot':
|
|
900
|
+
return snapshot(cdp, snapshotKey, 'full', signal);
|
|
901
|
+
case 'frameInfo':
|
|
902
|
+
return normalizeFrameInfo(normalizeFrameTree((await cdp.send('Page.getFrameTree', {}, undefined, 30_000, signal)).frameTree));
|
|
903
|
+
case 'visibleDom':
|
|
904
|
+
return evaluate(cdp, visibleDomExpression, signal, frameId);
|
|
905
|
+
case 'clickNode':
|
|
906
|
+
if (step.node_id === undefined)
|
|
907
|
+
throw new Error('clickNode requires node_id');
|
|
908
|
+
return trustedClick(cdp, visibleDomTarget(step.node_id), [], signal);
|
|
909
|
+
case 'typeNode':
|
|
910
|
+
if (step.node_id === undefined || step.value === undefined)
|
|
911
|
+
throw new Error('typeNode requires node_id and value');
|
|
912
|
+
return insertText(cdp, visibleDomTarget(step.node_id), step.value, false, signal, frameId);
|
|
913
|
+
case 'scrollNode':
|
|
914
|
+
if (step.node_id === undefined)
|
|
915
|
+
throw new Error('scrollNode requires node_id');
|
|
916
|
+
return evaluate(cdp, `(()=>{const e=${visibleDomTarget(step.node_id)};e.scrollIntoView({block:'center',inline:'nearest'});return {node_id:${step.node_id}}})()`, signal, frameId);
|
|
917
|
+
case 'upload': {
|
|
918
|
+
if (!step.files?.length)
|
|
919
|
+
throw new Error('upload requires files');
|
|
920
|
+
if (step.trigger_selector || step.trigger_locator) {
|
|
921
|
+
const trigger = buildTargetExpression(step.trigger_locator, step.trigger_selector);
|
|
922
|
+
await trustedClick(cdp, trigger, [], signal);
|
|
923
|
+
return { files: step.files, chooser_triggered: true };
|
|
924
|
+
}
|
|
925
|
+
if (!step.selector)
|
|
926
|
+
throw new Error('upload requires selector or trigger_selector');
|
|
927
|
+
const documentResult = (await cdp.send('DOM.getDocument', {
|
|
928
|
+
depth: -1,
|
|
929
|
+
}, undefined, 30_000, signal));
|
|
930
|
+
if (!documentResult.root?.nodeId)
|
|
931
|
+
throw new Error('无法取得 DOM 根节点');
|
|
932
|
+
const found = (await cdp.send('DOM.querySelector', {
|
|
933
|
+
nodeId: documentResult.root.nodeId,
|
|
934
|
+
selector: step.selector,
|
|
935
|
+
}, undefined, 30_000, signal));
|
|
936
|
+
if (!found.nodeId)
|
|
937
|
+
throw new Error('upload selector matched no input');
|
|
938
|
+
await cdp.send('DOM.setFileInputFiles', {
|
|
939
|
+
files: step.files,
|
|
940
|
+
nodeId: found.nodeId,
|
|
941
|
+
}, undefined, 30_000, signal);
|
|
942
|
+
return { files: step.files, uploaded: true };
|
|
943
|
+
}
|
|
944
|
+
case 'pageAssets':
|
|
945
|
+
case 'listPageAssets':
|
|
946
|
+
case 'bundlePageAssets': {
|
|
947
|
+
const assets = await evaluate(cdp, PAGE_ASSETS_EXPRESSION, signal);
|
|
948
|
+
if (!Array.isArray(assets))
|
|
949
|
+
throw new Error('页面资产清单格式无效');
|
|
950
|
+
if (step.bundle_assets || step.action === 'bundlePageAssets') {
|
|
951
|
+
if (!step.bundle_dir)
|
|
952
|
+
throw new Error('bundle_assets requires bundle_dir');
|
|
953
|
+
const bundle = await bundlePageAssets(assets, step.bundle_dir, async (url) => {
|
|
954
|
+
const fetched = await evaluate(cdp, browserAssetFetchExpression(url), signal);
|
|
955
|
+
if (!fetched ||
|
|
956
|
+
typeof fetched !== 'object' ||
|
|
957
|
+
fetched.ok !== true ||
|
|
958
|
+
typeof fetched.base64 !== 'string') {
|
|
959
|
+
const status = fetched && typeof fetched === 'object'
|
|
960
|
+
? String(fetched.status ?? 'unknown')
|
|
961
|
+
: 'unknown';
|
|
962
|
+
throw new Error(`浏览器上下文抓取资源失败 HTTP ${status}`);
|
|
963
|
+
}
|
|
964
|
+
const value = fetched;
|
|
965
|
+
return new Response(Buffer.from(value.base64, 'base64'), {
|
|
966
|
+
status: 200,
|
|
967
|
+
headers: value.type ? { 'content-type': value.type } : undefined,
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
return { assets, bundle };
|
|
971
|
+
}
|
|
972
|
+
return { assets };
|
|
973
|
+
}
|
|
974
|
+
case 'exportContent': {
|
|
975
|
+
const format = step.export_format ?? 'page';
|
|
976
|
+
if (format === 'pdf') {
|
|
977
|
+
const pdf = (await cdp.send('Page.printToPDF', {
|
|
978
|
+
printBackground: true,
|
|
979
|
+
preferCSSPageSize: true,
|
|
980
|
+
}, undefined, 30_000, signal));
|
|
981
|
+
if (!pdf.data)
|
|
982
|
+
throw new Error('浏览器未返回 PDF 内容');
|
|
983
|
+
return { __browser_pdf_base64: pdf.data };
|
|
984
|
+
}
|
|
985
|
+
if (['docx', 'xlsx', 'csv', 'pptx'].includes(format)) {
|
|
986
|
+
const exported = await evaluate(cdp, googleWorkspaceExportExpression(format), signal);
|
|
987
|
+
if (exported && typeof exported === 'object' && 'error' in exported)
|
|
988
|
+
throw new Error(String(exported.error));
|
|
989
|
+
if (!exported ||
|
|
990
|
+
typeof exported !== 'object' ||
|
|
991
|
+
typeof exported
|
|
992
|
+
.__browser_binary_base64 !== 'string')
|
|
993
|
+
throw new Error('Workspace 未返回可保存的文件内容');
|
|
994
|
+
return exported;
|
|
995
|
+
}
|
|
996
|
+
const content = await evaluate(cdp, pageContentExpression(format, step.selector), signal);
|
|
997
|
+
if (!content || typeof content !== 'object')
|
|
998
|
+
throw new Error('页面内容导出失败');
|
|
999
|
+
if (format === 'markdown' || format === 'md')
|
|
1000
|
+
return markdownFromPageContent(content);
|
|
1001
|
+
return content;
|
|
1002
|
+
}
|
|
1003
|
+
case 'readUrls': {
|
|
1004
|
+
if (!step.urls?.length)
|
|
1005
|
+
throw new Error('readUrls requires urls');
|
|
1006
|
+
return readUrlsInBackground(cdp, step.urls, timeout, step.content_type, step.concurrency, signal);
|
|
1007
|
+
}
|
|
1008
|
+
case 'clipboardReadText':
|
|
1009
|
+
return evaluate(cdp, CLIPBOARD_READ_TEXT_EXPRESSION, signal);
|
|
1010
|
+
case 'clipboardWriteText':
|
|
1011
|
+
if (step.clipboard_text === undefined)
|
|
1012
|
+
throw new Error('clipboardWriteText requires clipboard_text');
|
|
1013
|
+
return evaluate(cdp, clipboardWriteTextExpression(step.clipboard_text), signal);
|
|
1014
|
+
case 'clipboardRead':
|
|
1015
|
+
return evaluate(cdp, CLIPBOARD_READ_EXPRESSION, signal);
|
|
1016
|
+
case 'clipboardWrite':
|
|
1017
|
+
if (!step.clipboard_items?.length)
|
|
1018
|
+
throw new Error('clipboardWrite requires clipboard_items');
|
|
1019
|
+
return evaluate(cdp, clipboardWriteExpression(step.clipboard_items), signal);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function call(input, signal, ctx = {}) {
|
|
1023
|
+
// Tool runners normally apply the Zod default, but callWithContext is also
|
|
1024
|
+
// used by the local runtime and deterministic E2E harnesses directly. Keep
|
|
1025
|
+
// the execution core safe when that adapter is intentionally bypassed.
|
|
1026
|
+
const timeoutMs = input.timeout_ms ?? 120_000;
|
|
1027
|
+
const stopOnError = input.stop_on_error ?? true;
|
|
1028
|
+
const finalSnapshotMode = input.final_snapshot ?? 'auto';
|
|
1029
|
+
const captureMode = input.capture ?? 'state';
|
|
1030
|
+
if (!(await configService.browser()).enabled)
|
|
1031
|
+
return { ok: false, error: '内置浏览器已在设置中关闭' };
|
|
1032
|
+
if (ctx.browserRun?.supervised !== true)
|
|
1033
|
+
return {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
error: 'BrowserBatch 仅可在 Desktop 的受监督浏览器会话中使用',
|
|
1036
|
+
};
|
|
1037
|
+
const bin = await resolveAgentBrowserPath();
|
|
1038
|
+
if (!bin)
|
|
1039
|
+
return { ok: false, error: '内置浏览器不可用:未找到 agent-browser' };
|
|
1040
|
+
const runId = ctx.browserRun?.runId ?? ctx.toolCallId ?? randomUUID();
|
|
1041
|
+
const conversationKey = ctx.browserRun?.conversationKey ?? `headless:${process.pid}`;
|
|
1042
|
+
const run = browserRunRegistry.ensure({
|
|
1043
|
+
conversationKey,
|
|
1044
|
+
runId,
|
|
1045
|
+
grants: ctx.browserRun?.grants,
|
|
1046
|
+
allowedUploadRoots: ctx.browserRun?.allowedUploadRoots,
|
|
1047
|
+
});
|
|
1048
|
+
try {
|
|
1049
|
+
browserRunRegistry.requireGrant(run, 'interact');
|
|
1050
|
+
if (input.steps.some((step) => step.intent === 'submit'))
|
|
1051
|
+
browserRunRegistry.requireGrant(run, 'submit');
|
|
1052
|
+
const uploadSteps = input.steps.filter((step) => step.action === 'upload');
|
|
1053
|
+
if (uploadSteps.length) {
|
|
1054
|
+
browserRunRegistry.requireGrant(run, 'upload');
|
|
1055
|
+
const roots = await Promise.all(run.allowedUploadRoots.map(async (root) => {
|
|
1056
|
+
try {
|
|
1057
|
+
return await realpath(root);
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
return root;
|
|
1061
|
+
}
|
|
1062
|
+
}));
|
|
1063
|
+
if (!roots.length)
|
|
1064
|
+
throw new Error('当前浏览器任务没有获批的文件上传范围');
|
|
1065
|
+
for (const file of uploadSteps.flatMap((step) => step.files ?? [])) {
|
|
1066
|
+
const canonical = await realpath(file).catch(() => '');
|
|
1067
|
+
if (!canonical)
|
|
1068
|
+
throw new Error(`上传文件不存在或不可读取:${file}`);
|
|
1069
|
+
const allowed = roots.some((root) => {
|
|
1070
|
+
const child = relative(root, canonical);
|
|
1071
|
+
return (child === '' || (!child.startsWith('..') && !isAbsolute(child)));
|
|
1072
|
+
});
|
|
1073
|
+
if (!allowed)
|
|
1074
|
+
throw new Error(`上传文件超出当前任务批准范围:${file}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const bundleDirs = input.steps
|
|
1078
|
+
.filter((step) => ['pageAssets', 'bundlePageAssets'].includes(step.action) &&
|
|
1079
|
+
(step.bundle_assets || step.action === 'bundlePageAssets'))
|
|
1080
|
+
.map((step) => step.bundle_dir)
|
|
1081
|
+
.filter((value) => Boolean(value));
|
|
1082
|
+
for (const bundleDir of bundleDirs) {
|
|
1083
|
+
const roots = [browserArtifactDir(), getWorkingDir()];
|
|
1084
|
+
const target = await realpath(bundleDir).catch(() => bundleDir);
|
|
1085
|
+
const allowed = roots.some((root) => {
|
|
1086
|
+
const child = relative(root, target);
|
|
1087
|
+
return child === '' || (!child.startsWith('..') && !isAbsolute(child));
|
|
1088
|
+
});
|
|
1089
|
+
if (!allowed)
|
|
1090
|
+
throw new Error(`资产打包目录不在浏览器产物或工作区范围内:${bundleDir}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
return {
|
|
1095
|
+
ok: false,
|
|
1096
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
let claimedTabId = input.tab_id
|
|
1101
|
+
? await claimConversationTabById({
|
|
1102
|
+
context: run,
|
|
1103
|
+
sessionName: BROWSER_SESSION,
|
|
1104
|
+
bin,
|
|
1105
|
+
globals: sessionArgs(BROWSER_SESSION),
|
|
1106
|
+
runner: spawnRunner,
|
|
1107
|
+
signal,
|
|
1108
|
+
}, input.tab_id)
|
|
1109
|
+
: await ensureConversationTab({
|
|
1110
|
+
context: run,
|
|
1111
|
+
sessionName: BROWSER_SESSION,
|
|
1112
|
+
bin,
|
|
1113
|
+
globals: sessionArgs(BROWSER_SESSION),
|
|
1114
|
+
runner: spawnRunner,
|
|
1115
|
+
signal,
|
|
1116
|
+
});
|
|
1117
|
+
if (input.retention) {
|
|
1118
|
+
browserRunRegistry.markTab(run, claimedTabId, {
|
|
1119
|
+
retention: input.retention,
|
|
1120
|
+
...(input.tab_title ? { title: input.tab_title } : {}),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
const serviceInput = {
|
|
1124
|
+
sessionName: BROWSER_SESSION,
|
|
1125
|
+
bin,
|
|
1126
|
+
runner: spawnRunner,
|
|
1127
|
+
signal,
|
|
1128
|
+
};
|
|
1129
|
+
const runExclusive = (task) => browserService.exclusive(serviceInput, async () => task());
|
|
1130
|
+
// Do not hold the process-wide browser mutation lock while waiting for a
|
|
1131
|
+
// human. Desktop must be able to click/type during waiting_human; only
|
|
1132
|
+
// each setup/action/final-observation critical section is serialized.
|
|
1133
|
+
return await (async () => {
|
|
1134
|
+
const tabMarker = `${conversationKey}:${runId}:${randomUUID()}`;
|
|
1135
|
+
const markActiveTab = async (markerSignal) => {
|
|
1136
|
+
const marked = await spawnRunner(bin, [
|
|
1137
|
+
...sessionArgs(BROWSER_SESSION),
|
|
1138
|
+
'eval',
|
|
1139
|
+
`globalThis[${JSON.stringify(BROWSER_TAB_MARKER_KEY)}]=${JSON.stringify(tabMarker)}`,
|
|
1140
|
+
], markerSignal);
|
|
1141
|
+
if (marked.code !== 0)
|
|
1142
|
+
throw new Error(marked.stderr || '无法标记当前浏览器会话标签');
|
|
1143
|
+
};
|
|
1144
|
+
// Reuse the same owned tab policy as WebBrowser before opening CDP. This
|
|
1145
|
+
// makes a batch one atomic lease instead of an accidental shared tab.
|
|
1146
|
+
// Setup is serialized, but this lock is released before any human wait.
|
|
1147
|
+
const setup = await runExclusive(async () => {
|
|
1148
|
+
const switched = await spawnRunner(bin, [...sessionArgs(BROWSER_SESSION), 'tab', claimedTabId], signal);
|
|
1149
|
+
if (switched.code !== 0)
|
|
1150
|
+
throw new Error(switched.stderr || '无法切换到已认领的浏览器标签页');
|
|
1151
|
+
await markActiveTab(signal);
|
|
1152
|
+
// The daemon exposes a browser-level CDP endpoint. Prefer the URL of
|
|
1153
|
+
// the tab we just claimed when attaching a page target, otherwise a
|
|
1154
|
+
// multi-tab session could silently bind the last unrelated tab.
|
|
1155
|
+
const currentPage = await spawnRunner(bin, [...sessionArgs(BROWSER_SESSION), 'get', 'url'], signal);
|
|
1156
|
+
const lastKnownUrl = currentPage.code === 0 ? currentPage.stdout.trim() : undefined;
|
|
1157
|
+
const cdp = await getBrowserAutomation(BROWSER_SESSION, bin, spawnRunner, signal, lastKnownUrl, tabMarker);
|
|
1158
|
+
return { cdp, lastKnownUrl };
|
|
1159
|
+
});
|
|
1160
|
+
let { cdp, lastKnownUrl } = setup;
|
|
1161
|
+
const snapshotKey = () => `${conversationKey}:${runId}:${claimedTabId}`;
|
|
1162
|
+
/**
|
|
1163
|
+
* Rebind both sides of the tab lease after a daemon/page restart.
|
|
1164
|
+
* A CDP reconnect alone is insufficient: the old tab id may have
|
|
1165
|
+
* disappeared with the browser process, and attaching the last page
|
|
1166
|
+
* would silently send the next action to another conversation.
|
|
1167
|
+
*/
|
|
1168
|
+
const rebindTab = async (recoverySignal) => {
|
|
1169
|
+
const rebound = await spawnRunner(bin, [...sessionArgs(BROWSER_SESSION), 'tab', claimedTabId], recoverySignal);
|
|
1170
|
+
if (rebound.code === 0) {
|
|
1171
|
+
const active = await spawnRunner(bin, [...sessionArgs(BROWSER_SESSION), 'get', 'url'], recoverySignal);
|
|
1172
|
+
if (active.code === 0)
|
|
1173
|
+
lastKnownUrl = active.stdout.trim();
|
|
1174
|
+
}
|
|
1175
|
+
else {
|
|
1176
|
+
const beforeResult = await spawnRunner(bin, [...sessionArgs(BROWSER_SESSION), 'tab', 'list', '--json'], recoverySignal);
|
|
1177
|
+
if (beforeResult.code !== 0)
|
|
1178
|
+
throw new Error(beforeResult.stderr || '浏览器重启后无法读取标签列表');
|
|
1179
|
+
const beforeIds = new Set(parseTabList(beforeResult.stdout).map((tab) => tab.id));
|
|
1180
|
+
const created = await spawnRunner(bin, [
|
|
1181
|
+
...sessionArgs(BROWSER_SESSION),
|
|
1182
|
+
'tab',
|
|
1183
|
+
'new',
|
|
1184
|
+
lastKnownUrl || 'about:blank',
|
|
1185
|
+
], recoverySignal);
|
|
1186
|
+
if (created.code !== 0)
|
|
1187
|
+
throw new Error(created.stderr || '浏览器重启后无法创建恢复标签');
|
|
1188
|
+
const afterResult = await spawnRunner(bin, [...sessionArgs(BROWSER_SESSION), 'tab', 'list', '--json'], recoverySignal);
|
|
1189
|
+
if (afterResult.code !== 0)
|
|
1190
|
+
throw new Error(afterResult.stderr || '浏览器重启后无法确认恢复标签');
|
|
1191
|
+
const added = parseTabList(afterResult.stdout).filter((tab) => !beforeIds.has(tab.id));
|
|
1192
|
+
const replacement = added.find((tab) => tab.active) ?? added.at(-1);
|
|
1193
|
+
if (!replacement)
|
|
1194
|
+
throw new Error('浏览器重启后未返回新的恢复标签 ID');
|
|
1195
|
+
browserRunRegistry.releaseTab(run.conversationKey, claimedTabId);
|
|
1196
|
+
browserRunRegistry.claimTab(run, replacement.id);
|
|
1197
|
+
claimedTabId = replacement.id;
|
|
1198
|
+
}
|
|
1199
|
+
const active = await spawnRunner(bin, [...sessionArgs(BROWSER_SESSION), 'get', 'url'], recoverySignal);
|
|
1200
|
+
if (active.code === 0)
|
|
1201
|
+
lastKnownUrl = active.stdout.trim();
|
|
1202
|
+
await markActiveTab(recoverySignal);
|
|
1203
|
+
};
|
|
1204
|
+
const rebindTabAndPage = async (recoverySignal) => {
|
|
1205
|
+
await rebindTab(recoverySignal);
|
|
1206
|
+
await cdp.attachToPage(lastKnownUrl, true, tabMarker);
|
|
1207
|
+
};
|
|
1208
|
+
const downloadDir = await runExclusive(() => configureDownloads(cdp, conversationKey, runId));
|
|
1209
|
+
const stream = getBrowserStream(BROWSER_SESSION);
|
|
1210
|
+
let currentStepAbort;
|
|
1211
|
+
let offControl;
|
|
1212
|
+
let offDownload;
|
|
1213
|
+
// ⚠️ 取锁必须是 try 的第一行,释放必须在同一个 finally 内——这把锁是进程级
|
|
1214
|
+
// 单例,唯一释放路径是下面的 finally。此前 beginAgentAction 与
|
|
1215
|
+
// stream.finishAgentAction 分属两个不同的 try 块(中间横跨整段初始化 +
|
|
1216
|
+
// 整个逐步执行循环),ctx.reportProgress(宿主注入回调)等任何一步抛错都会
|
|
1217
|
+
// 跳过 finishAgentAction,锁永久不释放——对齐 webbrowser.ts 的写法(见其头注)。
|
|
1218
|
+
try {
|
|
1219
|
+
stream.beginAgentAction({ runId, conversationKey, action: 'batch' });
|
|
1220
|
+
offControl = stream.subscribeControl((state) => {
|
|
1221
|
+
if (state.status === 'waiting_human')
|
|
1222
|
+
currentStepAbort?.abort(new Error('浏览器已交给用户处理'));
|
|
1223
|
+
});
|
|
1224
|
+
stream.setAgentActing(true, 'batch', `${input.steps.length} 个步骤`);
|
|
1225
|
+
ctx.reportProgress?.(`开始浏览器批量任务,共 ${input.steps.length} 个步骤`);
|
|
1226
|
+
const downloads = new Map();
|
|
1227
|
+
const subscribeDownloadEvents = (connection) => connection.subscribe((event) => {
|
|
1228
|
+
if (event.method === 'Browser.downloadWillBegin') {
|
|
1229
|
+
const id = String(event.params.guid ?? randomUUID());
|
|
1230
|
+
const item = {
|
|
1231
|
+
id,
|
|
1232
|
+
url: String(event.params.url ?? ''),
|
|
1233
|
+
filename: basename(String(event.params.suggestedFilename ?? 'download')) || 'download',
|
|
1234
|
+
path: join(downloadDir, basename(String(event.params.suggestedFilename ?? 'download')) || 'download'),
|
|
1235
|
+
state: 'start',
|
|
1236
|
+
startedAt: Date.now(),
|
|
1237
|
+
};
|
|
1238
|
+
downloads.set(id, item);
|
|
1239
|
+
stream.publish({ type: 'download', ...item, state: 'start' });
|
|
1240
|
+
}
|
|
1241
|
+
if (event.method === 'Browser.downloadProgress') {
|
|
1242
|
+
const id = String(event.params.guid ?? '');
|
|
1243
|
+
const item = downloads.get(id);
|
|
1244
|
+
if (!item)
|
|
1245
|
+
return;
|
|
1246
|
+
const state = event.params.state === 'completed'
|
|
1247
|
+
? 'complete'
|
|
1248
|
+
: event.params.state === 'canceled'
|
|
1249
|
+
? 'canceled'
|
|
1250
|
+
: 'progress';
|
|
1251
|
+
item.state = state;
|
|
1252
|
+
item.receivedBytes =
|
|
1253
|
+
Number(event.params.receivedBytes ?? 0) || undefined;
|
|
1254
|
+
item.totalBytes = Number(event.params.totalBytes ?? 0) || undefined;
|
|
1255
|
+
item.error =
|
|
1256
|
+
typeof event.params.error === 'string'
|
|
1257
|
+
? event.params.error
|
|
1258
|
+
: undefined;
|
|
1259
|
+
stream.publish({
|
|
1260
|
+
type: 'download',
|
|
1261
|
+
...item,
|
|
1262
|
+
state,
|
|
1263
|
+
receivedBytes: item.receivedBytes,
|
|
1264
|
+
totalBytes: item.totalBytes,
|
|
1265
|
+
...(item.error ? { error: item.error } : {}),
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
offDownload = subscribeDownloadEvents(cdp);
|
|
1270
|
+
const results = [];
|
|
1271
|
+
let artifactManifest;
|
|
1272
|
+
for (const [index, step] of input.steps.entries()) {
|
|
1273
|
+
const started = Date.now();
|
|
1274
|
+
const stepAbort = new AbortController();
|
|
1275
|
+
currentStepAbort = stepAbort;
|
|
1276
|
+
const abortFromParent = () => stepAbort.abort(signal?.reason);
|
|
1277
|
+
if (signal?.aborted)
|
|
1278
|
+
stepAbort.abort(signal.reason);
|
|
1279
|
+
else
|
|
1280
|
+
signal?.addEventListener('abort', abortFromParent, { once: true });
|
|
1281
|
+
const waits = [];
|
|
1282
|
+
try {
|
|
1283
|
+
ctx.reportProgress?.(`浏览器批量任务 ${index + 1}/${input.steps.length}:${step.action}`);
|
|
1284
|
+
const conditions = step.expect?.conditions ?? [];
|
|
1285
|
+
const preHuman = await runExclusive(async () => {
|
|
1286
|
+
const eventCursor = cdp.read(0).at(-1)?.sequence ?? 0;
|
|
1287
|
+
const baseline = await evaluate(cdp, `location.href + '|' + (document.body?.innerText || '').slice(0, 4000)`, stepAbort.signal).catch(() => undefined);
|
|
1288
|
+
if (step.expect?.navigation)
|
|
1289
|
+
waits.push(waitFor(cdp, (e) => e.method.startsWith('Page.frame') &&
|
|
1290
|
+
(!step.expect?.navigation?.url ||
|
|
1291
|
+
JSON.stringify(e.params).includes(step.expect.navigation.url)), timeoutMs, eventCursor, stepAbort.signal));
|
|
1292
|
+
if (step.expect?.new_tab)
|
|
1293
|
+
waits.push(waitFor(cdp, (e) => e.method === 'Target.targetCreated' &&
|
|
1294
|
+
(!step.expect?.new_tab?.url ||
|
|
1295
|
+
JSON.stringify(e.params).includes(step.expect.new_tab.url)), timeoutMs, eventCursor, stepAbort.signal));
|
|
1296
|
+
if (step.expect?.download)
|
|
1297
|
+
waits.push(waitFor(cdp, (e) => e.method === 'Browser.downloadWillBegin' &&
|
|
1298
|
+
(!step.expect?.download?.filename ||
|
|
1299
|
+
String(e.params.suggestedFilename ?? '').includes(step.expect.download.filename)), timeoutMs, eventCursor, stepAbort.signal));
|
|
1300
|
+
const uploadChooser = step.action === 'upload' &&
|
|
1301
|
+
(step.trigger_selector || step.trigger_locator)
|
|
1302
|
+
? { files: step.files }
|
|
1303
|
+
: undefined;
|
|
1304
|
+
if (step.expect?.file_chooser || uploadChooser)
|
|
1305
|
+
waits.push(waitFor(cdp, (e) => e.method === 'Page.fileChooserOpened', timeoutMs, eventCursor, stepAbort.signal));
|
|
1306
|
+
for (const condition of step.expect?.conditions ?? []) {
|
|
1307
|
+
if (condition.type === 'download' ||
|
|
1308
|
+
condition.type === 'new-tab' ||
|
|
1309
|
+
condition.type === 'dialog') {
|
|
1310
|
+
waits.push(waitFor(cdp, (event) => eventMatchesCondition(condition, event), timeoutMs, eventCursor, stepAbort.signal));
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
const value = await withBrowserRecovery(() => executeStep(cdp, step, timeoutMs, eventCursor, snapshotKey(), stepAbort.signal), {
|
|
1314
|
+
signal: stepAbort.signal,
|
|
1315
|
+
reconnect: async () => {
|
|
1316
|
+
await ensureDaemon(BROWSER_SESSION, {
|
|
1317
|
+
signal: stepAbort.signal,
|
|
1318
|
+
forceRestart: true,
|
|
1319
|
+
});
|
|
1320
|
+
browserRunRegistry.invalidateAllTabs();
|
|
1321
|
+
// A daemon restart can publish a new browser-level WebSocket
|
|
1322
|
+
// URL. Rebind the tab first, then mark that exact tab, and
|
|
1323
|
+
// only then create a fresh page-scoped CDP connection.
|
|
1324
|
+
await rebindTab(stepAbort.signal);
|
|
1325
|
+
const previousCdp = cdp;
|
|
1326
|
+
offDownload?.();
|
|
1327
|
+
cdp = await getBrowserAutomation(BROWSER_SESSION, bin, spawnRunner, stepAbort.signal, lastKnownUrl, tabMarker);
|
|
1328
|
+
offDownload = subscribeDownloadEvents(cdp);
|
|
1329
|
+
previousCdp.close();
|
|
1330
|
+
},
|
|
1331
|
+
rebindTab: async () => {
|
|
1332
|
+
await rebindTabAndPage(stepAbort.signal);
|
|
1333
|
+
},
|
|
1334
|
+
});
|
|
1335
|
+
if (step.action === 'navigate' && step.url)
|
|
1336
|
+
lastKnownUrl = step.url;
|
|
1337
|
+
let events = [];
|
|
1338
|
+
if (waits.length) {
|
|
1339
|
+
const challengeAbort = new AbortController();
|
|
1340
|
+
const challengeRace = waitForBrowserChallenge(cdp, timeoutMs, challengeAbort.signal).then((challenge) => {
|
|
1341
|
+
if (challenge)
|
|
1342
|
+
throw new Error(`页面需要人工验证:${challenge.summary}`);
|
|
1343
|
+
return new Promise(() => undefined);
|
|
1344
|
+
});
|
|
1345
|
+
try {
|
|
1346
|
+
events = await Promise.race([
|
|
1347
|
+
Promise.all(waits),
|
|
1348
|
+
challengeRace,
|
|
1349
|
+
]);
|
|
1350
|
+
}
|
|
1351
|
+
finally {
|
|
1352
|
+
challengeAbort.abort();
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (step.artifact && step.action === 'exportContent') {
|
|
1356
|
+
const nextManifest = await materializeExportArtifact(value, step.export_format ?? 'page', step.artifact.save_as);
|
|
1357
|
+
if (step.artifact.label)
|
|
1358
|
+
nextManifest.files[0].label = step.artifact.label;
|
|
1359
|
+
artifactManifest = mergeArtifactManifests(artifactManifest, nextManifest);
|
|
1360
|
+
}
|
|
1361
|
+
else if (step.artifact) {
|
|
1362
|
+
const startedDownload = events.find((event) => event.method === 'Browser.downloadWillBegin');
|
|
1363
|
+
const downloadId = String(startedDownload?.params.guid ?? '');
|
|
1364
|
+
const item = downloadId
|
|
1365
|
+
? downloads.get(downloadId)
|
|
1366
|
+
: [...downloads.values()].at(-1);
|
|
1367
|
+
if (!item)
|
|
1368
|
+
throw new Error('未捕获到要保存为成果的下载');
|
|
1369
|
+
if (item.state !== 'complete') {
|
|
1370
|
+
const completed = await waitForEventOrBrowserChallenge(cdp, (event) => event.method === 'Browser.downloadProgress' &&
|
|
1371
|
+
String(event.params.guid ?? '') === item.id &&
|
|
1372
|
+
['completed', 'canceled'].includes(String(event.params.state ?? '')), timeoutMs, eventCursor, stepAbort.signal);
|
|
1373
|
+
if (completed.params.state !== 'completed')
|
|
1374
|
+
throw new Error(`下载未完成:${String(completed.params.state ?? 'unknown')}`);
|
|
1375
|
+
item.state = 'complete';
|
|
1376
|
+
}
|
|
1377
|
+
item.path = await waitForDownloadedFile(downloadDir, item, timeoutMs, stepAbort.signal);
|
|
1378
|
+
const nextManifest = await materializeDownloadArtifact(item.path, step.artifact.save_as);
|
|
1379
|
+
if (step.artifact.label) {
|
|
1380
|
+
nextManifest.files[0].label = step.artifact.label;
|
|
1381
|
+
}
|
|
1382
|
+
artifactManifest = mergeArtifactManifests(artifactManifest, nextManifest);
|
|
1383
|
+
}
|
|
1384
|
+
const chooser = step.expect?.file_chooser ?? uploadChooser;
|
|
1385
|
+
if (chooser?.files?.length) {
|
|
1386
|
+
const opened = events.find((event) => event.method === 'Page.fileChooserOpened');
|
|
1387
|
+
const backendNodeId = Number(opened?.params.backendNodeId ?? 0);
|
|
1388
|
+
if (!backendNodeId)
|
|
1389
|
+
throw new Error('Browser did not provide the file chooser node');
|
|
1390
|
+
await cdp.send('DOM.setFileInputFiles', {
|
|
1391
|
+
files: chooser.files,
|
|
1392
|
+
backendNodeId,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
for (const condition of conditions) {
|
|
1396
|
+
if (condition.type === 'download' ||
|
|
1397
|
+
condition.type === 'new-tab' ||
|
|
1398
|
+
condition.type === 'dialog')
|
|
1399
|
+
continue;
|
|
1400
|
+
await waitForCondition(cdp, condition, timeoutMs, baseline, stepAbort.signal);
|
|
1401
|
+
}
|
|
1402
|
+
const challenge = await readBrowserChallenge(cdp, stepAbort.signal);
|
|
1403
|
+
return { value, events, challenge };
|
|
1404
|
+
});
|
|
1405
|
+
const { value, events, challenge } = preHuman;
|
|
1406
|
+
let humanSnapshot;
|
|
1407
|
+
if (challenge) {
|
|
1408
|
+
stream.requestHuman(challenge.reason, {
|
|
1409
|
+
runId,
|
|
1410
|
+
conversationKey,
|
|
1411
|
+
ownerId: 'local-owner',
|
|
1412
|
+
summary: challenge.summary,
|
|
1413
|
+
});
|
|
1414
|
+
const resolution = await resumeBatchAfterHuman(stream, ctx, signal, challenge.summary);
|
|
1415
|
+
if (resolution === 'stopped')
|
|
1416
|
+
throw new Error('浏览器批量任务已由用户停止');
|
|
1417
|
+
humanSnapshot = await runExclusive(() => snapshot(cdp, snapshotKey(), 'full', signal));
|
|
1418
|
+
}
|
|
1419
|
+
results.push({
|
|
1420
|
+
id: step.id ?? String(index + 1),
|
|
1421
|
+
ok: true,
|
|
1422
|
+
duration_ms: Date.now() - started,
|
|
1423
|
+
result: value,
|
|
1424
|
+
resolution_strategy: resolveInteractionStrategy(step),
|
|
1425
|
+
events,
|
|
1426
|
+
...(humanSnapshot
|
|
1427
|
+
? {
|
|
1428
|
+
human_intervention: 'resumed',
|
|
1429
|
+
post_intervention_snapshot: humanSnapshot,
|
|
1430
|
+
}
|
|
1431
|
+
: {}),
|
|
1432
|
+
...(conditions.length
|
|
1433
|
+
? {
|
|
1434
|
+
conditions_satisfied: conditions.map((condition) => condition.type),
|
|
1435
|
+
}
|
|
1436
|
+
: {}),
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
catch (error) {
|
|
1440
|
+
await Promise.allSettled(waits);
|
|
1441
|
+
let errorMessage = error instanceof Error ? error.message : String(error);
|
|
1442
|
+
if (stream.getControlState().status !== 'waiting_human') {
|
|
1443
|
+
const challenge = await runExclusive(() => readBrowserChallenge(cdp).catch(() => null));
|
|
1444
|
+
if (challenge) {
|
|
1445
|
+
stream.requestHuman(challenge.reason, {
|
|
1446
|
+
runId,
|
|
1447
|
+
conversationKey,
|
|
1448
|
+
ownerId: 'local-owner',
|
|
1449
|
+
summary: challenge.summary,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (stream.getControlState().status === 'waiting_human') {
|
|
1454
|
+
const resolution = await resumeBatchAfterHuman(stream, ctx, signal, '浏览器批量步骤已暂停');
|
|
1455
|
+
if (resolution === 'resumed') {
|
|
1456
|
+
const recovered = await runExclusive(() => snapshot(cdp, snapshotKey(), 'full', signal));
|
|
1457
|
+
results.push({
|
|
1458
|
+
id: step.id ?? String(index + 1),
|
|
1459
|
+
ok: false,
|
|
1460
|
+
duration_ms: Date.now() - started,
|
|
1461
|
+
resolution_strategy: resolveInteractionStrategy(step),
|
|
1462
|
+
error: '人工接管中断了当前步骤;浏览器已恢复,但该步骤是否完成无法自动确认。请依据最新快照重新确认后再继续。',
|
|
1463
|
+
result: {
|
|
1464
|
+
human_intervention: 'resumed_unverified',
|
|
1465
|
+
post_intervention_snapshot: recovered,
|
|
1466
|
+
},
|
|
1467
|
+
});
|
|
1468
|
+
// Never continue subsequent steps with an unknown side effect:
|
|
1469
|
+
// a click may have landed before the handoff, while a form
|
|
1470
|
+
// submission may still be pending. The next model turn can
|
|
1471
|
+
// inspect the fresh snapshot and deliberately resume.
|
|
1472
|
+
break;
|
|
1473
|
+
}
|
|
1474
|
+
errorMessage = '浏览器批量任务已由用户停止';
|
|
1475
|
+
}
|
|
1476
|
+
results.push({
|
|
1477
|
+
id: step.id ?? String(index + 1),
|
|
1478
|
+
ok: false,
|
|
1479
|
+
duration_ms: Date.now() - started,
|
|
1480
|
+
resolution_strategy: resolveInteractionStrategy(step),
|
|
1481
|
+
error: errorMessage,
|
|
1482
|
+
});
|
|
1483
|
+
if (stopOnError)
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
finally {
|
|
1487
|
+
stepAbort.abort();
|
|
1488
|
+
signal?.removeEventListener('abort', abortFromParent);
|
|
1489
|
+
if (currentStepAbort === stepAbort)
|
|
1490
|
+
currentStepAbort = undefined;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
let final;
|
|
1494
|
+
let finalScreenshot;
|
|
1495
|
+
await runExclusive(async () => {
|
|
1496
|
+
final =
|
|
1497
|
+
finalSnapshotMode === 'none'
|
|
1498
|
+
? undefined
|
|
1499
|
+
: await snapshot(cdp, `${conversationKey}:${runId}:${claimedTabId}`, finalSnapshotMode, signal);
|
|
1500
|
+
if (captureMode === 'screenshot' || captureMode === 'both') {
|
|
1501
|
+
const path = await createArtifactPath('browser-batch-final', 'png');
|
|
1502
|
+
const captured = (await cdp.send('Page.captureScreenshot', {
|
|
1503
|
+
format: 'png',
|
|
1504
|
+
fromSurface: true,
|
|
1505
|
+
}));
|
|
1506
|
+
if (!captured.data)
|
|
1507
|
+
throw new Error('浏览器未返回最终截图');
|
|
1508
|
+
await writeFile(path, Buffer.from(captured.data, 'base64'), {
|
|
1509
|
+
mode: 0o600,
|
|
1510
|
+
});
|
|
1511
|
+
finalScreenshot = path;
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
const failed = results.some((result) => result.ok === false);
|
|
1515
|
+
const content = JSON.stringify({
|
|
1516
|
+
tab_id: claimedTabId,
|
|
1517
|
+
download_dir: downloadDir,
|
|
1518
|
+
results,
|
|
1519
|
+
...(final ? { final_snapshot: final } : {}),
|
|
1520
|
+
...(finalScreenshot ? { final_screenshot: finalScreenshot } : {}),
|
|
1521
|
+
});
|
|
1522
|
+
// A direct export (PDF/MD/Workspace formats) has no Browser.download*
|
|
1523
|
+
// event, but it is still a formal browser deliverable. Keep one
|
|
1524
|
+
// metadata envelope for the Desktop delivery ledger and allow an empty
|
|
1525
|
+
// downloads list for that case.
|
|
1526
|
+
const metadata = downloads.size || artifactManifest
|
|
1527
|
+
? {
|
|
1528
|
+
kind: 'browser_download',
|
|
1529
|
+
downloads: [...downloads.values()],
|
|
1530
|
+
...(artifactManifest ? { artifact: artifactManifest } : {}),
|
|
1531
|
+
}
|
|
1532
|
+
: undefined;
|
|
1533
|
+
return failed
|
|
1534
|
+
? { ok: false, error: content, ...(metadata ? { metadata } : {}) }
|
|
1535
|
+
: { ok: true, content, ...(metadata ? { metadata } : {}) };
|
|
1536
|
+
}
|
|
1537
|
+
finally {
|
|
1538
|
+
offDownload?.();
|
|
1539
|
+
offControl?.();
|
|
1540
|
+
currentStepAbort?.abort();
|
|
1541
|
+
currentStepAbort = undefined;
|
|
1542
|
+
cdp.close();
|
|
1543
|
+
stream.finishAgentAction(runId);
|
|
1544
|
+
try {
|
|
1545
|
+
stream.setAgentActing(false);
|
|
1546
|
+
}
|
|
1547
|
+
catch {
|
|
1548
|
+
// UI decoration failure must not hide the batch result or leak control state.
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
})();
|
|
1552
|
+
}
|
|
1553
|
+
catch (error) {
|
|
1554
|
+
return {
|
|
1555
|
+
ok: false,
|
|
1556
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
finally {
|
|
1560
|
+
// AX snapshots are part of the BrowserBatch contract: the model may use
|
|
1561
|
+
// the returned generation in the next tool call. Keep them behind the
|
|
1562
|
+
// bounded TTL/capacity eviction in captureFinalSnapshot instead of
|
|
1563
|
+
// invalidating the reference as soon as this call returns.
|
|
1564
|
+
browserRunRegistry.finish(conversationKey, runId);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
// biome-ignore lint/suspicious/noExplicitAny: Tool's schema parser supplies the inferred output type.
|
|
1568
|
+
export const browserBatchTool = {
|
|
1569
|
+
name: 'BrowserBatch',
|
|
1570
|
+
description: 'Executes multiple visible-browser actions under one tab claim and one persistent CDP connection. Supports semantic locators, event expectations, downloads, waits and final snapshots.',
|
|
1571
|
+
inputSchema,
|
|
1572
|
+
parameters: toToolParameters(inputSchema),
|
|
1573
|
+
isReadOnly: false,
|
|
1574
|
+
isConcurrencySafe: false,
|
|
1575
|
+
maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
1576
|
+
call,
|
|
1577
|
+
callWithContext: call,
|
|
1578
|
+
};
|