minimal-agent 0.10.0 → 0.12.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 +11 -3
- package/package.json +52 -5
- package/plugins/computer_use/.claude-plugin/plugin.json +6 -0
- package/plugins/computer_use/README.md +94 -0
- package/plugins/computer_use/app/_template.md +142 -0
- package/plugins/computer_use/app/chrome.md +165 -0
- package/plugins/computer_use/app/finder.md +76 -0
- package/plugins/computer_use/app/macos-system-settings.md +81 -0
- package/plugins/computer_use/app/microsoft-excel.md +80 -0
- package/plugins/computer_use/app/microsoft-powerpoint.md +80 -0
- package/plugins/computer_use/app/microsoft-word.md +79 -0
- package/plugins/computer_use/app/wechat.md +413 -0
- package/plugins/computer_use/app/weread.md +319 -0
- package/plugins/computer_use/commands/computeruse.md +25 -0
- package/plugins/computer_use/commands/cu.md +9 -0
- package/plugins/computer_use/plugin.js +26 -0
- package/plugins/computer_use/plugin.ts +30 -0
- package/plugins/computer_use/src/capture.js +90 -0
- package/plugins/computer_use/src/capture.ts +104 -0
- package/plugins/computer_use/src/commands.js +91 -0
- package/plugins/computer_use/src/commands.ts +103 -0
- package/plugins/computer_use/src/daemon.js +134 -0
- package/plugins/computer_use/src/daemon.ts +161 -0
- package/plugins/computer_use/src/manuals.js +135 -0
- package/plugins/computer_use/src/manuals.ts +159 -0
- package/plugins/computer_use/src/verify.js +168 -0
- package/plugins/computer_use/src/verify.ts +219 -0
- package/plugins/computer_use/test/commands.test.ts +73 -0
- package/plugins/cron-runner/plugin.ts +38 -0
- package/plugins/cron-runner/src/commands.ts +373 -0
- package/plugins/cron-runner/src/cronTime.ts +155 -0
- package/plugins/cron-runner/src/lock.ts +79 -0
- package/plugins/cron-runner/src/runs.ts +99 -0
- package/plugins/cron-runner/src/scheduleEngine.ts +76 -0
- package/plugins/cron-runner/src/scheduler.ts +376 -0
- package/plugins/cron-runner/src/schema.ts +139 -0
- package/plugins/cron-runner/src/service.ts +193 -0
- package/plugins/cron-runner/src/store.ts +333 -0
- package/plugins/cron-runner/src/types.ts +141 -0
- package/plugins/cron-runner/test/cronTime.test.ts +41 -0
- package/plugins/cron-runner/test/plugin-integration.test.ts +47 -0
- package/plugins/cron-runner/test/scheduleEngine.test.ts +41 -0
- package/plugins/cron-runner/test/service.test.ts +76 -0
- package/plugins/cron-runner/test/store-and-scheduler.test.ts +501 -0
- package/plugins/workflow-runner/plugin.js +56 -2
- package/plugins/workflow-runner/plugin.ts +113 -0
- package/plugins/workflow-runner/src/agentCommon.js +43 -4
- package/plugins/workflow-runner/src/agentCommon.ts +435 -0
- package/plugins/workflow-runner/src/controlFlow/branch.js +40 -0
- package/plugins/workflow-runner/src/controlFlow/branch.ts +46 -0
- package/plugins/workflow-runner/src/controlFlow/loop.js +211 -0
- package/plugins/workflow-runner/src/controlFlow/loop.ts +248 -0
- package/plugins/workflow-runner/src/controlFlow/pause.js +129 -0
- package/plugins/workflow-runner/src/controlFlow/pause.ts +141 -0
- package/plugins/workflow-runner/src/controlFlow/signal.js +21 -0
- package/plugins/workflow-runner/src/controlFlow/signal.ts +29 -0
- package/plugins/workflow-runner/src/controlFlow/types.js +18 -0
- package/plugins/workflow-runner/src/controlFlow/types.ts +63 -0
- package/plugins/workflow-runner/src/dslManifest.ts +127 -0
- package/plugins/workflow-runner/src/expressions.ts +622 -0
- package/plugins/workflow-runner/src/index.js +13 -4
- package/plugins/workflow-runner/src/index.ts +338 -0
- package/plugins/workflow-runner/src/jsonPayload.ts +84 -0
- package/plugins/workflow-runner/src/loader.js +23 -0
- package/plugins/workflow-runner/src/loader.ts +721 -0
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +64 -0
- package/plugins/workflow-runner/src/recovery.ts +149 -0
- package/plugins/workflow-runner/src/runner.js +75 -659
- package/plugins/workflow-runner/src/runner.ts +604 -0
- package/plugins/workflow-runner/src/stepExecutors/agent.ts +234 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +50 -0
- package/plugins/workflow-runner/src/stepExecutors/echo.js +31 -0
- package/plugins/workflow-runner/src/stepExecutors/echo.ts +38 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +133 -0
- package/plugins/workflow-runner/src/stepExecutors/set.js +49 -0
- package/plugins/workflow-runner/src/stepExecutors/set.ts +56 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +140 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.js +80 -8
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +256 -0
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +68 -0
- package/plugins/workflow-runner/src/stepPipeline.js +277 -0
- package/plugins/workflow-runner/src/stepPipeline.ts +366 -0
- package/plugins/workflow-runner/src/taskIdentity.ts +405 -0
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.js +37 -1
- package/plugins/workflow-runner/src/types.ts +602 -0
- package/plugins/workflow-runner/src/workflowState.ts +733 -0
- package/plugins/workflow-runner/test/agent.test.ts +587 -0
- package/plugins/workflow-runner/test/agentLlmError.test.ts +373 -0
- package/plugins/workflow-runner/test/bashBlacklist.sweep.test.ts +65 -0
- package/plugins/workflow-runner/test/captureMisbind.test.ts +99 -0
- package/plugins/workflow-runner/test/cli.e2e.test.ts +147 -0
- package/plugins/workflow-runner/test/context-files.test.ts +320 -0
- package/plugins/workflow-runner/test/e2e.test.ts +370 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +226 -0
- package/plugins/workflow-runner/test/expressions.test.ts +397 -0
- package/plugins/workflow-runner/test/foundation.test.ts +126 -0
- package/plugins/workflow-runner/test/graceful.test.ts +139 -0
- package/plugins/workflow-runner/test/greedyInputs.load.test.ts +47 -0
- package/plugins/workflow-runner/test/hostCapability.test.ts +108 -0
- package/plugins/workflow-runner/test/illustrate.load.test.ts +183 -0
- package/plugins/workflow-runner/test/jsonPayload.test.ts +83 -0
- package/plugins/workflow-runner/test/loader.test.ts +617 -0
- package/plugins/workflow-runner/test/maxTurnsRetryDelay.test.ts +33 -0
- package/plugins/workflow-runner/test/multimodal.test.ts +342 -0
- package/plugins/workflow-runner/test/output-schema.test.ts +213 -0
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +87 -0
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +269 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +1220 -0
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +220 -0
- package/plugins/workflow-runner/test/recovery.test.ts +265 -0
- package/plugins/workflow-runner/test/runner.test.ts +911 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +379 -0
- package/plugins/workflow-runner/test/skillTurnDone.test.ts +71 -0
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +150 -0
- package/plugins/workflow-runner/test/stepPipeline.test.ts +114 -0
- package/plugins/workflow-runner/test/taskArbitration.test.ts +338 -0
- package/plugins/workflow-runner/test/taskIdentity.test.ts +237 -0
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +270 -0
- package/plugins/workflow-runner/test/toolArtifactOverwrite.test.ts +221 -0
- package/plugins/workflow-runner/test/toolCapabilityBoundary.test.ts +29 -0
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/plugins/workflow-runner/test/webppt.load.test.ts +250 -0
- package/plugins/workflow-runner/test/word.load.test.ts +208 -0
- package/plugins/workflow-runner/test/workflows.smoke.test.ts +76 -0
- package/plugins/workflow-runner/test/yamlGuardConditions.test.ts +207 -0
- package/skills/browser-automation/SKILL.md +9 -19
- package/skills/web-crawler/SKILL.md +9 -11
- package/src/askBridge.ts +249 -0
- package/src/bootstrap/cwdArg.js +10 -3
- package/src/bootstrap/cwdArg.ts +31 -0
- package/src/bootstrap/workingDir.ts +48 -0
- package/src/cli/args.js +70 -7
- package/src/cli/args.ts +146 -0
- package/src/cli/computerUse.js +185 -0
- package/src/cli/computerUse.ts +221 -0
- package/src/cli/configMenu.js +274 -202
- package/src/cli/configMenu.tsx +521 -0
- package/src/cli/memoryAccess.js +45 -0
- package/src/cli/memoryAccess.ts +53 -0
- package/src/cli/print.js +119 -5
- package/src/cli/print.ts +597 -0
- package/src/cli/quickModelSetup.js +60 -0
- package/src/cli/quickModelSetup.tsx +96 -0
- package/src/cli/streamJson.js +80 -4
- package/src/cli/streamJson.ts +433 -0
- package/src/cli/wizards/advisorWizard.js +1 -0
- package/src/cli/wizards/advisorWizard.tsx +195 -0
- package/src/cli/wizards/agentRuntimeWizard.js +84 -0
- package/src/cli/wizards/agentRuntimeWizard.tsx +134 -0
- package/src/cli/wizards/browserWizard.js +55 -0
- package/src/cli/wizards/browserWizard.tsx +77 -0
- package/src/cli/wizards/contextAdvisorWizard.js +84 -0
- package/src/cli/wizards/contextAdvisorWizard.tsx +124 -0
- package/src/cli/wizards/fieldForm.js +7 -4
- package/src/cli/wizards/fieldForm.tsx +250 -0
- package/src/cli/wizards/imageWizard.js +4 -5
- package/src/cli/wizards/imageWizard.tsx +96 -0
- package/src/cli/wizards/openRouterWizard.tsx +48 -0
- package/src/cli/wizards/providerPresets.js +1 -8
- package/src/cli/wizards/providerPresets.ts +106 -0
- package/src/cli/wizards/providerWizard.js +152 -205
- package/src/cli/wizards/providerWizard.tsx +266 -0
- package/src/cli/wizards/searchWizard.tsx +47 -0
- package/src/cli/wizards/ttsWizard.js +4 -3
- package/src/cli/wizards/ttsWizard.tsx +61 -0
- package/src/config/configDefaults.js +25 -0
- package/src/config/configDefaults.ts +33 -0
- package/src/config/configDiagnostics.js +117 -0
- package/src/config/configDiagnostics.ts +166 -0
- package/src/config/configFile.js +52 -32
- package/src/config/configFile.ts +633 -0
- package/src/config/configManagement.js +126 -0
- package/src/config/configManagement.ts +159 -0
- package/src/config/configService.js +66 -11
- package/src/config/configService.ts +554 -0
- package/src/config/numericLimits.js +61 -0
- package/src/config/numericLimits.ts +80 -0
- package/src/config.ts +109 -0
- package/src/context/agentHome.ts +11 -0
- package/src/context/archive.ts +144 -0
- package/src/context/budgetPlan.js +54 -0
- package/src/context/budgetPlan.ts +88 -0
- package/src/context/compact.js +179 -106
- package/src/context/compact.ts +584 -0
- package/src/context/contextBundle.js +119 -0
- package/src/context/contextBundle.ts +176 -0
- package/src/context/contextLock.ts +84 -0
- package/src/context/microCompactLite.ts +218 -0
- package/src/context/persistContext.ts +177 -0
- package/src/context/reactiveCompact.js +19 -4
- package/src/context/reactiveCompact.ts +240 -0
- package/src/context/recentDirs.ts +42 -0
- package/src/context/sessionPath.ts +206 -0
- package/src/context/sessionRegistry.js +8 -2
- package/src/context/sessionRegistry.ts +414 -0
- package/src/context/snipCompact.ts +163 -0
- package/src/context/tokenCounter.ts +106 -0
- package/src/context/workspaceIndex.js +2 -2
- package/src/context/workspaceIndex.ts +751 -0
- package/src/entry.compiled.tsx +20 -0
- package/src/harness/claimValidation.js +9 -0
- package/src/harness/claimValidation.ts +22 -0
- package/src/harness/evidenceLedger.js +92 -0
- package/src/harness/evidenceLedger.ts +96 -0
- package/src/harness/stallGuard.js +60 -0
- package/src/harness/stallGuard.ts +81 -0
- package/src/humanIntervention.js +129 -0
- package/src/humanIntervention.ts +216 -0
- package/src/infra/verification.ts +295 -0
- package/src/irreversible/rules.js +216 -0
- package/src/irreversible/rules.ts +261 -0
- package/src/llm/client.js +40 -3
- package/src/llm/client.ts +621 -0
- package/src/llm/promptCache.js +27 -0
- package/src/llm/promptCache.ts +41 -0
- package/src/llm/providers/anthropicFormat.js +41 -5
- package/src/llm/providers/anthropicFormat.ts +290 -0
- package/src/llm/providers/openaiFormat.js +186 -8
- package/src/llm/providers/openaiFormat.ts +428 -0
- package/src/loop.js +614 -63
- package/src/loop.ts +1454 -0
- package/src/main.js +67 -10
- package/src/main.tsx +344 -0
- package/src/memory/codec.js +204 -0
- package/src/memory/codec.ts +249 -0
- package/src/memory/constants.js +34 -0
- package/src/memory/constants.ts +50 -0
- package/src/memory/episodicMemory.js +684 -0
- package/src/memory/episodicMemory.ts +815 -0
- package/src/memory/fileStore.js +148 -0
- package/src/memory/fileStore.ts +184 -0
- package/src/memory/memoryService.js +570 -0
- package/src/memory/memoryService.ts +859 -0
- package/src/memory/migration.js +128 -0
- package/src/memory/migration.ts +160 -0
- package/src/memory/repository.js +789 -0
- package/src/memory/repository.ts +893 -0
- package/src/memory/retrieval.js +108 -0
- package/src/memory/retrieval.ts +145 -0
- package/src/memory/searchIndex.js +107 -0
- package/src/memory/searchIndex.ts +153 -0
- package/src/memory/topicView.js +82 -0
- package/src/memory/topicView.ts +95 -0
- package/src/memory/turnJournal.js +95 -0
- package/src/memory/turnJournal.ts +120 -0
- package/src/memory/types.js +2 -0
- package/src/memory/types.ts +186 -0
- package/src/messageQueue.ts +123 -0
- package/src/multimodal.js +128 -46
- package/src/multimodal.ts +349 -0
- package/src/plugin-sdk.js +21 -1
- package/src/plugin-sdk.ts +134 -0
- package/src/plugins/builtinPluginIds.js +47 -0
- package/src/plugins/builtinPluginIds.ts +51 -0
- package/src/plugins/builtinRegistry.ts +43 -0
- package/src/plugins/commandRouter.js +40 -26
- package/src/plugins/commandRouter.ts +261 -0
- package/src/plugins/hookEngine.ts +341 -0
- package/src/plugins/pluginApi.ts +133 -0
- package/src/plugins/pluginLifecycle.ts +34 -0
- package/src/plugins/pluginLoader.ts +147 -0
- package/src/plugins/pluginManifest.js +127 -0
- package/src/plugins/pluginManifest.ts +152 -0
- package/src/plugins/pluginRunner.js +47 -2
- package/src/plugins/pluginRunner.ts +207 -0
- package/src/plugins/transcript.ts +208 -0
- package/src/prompts/lint.js +169 -0
- package/src/prompts/lint.ts +220 -0
- package/src/prompts/pluginList.js +32 -16
- package/src/prompts/pluginList.ts +86 -0
- package/src/prompts/policyRegistry.js +318 -0
- package/src/prompts/policyRegistry.ts +358 -0
- package/src/prompts/projectInstructions.js +24 -0
- package/src/prompts/projectInstructions.ts +81 -0
- package/src/prompts/promptAssembly.js +311 -202
- package/src/prompts/promptAssembly.ts +510 -0
- package/src/prompts/skillList.js +106 -45
- package/src/prompts/skillList.ts +198 -0
- package/src/prompts/system.js +31 -6
- package/src/prompts/system.ts +81 -0
- package/src/runtime/agentRuntimeContext.js +53 -0
- package/src/runtime/agentRuntimeContext.ts +112 -0
- package/src/session/runAgentTurn.js +71 -12
- package/src/session/runAgentTurn.ts +181 -0
- package/src/session/runTurn.js +29 -0
- package/src/session/runTurn.ts +160 -0
- package/src/session/runtimeStore.js +149 -0
- package/src/session/runtimeStore.ts +197 -0
- package/src/session/sessionState.js +79 -3
- package/src/session/sessionState.ts +134 -0
- package/src/toolGate.ts +50 -0
- package/src/tools/advisor/advisor.js +76 -15
- package/src/tools/advisor/advisor.ts +512 -0
- package/src/tools/agent/agent.js +245 -29
- package/src/tools/agent/agent.ts +837 -0
- package/src/tools/agent/agentTaskService.js +60 -0
- package/src/tools/agent/agentTaskService.ts +113 -0
- package/src/tools/agent/agentTypes.js +20 -8
- package/src/tools/agent/agentTypes.ts +94 -0
- package/src/tools/agent/coordinator.ts +111 -0
- package/src/tools/agent/worktree.ts +296 -0
- package/src/tools/ask/ask.js +17 -0
- package/src/tools/ask/ask.ts +122 -0
- package/src/tools/bash/attachImage.js +223 -0
- package/src/tools/bash/attachImage.ts +281 -0
- package/src/tools/bash/bash.js +132 -14
- package/src/tools/bash/bash.ts +642 -0
- package/src/tools/bash/semantics.ts +103 -0
- package/src/tools/bash/warnings.js +17 -11
- package/src/tools/bash/warnings.ts +117 -0
- package/src/tools/capabilities.js +42 -0
- package/src/tools/capabilities.ts +69 -0
- package/src/tools/computeruse/accessibilityPermission.js +261 -0
- package/src/tools/computeruse/accessibilityPermission.ts +294 -0
- package/src/tools/computeruse/axSearch.js +96 -0
- package/src/tools/computeruse/axSearch.ts +120 -0
- package/src/tools/computeruse/axSnap.js +272 -0
- package/src/tools/computeruse/axSnap.ts +349 -0
- package/src/tools/computeruse/capabilityLabels.js +95 -0
- package/src/tools/computeruse/capabilityLabels.ts +115 -0
- package/src/tools/computeruse/computerUseService.js +2235 -0
- package/src/tools/computeruse/computerUseService.ts +2686 -0
- package/src/tools/computeruse/computeruse.js +2742 -0
- package/src/tools/computeruse/computeruse.ts +3265 -0
- package/src/tools/computeruse/diagnosticTrace.js +154 -0
- package/src/tools/computeruse/diagnosticTrace.ts +195 -0
- package/src/tools/computeruse/diagnostics.js +369 -0
- package/src/tools/computeruse/diagnostics.ts +453 -0
- package/src/tools/computeruse/grants.js +53 -0
- package/src/tools/computeruse/grants.ts +70 -0
- package/src/tools/computeruse/humanBoundary.js +159 -0
- package/src/tools/computeruse/humanBoundary.ts +199 -0
- package/src/tools/computeruse/intervention.js +77 -0
- package/src/tools/computeruse/intervention.ts +110 -0
- package/src/tools/computeruse/manualSchema.js +709 -0
- package/src/tools/computeruse/manualSchema.ts +916 -0
- package/src/tools/computeruse/observationImages.js +705 -0
- package/src/tools/computeruse/observationImages.ts +889 -0
- package/src/tools/computeruse/protocol.js +60 -0
- package/src/tools/computeruse/protocol.ts +515 -0
- package/src/tools/computeruse/telemetryLog.js +57 -0
- package/src/tools/computeruse/telemetryLog.ts +67 -0
- package/src/tools/computeruse/vision/appGuide.js +325 -0
- package/src/tools/computeruse/vision/appGuide.ts +482 -0
- package/src/tools/computeruse/vision/focus.js +291 -0
- package/src/tools/computeruse/vision/focus.ts +382 -0
- package/src/tools/computeruse/vision/frames.js +248 -0
- package/src/tools/computeruse/vision/frames.ts +329 -0
- package/src/tools/computeruse/vision/geometry.js +131 -0
- package/src/tools/computeruse/vision/geometry.ts +163 -0
- package/src/tools/computeruse/vision/imaging.js +201 -0
- package/src/tools/computeruse/vision/imaging.ts +281 -0
- package/src/tools/computeruse/vision/png.js +274 -0
- package/src/tools/computeruse/vision/png.ts +317 -0
- package/src/tools/computeruse/vision/raster.js +320 -0
- package/src/tools/computeruse/vision/raster.ts +408 -0
- package/src/tools/computeruse/vision/settle.js +131 -0
- package/src/tools/computeruse/vision/settle.ts +220 -0
- package/src/tools/computeruse/vision/telemetry.js +196 -0
- package/src/tools/computeruse/vision/telemetry.ts +241 -0
- package/src/tools/computeruse/visionOcr.js +115 -0
- package/src/tools/computeruse/visionOcr.ts +128 -0
- package/src/tools/computeruse/waitFor.js +237 -0
- package/src/tools/computeruse/waitFor.ts +291 -0
- package/src/tools/download/download.js +10 -6
- package/src/tools/download/download.ts +187 -0
- package/src/tools/edit/edit.js +30 -18
- package/src/tools/edit/edit.ts +349 -0
- package/src/tools/edit/multi-edit.js +25 -14
- package/src/tools/edit/multi-edit.ts +223 -0
- package/src/tools/effectiveToolSet.js +73 -0
- package/src/tools/effectiveToolSet.ts +91 -0
- package/src/tools/genimage/genimage.ts +257 -0
- package/src/tools/genspeech/genspeech.ts +83 -0
- package/src/tools/glob/glob.js +176 -29
- package/src/tools/glob/glob.ts +283 -0
- package/src/tools/grep/grep.js +61 -19
- package/src/tools/grep/grep.ts +239 -0
- package/src/tools/grep/rgPath.ts +206 -0
- package/src/tools/index.js +99 -5
- package/src/tools/index.ts +293 -0
- package/src/tools/lsp/config.js +113 -0
- package/src/tools/lsp/config.ts +113 -0
- package/src/tools/lsp/lsp.js +256 -0
- package/src/tools/lsp/lsp.ts +274 -0
- package/src/tools/lsp/service.js +303 -0
- package/src/tools/lsp/service.ts +296 -0
- package/src/tools/memory/memory.js +520 -0
- package/src/tools/memory/memory.ts +625 -0
- package/src/tools/memory/policy.js +53 -0
- package/src/tools/memory/policy.ts +72 -0
- package/src/tools/notebook/notebookEdit.js +114 -0
- package/src/tools/notebook/notebookEdit.ts +126 -0
- package/src/tools/powershell/powershell.js +210 -0
- package/src/tools/powershell/powershell.ts +239 -0
- package/src/tools/presentresult/presentresult.js +145 -0
- package/src/tools/presentresult/presentresult.ts +156 -0
- package/src/tools/read/read.js +371 -163
- package/src/tools/read/read.ts +558 -0
- package/src/tools/shared/diffSummary.js +44 -0
- package/src/tools/shared/diffSummary.ts +63 -0
- package/src/tools/shared/fileActivity.js +51 -0
- package/src/tools/shared/fileActivity.ts +66 -0
- package/src/tools/shared/fileMutationBus.js +13 -0
- package/src/tools/shared/fileMutationBus.ts +14 -0
- package/src/tools/shared/fileState.js +73 -65
- package/src/tools/shared/fileState.ts +145 -0
- package/src/tools/shared/fileUtils.js +81 -4
- package/src/tools/shared/fileUtils.ts +504 -0
- package/src/tools/shared/killTree.ts +50 -0
- package/src/tools/shared/permanentDeleteGuard.js +26 -0
- package/src/tools/shared/permanentDeleteGuard.ts +34 -0
- package/src/tools/shared/schemas.ts +20 -0
- package/src/tools/shared/toolOutputStore.js +97 -0
- package/src/tools/shared/toolOutputStore.ts +110 -0
- package/src/tools/skill/skill.js +86 -0
- package/src/tools/skill/skill.ts +94 -0
- package/src/tools/snip/snip.js +67 -0
- package/src/tools/snip/snip.ts +80 -0
- package/src/tools/tasks/backgroundTaskService.js +275 -0
- package/src/tools/tasks/backgroundTaskService.ts +369 -0
- package/src/tools/tasks/processEvidence.js +24 -0
- package/src/tools/tasks/processEvidence.ts +34 -0
- package/src/tools/tasks/taskManagement.js +163 -0
- package/src/tools/tasks/taskManagement.ts +180 -0
- package/src/tools/tasks/taskOutput.js +113 -0
- package/src/tools/tasks/taskOutput.ts +145 -0
- package/src/tools/tasks/taskStop.js +74 -0
- package/src/tools/tasks/taskStop.ts +99 -0
- package/src/tools/tasks/taskStore.js +46 -0
- package/src/tools/tasks/taskStore.ts +77 -0
- package/src/tools/terminal/terminalCapture.js +167 -0
- package/src/tools/terminal/terminalCapture.ts +179 -0
- package/src/tools/trash/trash.js +233 -0
- package/src/tools/trash/trash.ts +283 -0
- package/src/tools/types.js +1 -1
- package/src/tools/types.ts +24 -0
- package/src/tools/webbrowser/agentBrowserPath.ts +286 -0
- package/src/tools/webbrowser/browser.js +214 -11
- package/src/tools/webbrowser/browser.ts +293 -0
- package/src/tools/webbrowser/browserControl.js +153 -48
- package/src/tools/webbrowser/browserControl.ts +262 -0
- package/src/tools/webbrowser/browserDaemon.js +147 -39
- package/src/tools/webbrowser/browserDaemon.ts +270 -0
- package/src/tools/webbrowser/browserOutcome.js +273 -0
- package/src/tools/webbrowser/browserOutcome.ts +306 -0
- package/src/tools/webbrowser/browserRunRegistry.js +150 -0
- package/src/tools/webbrowser/browserRunRegistry.ts +179 -0
- package/src/tools/webbrowser/browserService.js +20 -6
- package/src/tools/webbrowser/browserService.ts +56 -0
- package/src/tools/webbrowser/browserStream.js +30 -1
- package/src/tools/webbrowser/browserStream.ts +216 -0
- package/src/tools/webbrowser/browserTabs.js +143 -0
- package/src/tools/webbrowser/browserTabs.ts +210 -0
- package/src/tools/webbrowser/webbrowser.js +831 -150
- package/src/tools/webbrowser/webbrowser.ts +1537 -0
- package/src/tools/webfetch/preapproved.ts +274 -0
- package/src/tools/webfetch/webfetch.js +24 -23
- package/src/tools/webfetch/webfetch.ts +383 -0
- package/src/tools/websearch/websearch.js +12 -11
- package/src/tools/websearch/websearch.ts +292 -0
- package/src/tools/worktree/sessionWorktree.js +128 -0
- package/src/tools/worktree/sessionWorktree.ts +158 -0
- package/src/tools/write/write.js +27 -44
- package/src/tools/write/write.ts +136 -0
- package/src/types.ts +960 -0
- package/src/ui/App.js +216 -12
- package/src/ui/App.tsx +879 -0
- package/src/ui/AskQuestion.tsx +300 -0
- package/src/ui/InputBox.js +77 -1
- package/src/ui/InputBox.tsx +567 -0
- package/src/ui/LiveArea.tsx +344 -0
- package/src/ui/MessageList.js +27 -6
- package/src/ui/MessageList.tsx +559 -0
- package/src/ui/ModelPicker.tsx +107 -0
- package/src/ui/QueuedMessagesBanner.tsx +51 -0
- package/src/ui/Root.js +6 -8
- package/src/ui/Root.tsx +100 -0
- package/src/ui/SessionPicker.tsx +169 -0
- package/src/ui/StatusLine.js +22 -1
- package/src/ui/StatusLine.tsx +145 -0
- package/src/ui/WorkspacePicker.tsx +84 -0
- package/src/ui/advisorCommand.js +54 -0
- package/src/ui/advisorCommand.ts +63 -0
- package/src/ui/computerCommand.js +19 -0
- package/src/ui/computerCommand.ts +35 -0
- package/src/ui/figures.ts +45 -0
- package/src/ui/hooks/askSlot.ts +112 -0
- package/src/ui/hooks/useChat.js +271 -7
- package/src/ui/hooks/useChat.ts +1578 -0
- package/src/ui/hooks/useInputHistory.ts +235 -0
- package/src/ui/hooks/usePasteHandler.ts +220 -0
- package/src/ui/hooks/useTerminalSize.ts +41 -0
- package/src/ui/hooks/useTextBuffer.ts +108 -0
- package/src/ui/hooks/useTokenUsage.ts +44 -0
- package/src/ui/liveViewport.ts +189 -0
- package/src/ui/markdown.ts +230 -0
- package/src/ui/textBuffer.ts +239 -0
- package/src/ui/theme.ts +35 -0
- package/src/utils/clipboardImage.js +358 -0
- package/src/utils/clipboardImage.ts +420 -0
- package/src/utils/debugLog.ts +50 -0
- package/src/utils/greenRoot.ts +35 -0
- package/src/utils/packageRoot.ts +40 -0
- package/src/utils/pathContains.js +28 -0
- package/src/utils/pathContains.ts +31 -0
- package/src/utils/resourcePaths.ts +71 -0
- package/src/utils/stripThink.ts +49 -0
- package/src/utils/zodToJson.ts +73 -0
- package/src/web/browserBridge.js +365 -66
- package/src/web/browserBridge.ts +672 -0
- package/src/web/protocol.ts +1804 -0
- package/vendor/computer-use/arm64-darwin/cu-helperd +0 -0
- package/workflows/ppt.yaml +13 -2
- package/workflows/wechat-reading-shots.yaml +315 -0
- package/workflows/word.yaml +11 -1
- package/skills/docx/scripts/office/helpers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/helpers/__pycache__/merge_runs.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/helpers/__pycache__/simplify_redlines.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/docx.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/pptx.cpython-314.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/redlining.cpython-314.pyc +0 -0
- package/skills/image-stats/scripts/__pycache__/image_stats.cpython-314.pyc +0 -0
- package/skills/image-stats/scripts/__pycache__/image_stats_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/assemble_material.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/assemble_material_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/chart_sanity_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/check_language_policy.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/check_ui_artifact.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/check_ui_artifact_pptx_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_pages_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_pages_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_visual_style_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/deck_visual_style_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_budget_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_budget_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_repair_hints.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_repair_hints_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_style_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/design_style_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/draft_coverage_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/extract_source_assets.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/extract_source_assets_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/generated_image_review_collect.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/generated_image_review_collect_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_plan_lint.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_plan_lint_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/layout_balance.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/layout_balance_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/normalize_design_json.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/normalize_design_json_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/plan_source_layout.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/plan_source_layout_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_hint.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_hint_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_input.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/resolve_source_input_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/scaffold_ui_artifact.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/source_usage_check.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/source_usage_check_test.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/text_readability_pixel.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/visual_quality_gate.cpython-314.pyc +0 -0
- package/skills/ui-design/scripts/__pycache__/visual_quality_gate_test.cpython-314.pyc +0 -0
- /package/skills/ai-image/scripts/{generate_test.mjs → generate.selftest.mjs} +0 -0
- /package/skills/ai-image/scripts/{generate_from_plan_test.mjs → generate_from_plan.selftest.mjs} +0 -0
|
@@ -0,0 +1,3265 @@
|
|
|
1
|
+
import { appendFile, mkdir, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { pathContainedByAny } from '../../utils/pathContains.js';
|
|
5
|
+
import { hasAskHandler, requestUserChoice } from '../../askBridge.js';
|
|
6
|
+
import { configService } from '../../config/configService.js';
|
|
7
|
+
import type { ImageInputSpec, Tool, ToolResult } from '../../types.js';
|
|
8
|
+
import {
|
|
9
|
+
getPermissionTargetName,
|
|
10
|
+
isRunningInAppBundle,
|
|
11
|
+
openAccessibilitySettings,
|
|
12
|
+
openScreenCaptureSettings,
|
|
13
|
+
} from './accessibilityPermission.js';
|
|
14
|
+
import {
|
|
15
|
+
type AxSearchNode,
|
|
16
|
+
describeNode,
|
|
17
|
+
findClickableByText,
|
|
18
|
+
flattenAx,
|
|
19
|
+
} from './axSearch.js';
|
|
20
|
+
import {
|
|
21
|
+
computerUseService,
|
|
22
|
+
computerUseStateDir,
|
|
23
|
+
} from './computerUseService.js';
|
|
24
|
+
import { looksLikeIrreversibleRequest } from './grants.js';
|
|
25
|
+
import {
|
|
26
|
+
classifyIntent,
|
|
27
|
+
isGlobalCriticalIntent,
|
|
28
|
+
type RiskHit,
|
|
29
|
+
} from '../../irreversible/rules.js';
|
|
30
|
+
import {
|
|
31
|
+
type ComputerHumanBoundary,
|
|
32
|
+
classifyPixelChange,
|
|
33
|
+
detectComputerHumanBoundary,
|
|
34
|
+
detectPixelHumanBoundary,
|
|
35
|
+
} from './humanBoundary.js';
|
|
36
|
+
import {
|
|
37
|
+
EMPTY_MANUAL,
|
|
38
|
+
renderManualDigest,
|
|
39
|
+
unmetPreconditionsFor,
|
|
40
|
+
type AppManual,
|
|
41
|
+
} from './manualSchema.js';
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_OBSERVE_FRAMES,
|
|
44
|
+
type ReplayFrame,
|
|
45
|
+
buildObservationImages,
|
|
46
|
+
buildReplayImages,
|
|
47
|
+
modelFacingImageSize,
|
|
48
|
+
resolveObservationFrames,
|
|
49
|
+
} from './observationImages.js';
|
|
50
|
+
import {
|
|
51
|
+
type AccessibilityNode,
|
|
52
|
+
COMPUTER_USE_TOOL_ACTIONS,
|
|
53
|
+
type ComputerState,
|
|
54
|
+
type ComputerUseRect,
|
|
55
|
+
type ComputerUseRequest,
|
|
56
|
+
type ComputerUseResponse,
|
|
57
|
+
isMutatingComputerUseAction,
|
|
58
|
+
requiredCapability,
|
|
59
|
+
} from './protocol.js';
|
|
60
|
+
import {
|
|
61
|
+
CONSECUTIVE_FAILURE_LIMIT,
|
|
62
|
+
recordComputerUseCall,
|
|
63
|
+
} from './telemetryLog.js';
|
|
64
|
+
import { recordComputerUseDiagnosticStep } from './diagnosticTrace.js';
|
|
65
|
+
import {
|
|
66
|
+
type AppGuide,
|
|
67
|
+
describeMatch,
|
|
68
|
+
loadAllAppManuals,
|
|
69
|
+
matchGuide,
|
|
70
|
+
matchIrreversible,
|
|
71
|
+
} from './vision/appGuide.js';
|
|
72
|
+
import {
|
|
73
|
+
FOCUS_CHANGE,
|
|
74
|
+
deriveFocusSnapshot,
|
|
75
|
+
describeFocus,
|
|
76
|
+
describeFocusChange,
|
|
77
|
+
recommendedAction,
|
|
78
|
+
} from './vision/focus.js';
|
|
79
|
+
import { evaluateSettle } from './vision/settle.js';
|
|
80
|
+
import {
|
|
81
|
+
type WaitAction,
|
|
82
|
+
isSimpleWaitAction,
|
|
83
|
+
isWaitAction,
|
|
84
|
+
waitForCondition,
|
|
85
|
+
} from './waitFor.js';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 「翻阅长内容」这段指导的**唯一出处**。
|
|
89
|
+
*
|
|
90
|
+
* ⚠️ 它此前有两份几乎逐字重复的拷贝:工具描述里一份、`GENERIC_INTERACTION_GUIDE`
|
|
91
|
+
* 里一份。两份各自演进就会漂移 —— 实测代价:给 `repeat` 补 `repeatFrames` 说明时
|
|
92
|
+
* 必须记得改两个地方,漏一个模型就只在某些场景看得到那条约束。
|
|
93
|
+
*
|
|
94
|
+
* 现在两处都引用这一份。`computerUseGuideSingleSource.test.ts` 钉住这条不变量。
|
|
95
|
+
*/
|
|
96
|
+
/**
|
|
97
|
+
* 遍历长内容的通用指引。**每次观测都会注入**,所以这里的每一行都要付 token。
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ 写作原则:只写**模型要照做的规则**,不写我们的事故复盘 —— 后者是给维护者的,
|
|
100
|
+
* 放进来只会挤占预算(`computerUseManuals.test.ts` 用 2000 字上限守着这条)。
|
|
101
|
+
*
|
|
102
|
+
* 第 5 条的来历:一次「读 20 条聊天记录」的真机任务里,模型 `pageup` 翻到起点读完后,
|
|
103
|
+
* 又 `pagedown` 原路翻回去 —— 多花 4 轮往返约 104 秒(占全程 31%)和 4 张全屏图的
|
|
104
|
+
* token,**零新增信息**。早先的措辞是「只读遍历读完后不必翻回原位」,它回答的是
|
|
105
|
+
* 「有没有必要」,没回答模型心里真正的问题:「我逆序看的,是不是得再顺序看一遍
|
|
106
|
+
* 才算读全?」现在改成讲清**方向即顺序**,那个疑问才被正面消掉。
|
|
107
|
+
*/
|
|
108
|
+
export const TRAVERSAL_GUIDE_LINES = [
|
|
109
|
+
' 1. `scroll` + x/y(首选):坐标必须框在**内容区中心**,不是边缘、不是滚动条。',
|
|
110
|
+
' 滚轮事件投递给**鼠标所在**的视图,坐标落在哪就滚哪 —— 落在旁边的列表上,',
|
|
111
|
+
' 滚的就是那个列表(实测发生过:连滚多次,目标区一行没动、旁边的列表滚了一大截)。',
|
|
112
|
+
' 2. `scroll` 无效时改键盘:先 click 内容区中心让它获得焦点,再 `key` 发',
|
|
113
|
+
' `pageup` / `pagedown`(整页),或 `home` / `end`(跳到最开头 / 最末尾)。',
|
|
114
|
+
' 键盘事件走**焦点控件**,不依赖鼠标位置,在滚轮失效的自绘应用上往往反而更稳。',
|
|
115
|
+
' 3. **用 `repeat` 一次翻多页,不要一页一轮。** 一页一轮的开销比是 15:1 ——',
|
|
116
|
+
' 翻页本身约 1 秒,而每轮完整思考约 20 秒。`key`/`scroll` 都支持 `repeat`(上限 8),',
|
|
117
|
+
' 一轮内连做 N 次、把**每次之后的画面**一起带回,翻到边界自动停并告诉你停在第几次。',
|
|
118
|
+
' ⚠️ 只是想**快速定位**(翻到第 N 页、跳到列表末尾)而不读中间内容时,务必加',
|
|
119
|
+
' `repeatFrames:"last"` 只取终点一张 —— 否则 repeat:8 会一次回 8 张全屏图',
|
|
120
|
+
' (约 1 万 token),中间那些页你根本不看。',
|
|
121
|
+
' 4. 回执里的变化率优先报**目标区域**(标注了是「目标区域」还是「全屏」)。',
|
|
122
|
+
' 提示「目标区域没有变化」= 这一路没生效,**换第 2 条,不要加大滚动距离重试**。',
|
|
123
|
+
' 5. ⭐ **翻页方向就是顺序信息:往回翻的过程本身就是阅读,别再顺序翻一遍。**',
|
|
124
|
+
' 每翻一次那一屏都已截图给你,加上你自己发出的方向,先后顺序就是确定的 ——',
|
|
125
|
+
' `pageup`/上滚 = 看到的是**从新到旧**,`pagedown`/下滚 = **从旧到新**。',
|
|
126
|
+
' 看到 4 3 2 1、确认 1 是起点就**读完了**;要正序输出自己倒排即可,不必回翻。',
|
|
127
|
+
' ⛔ 这条同样管 `end` / `home`:往回读到一半按 `end` 跳回底部,跟原路翻回去',
|
|
128
|
+
' 是同一种浪费(实测一次任务里 3 次 `end`,白花约 84 秒),而且会丢失读到哪儿了。',
|
|
129
|
+
' 只有某一屏当时确实没看清(被挡住/正在加载)才单独回到**那一屏**。',
|
|
130
|
+
' 6. ⛔ **任何情况下都不要用 `drag` 去拖滚动条,也不要去点那条细边。**',
|
|
131
|
+
' 现代应用的滚动条通常是隐藏的、只有几像素宽;拖偏或点偏就落到内容上 ——',
|
|
132
|
+
' 在聊天/文档里那不是滚动,是**框选文本**,属于误操作,而且看起来还像"成功了"。',
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const actionValues = COMPUTER_USE_TOOL_ACTIONS;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 模型能自报的动作类别形状。**只允许 `intent:` 前缀**。
|
|
139
|
+
*
|
|
140
|
+
* `key:` / `region:` 是引擎自己就能验证的模式(按了哪个键、点在哪个矩形里,
|
|
141
|
+
* 都是它手里的事实),接受模型自报这两类等于把可验证的判据换成不可验证的
|
|
142
|
+
* 声明 —— 一个想绕过审批的模型只要少报一句就行,而引擎本来能自己看见。
|
|
143
|
+
* 所以自报通道只开给引擎**本来就看不见**的那一类:意图。
|
|
144
|
+
*/
|
|
145
|
+
const MODEL_DECLARABLE_ACTION_CLASS = /^intent:[a-z0-9][a-z0-9-]*$/;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 可选**字符串**字段里,空串一律等同「没给」。
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ 这不是宽松,是修一个真实的调不动问题:不少模型(GPT 系尤其明显)会把 schema 里
|
|
151
|
+
* **每个**可选字段都填上默认值再发过来 —— `appId:"" / elementId:"" / textTarget:"" /
|
|
152
|
+
* actionClass:""`。而下游全都按 `!== undefined` 判断「有没有给」,于是空串被当成
|
|
153
|
+
* 「你要用它」:`textTarget:""` 去按空文字找控件、`actionClass:""` 撞格式校验……
|
|
154
|
+
*
|
|
155
|
+
* 实测代价(用户 Desktop 实机):连 `observe` 都调不动,而且报错一路歪 ——
|
|
156
|
+
* 最外层显示的是「Computer Use 运行身份与当前宿主会话不匹配」,
|
|
157
|
+
* 照那个方向根本查不到是参数问题。
|
|
158
|
+
*
|
|
159
|
+
* 空串对这些字段**从来没有过意义**(没有哪个应用的 bundleId 是空串),
|
|
160
|
+
* 所以归一成 undefined 不丢任何表达力。数值字段不做这种归一 ——
|
|
161
|
+
* `x:0` 是屏幕最左边,是**合法坐标**,不能当成"没给"。
|
|
162
|
+
*/
|
|
163
|
+
const BLANKABLE_STRING_FIELDS = [
|
|
164
|
+
'appId', 'windowId', 'displayId', 'elementId', 'expectedRevision',
|
|
165
|
+
'text', 'key', 'role', 'textTarget', 'actionClass',
|
|
166
|
+
] as const;
|
|
167
|
+
|
|
168
|
+
const inputSchema = z
|
|
169
|
+
.object({
|
|
170
|
+
action: z.enum(actionValues),
|
|
171
|
+
appId: z.string().optional(),
|
|
172
|
+
windowId: z.string().optional(),
|
|
173
|
+
displayId: z.string().optional(),
|
|
174
|
+
elementId: z.string().optional(),
|
|
175
|
+
expectedRevision: z.string().optional(),
|
|
176
|
+
expectedEpoch: z.number().int().nonnegative().optional(),
|
|
177
|
+
x: z.number().min(0).max(1000).optional(),
|
|
178
|
+
y: z.number().min(0).max(1000).optional(),
|
|
179
|
+
/** ★ UI-TARS bbox 支持:框选右下角坐标(0-1000),系统自动取中心点点击,容错率更高 */
|
|
180
|
+
x2: z.number().min(0).max(1000).optional(),
|
|
181
|
+
y2: z.number().min(0).max(1000).optional(),
|
|
182
|
+
toX: z.number().min(0).max(1000).optional(),
|
|
183
|
+
toY: z.number().min(0).max(1000).optional(),
|
|
184
|
+
dx: z.number().optional(),
|
|
185
|
+
dy: z.number().optional(),
|
|
186
|
+
text: z.string().max(100_000).optional(),
|
|
187
|
+
key: z.string().max(100).optional(),
|
|
188
|
+
/**
|
|
189
|
+
* 把同一个动作连做几次,**一轮往返内完成**(只对 `key` / `scroll` 生效)。
|
|
190
|
+
*
|
|
191
|
+
* 存在的理由是实测出来的:一次「翻阅聊天记录」的任务里 14 次翻页键花了 270 秒,
|
|
192
|
+
* 而这些按键的工具执行总耗时只有 18 秒 —— 开销比 15:1,全部花在「每按一次就要
|
|
193
|
+
* 完整地想一轮」上。遍历任何可滚动区域(长列表、文档、邮件、diff)都是这个形状。
|
|
194
|
+
*
|
|
195
|
+
* 上限 8:再大就等于把「翻到哪了」的判断权整个交给一次盲目重复,
|
|
196
|
+
* 而模型看不到中间过程就无法纠偏。到边界会自动提前停(见 runRepeatedAction)。
|
|
197
|
+
*/
|
|
198
|
+
repeat: z.number().int().min(1).max(8).optional(),
|
|
199
|
+
repeatFrames: z.enum(['all', 'last']).optional(),
|
|
200
|
+
direction: z.enum(['up', 'down', 'left', 'right']).optional(),
|
|
201
|
+
/** AX 角色。`wait_for_element` 用它;点击类动作用 `textTarget` 而不是它。 */
|
|
202
|
+
role: z.string().max(64).optional(),
|
|
203
|
+
/** 按可见文字定位点击目标(与 elementId / x,y 三选一)。 */
|
|
204
|
+
textTarget: z.string().max(200).optional(),
|
|
205
|
+
pixelX: z.number().min(0).optional(),
|
|
206
|
+
pixelY: z.number().min(0).optional(),
|
|
207
|
+
pixelX2: z.number().min(0).optional(),
|
|
208
|
+
pixelY2: z.number().min(0).optional(),
|
|
209
|
+
filePaths: z.array(z.string()).max(20).optional(),
|
|
210
|
+
rect: z
|
|
211
|
+
.object({
|
|
212
|
+
x: z.number(),
|
|
213
|
+
y: z.number(),
|
|
214
|
+
width: z.number().positive(),
|
|
215
|
+
height: z.number().positive(),
|
|
216
|
+
})
|
|
217
|
+
.optional(),
|
|
218
|
+
magnify: z
|
|
219
|
+
.object({
|
|
220
|
+
x: z.number(),
|
|
221
|
+
y: z.number(),
|
|
222
|
+
width: z.number().positive(),
|
|
223
|
+
height: z.number().positive(),
|
|
224
|
+
})
|
|
225
|
+
.optional(),
|
|
226
|
+
magnifyAnchor: z.enum(['screen', 'window']).optional(),
|
|
227
|
+
timeoutMs: z.number().int().min(100).max(120_000).optional(),
|
|
228
|
+
quietMs: z.number().int().min(100).max(5_000).optional(),
|
|
229
|
+
/** 简单 wait 动作的等待时间(毫秒),默认 1000ms */
|
|
230
|
+
waitMs: z.number().int().min(10).max(30_000).optional(),
|
|
231
|
+
intent: z.enum(['normal', 'submit']).optional(),
|
|
232
|
+
actionClass: z.string().optional(),
|
|
233
|
+
frames: z.number().int().min(1).max(6).optional(),
|
|
234
|
+
force: z.boolean().optional(),
|
|
235
|
+
windowOperation: z
|
|
236
|
+
.enum(['focus', 'minimize', 'restore', 'close', 'move_resize', 'center'])
|
|
237
|
+
.optional(),
|
|
238
|
+
dropMethod: z.enum(['paste', 'open_panel']).optional(),
|
|
239
|
+
})
|
|
240
|
+
.superRefine((input, ctx) => {
|
|
241
|
+
// ⚠️ 这里必须自己判空串:zod 的执行顺序是 object → superRefine → transform,
|
|
242
|
+
// 而归一空串的 transform 排在**后面**,所以 superRefine 看到的仍是原始的 ""。
|
|
243
|
+
// (transform 不能提到前面:那样 superRefine 就拿不到 ZodObject 的类型推断。)
|
|
244
|
+
if (input.actionClass === undefined || input.actionClass === '') return;
|
|
245
|
+
if (!MODEL_DECLARABLE_ACTION_CLASS.test(input.actionClass)) {
|
|
246
|
+
ctx.addIssue({
|
|
247
|
+
code: z.ZodIssueCode.custom,
|
|
248
|
+
path: ['actionClass'],
|
|
249
|
+
message:
|
|
250
|
+
'actionClass 只能是 `intent:<小写短横线名>` 形式,取值必须来自当前应用说明书的 ' +
|
|
251
|
+
'irreversible: 清单(引擎已把该清单写进观测回执)。key:/region: 由引擎自行判定,不接受自报。',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// 类别是「这次提交属于哪一类」的限定语,没有提交就没有可限定的东西。
|
|
255
|
+
// 允许它单独出现会造出一个看着像在申报、实际什么也没申报的调用形状。
|
|
256
|
+
if (input.intent !== 'submit') {
|
|
257
|
+
ctx.addIssue({
|
|
258
|
+
code: z.ZodIssueCode.custom,
|
|
259
|
+
path: ['actionClass'],
|
|
260
|
+
message:
|
|
261
|
+
'actionClass 必须与 intent:"submit" 一起给出 —— 它是对这次提交属于哪一类的限定,' +
|
|
262
|
+
'单独出现没有意义。',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
// 空串归一成「没给」——必须在 superRefine **之后**、且用 transform 而不是
|
|
267
|
+
// preprocess:preprocess 会把 schema 的输入类型抹成 unknown,与 Tool 接口不兼容。
|
|
268
|
+
.transform((input) => {
|
|
269
|
+
const out = { ...input } as Record<string, unknown>;
|
|
270
|
+
for (const field of BLANKABLE_STRING_FIELDS) {
|
|
271
|
+
if (out[field] === '') delete out[field];
|
|
272
|
+
}
|
|
273
|
+
// ⚠️ `expectedEpoch` 不再是模型的责任 —— 整个字段一律丢弃。
|
|
274
|
+
//
|
|
275
|
+
// 它是我们发明的并发令牌,可它**每次 observe 都会 +1,包括工具内部
|
|
276
|
+
// 自己发起的那些**(verified transaction 的 before 快照、probe、
|
|
277
|
+
// settle 轮询…)。模型只能看到自己显式 observe 的回执,中间被我们
|
|
278
|
+
// 推走的部分它无从得知 —— 这是一个物理上无法履行的契约。
|
|
279
|
+
//
|
|
280
|
+
// 实测代价(两次真机 trace):`stale epoch: expected 4, current 10`
|
|
281
|
+
// 这类失败 7 次,全部是模型如实转述了它上次看到的数。
|
|
282
|
+
//
|
|
283
|
+
// 正确性并没有因此损失:`runVerifiedTransaction` 本来就用
|
|
284
|
+
// `input.expectedEpoch ?? before.epoch` 兜底,而 `before` 是它在动作
|
|
285
|
+
// 前**当场拍的**那一张 —— 模型给的数从未参与过任何有效判断。
|
|
286
|
+
// 「别基于过期坐标动手」这件事由 verified transaction 的前后差分
|
|
287
|
+
// (movedRatio / probeRegion)真正把关,不靠一个计数器。
|
|
288
|
+
delete out.expectedEpoch;
|
|
289
|
+
// ⚠️ `pixelX/pixelY` 同为 0、且又给了比例坐标时,按「模型填了默认值」处理。
|
|
290
|
+
// 像素坐标 (0,0) 是截图最左上角那一个像素 —— 作为点击目标几乎不可能是真实意图,
|
|
291
|
+
// 而同时又给出 x/y 更说明它只是在填字段。
|
|
292
|
+
// 实测代价:GPT 因此**连撞 9 次**坐标互斥,9 轮 LLM 往返全部作废,
|
|
293
|
+
// 而它每次都照原样重发 —— 报错没能让它改对。
|
|
294
|
+
if (out.pixelX === 0 && out.pixelY === 0 && (out.x !== undefined || out.y !== undefined)) {
|
|
295
|
+
delete out.pixelX;
|
|
296
|
+
delete out.pixelY;
|
|
297
|
+
if (out.pixelX2 === 0) delete out.pixelX2;
|
|
298
|
+
if (out.pixelY2 === 0) delete out.pixelY2;
|
|
299
|
+
}
|
|
300
|
+
return out as typeof input;
|
|
301
|
+
});
|
|
302
|
+
type ComputerUseInput = z.infer<typeof inputSchema>;
|
|
303
|
+
|
|
304
|
+
function capabilityForInput(input: ComputerUseInput) {
|
|
305
|
+
return requiredCapability(input.action, input.intent ?? 'normal');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 关闭窗口算不可逆:未保存的内容会丢,而且没有「撤销关闭」。
|
|
310
|
+
*
|
|
311
|
+
* 它与模型自报的 `intent:'submit'` 走同一条准入,因为后果是同一类的 ——
|
|
312
|
+
* 一个动作发生后就收不回来了。
|
|
313
|
+
*/
|
|
314
|
+
function isIrreversible(input: ComputerUseInput): boolean {
|
|
315
|
+
if (input.intent === 'submit') return true;
|
|
316
|
+
return input.action === 'window' && input.windowOperation === 'close';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function actionDescription(
|
|
320
|
+
input: ComputerUseInput,
|
|
321
|
+
): NonNullable<
|
|
322
|
+
Parameters<NonNullable<import('../../toolGate.js').ToolGate>>[0]['action']
|
|
323
|
+
> {
|
|
324
|
+
const capability = capabilityForInput(input);
|
|
325
|
+
return {
|
|
326
|
+
kind: input.action,
|
|
327
|
+
targetApp: input.appId,
|
|
328
|
+
transmitsData: ['type', 'clipboard_write', 'file_drop'].includes(
|
|
329
|
+
input.action,
|
|
330
|
+
),
|
|
331
|
+
irreversible: isIrreversible(input),
|
|
332
|
+
capability,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export const computerUseTool: Tool<ComputerUseInput> = {
|
|
337
|
+
name: 'ComputerUse',
|
|
338
|
+
description: `观察和操作宿主支持的原生桌面应用。网页 DOM 交互应使用 WebBrowser。
|
|
339
|
+
|
|
340
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
341
|
+
【 核心铁律 #0:先 Observe 截图看清现状,再做任何操作 —— 没有例外 】
|
|
342
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
343
|
+
这和 Edit 工具要求「先 Read 文件再编辑」是同一个级别的强制规则:
|
|
344
|
+
✅ Edit 工具:不 Read 文件内容就 Edit = 盲改,会把代码改坏
|
|
345
|
+
✅ ComputerUse:不 Observe 看截图就操作 = 盲打,会点错按钮、发错消息、汇错钱
|
|
346
|
+
|
|
347
|
+
规则适用范围:**所有鼠标/键盘写操作,没有例外**
|
|
348
|
+
- click / double_click / right_click(点击任何位置前)
|
|
349
|
+
- type / key / hotkey(打字、按快捷键前)
|
|
350
|
+
- drag / scroll(拖拽、滚动前)
|
|
351
|
+
- activate_app / window 操作(切应用、关窗口前)
|
|
352
|
+
- 任何会改变界面状态的动作
|
|
353
|
+
|
|
354
|
+
强制要求:
|
|
355
|
+
1. 每次动手前,必须基于「最新的一张 observe 截图」判断坐标和目标,不能凭记忆、不能想当然、不能沿用上一次的结论
|
|
356
|
+
2. 界面随时可能变:新消息弹窗、别的应用抢焦点、窗口被拖动、输入法候选框弹出来——上一秒的结论下一秒可能就失效了
|
|
357
|
+
3. ⛔ 绝对禁止连续多个写操作不看截图:比如 click 完直接 type 不 observe = 错误;click → observe 确认焦点对了 → type → observe 确认输入正确 → key(submit) → observe 确认发送成功,才是正确流程
|
|
358
|
+
4. ⛔ 一次只做一个写操作,做完必须 observe 看结果,再决定下一步做什么
|
|
359
|
+
5. 动作执行完后,系统会自动返回新截图;下一个动作必须基于那张最新截图来判断,而不是继续盲操作
|
|
360
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
361
|
+
|
|
362
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
363
|
+
【 核心铁律 #1:看截图是为了核对目标进度,不是为了验证动作执行 】
|
|
364
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
365
|
+
|
|
366
|
+
每次拿到截图后,不要只看"我刚才的动作有没有执行成功",要问自己:
|
|
367
|
+
- 当前界面状态是什么?标题/聊天对象/页面是什么?
|
|
368
|
+
- 最终目标是什么?当前状态是否已经达成了?(比如右侧对话框已经是目标联系人,搜索这步就完成了,直接进入下一步)
|
|
369
|
+
- 如果目标已达成,立即继续下一步,不要重复执行已完成的步骤。
|
|
370
|
+
|
|
371
|
+
⛔ 禁止轻易自我否定:操作后如果系统返回 ok=true 且 focus 信息显示前台正确,不要仅凭截图的模糊印象判定"点错了""误点了"。只有在focus信息或截图明确显示状态与预期矛盾时才重试。拿不准时先描述当前状态,不急于下"失败"结论。
|
|
372
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
373
|
+
|
|
374
|
+
定位方式:
|
|
375
|
+
- 主要靠 VLM 视觉模型直接识别截图中的文字与控件位置。
|
|
376
|
+
- ⛔⛔⛔ **强制要求:所有点击/双击/右键/拖拽/悬停动作,必须使用 bbox 框选(同时提供 x/y 左上角 + x2/y2 右下角)**,系统自动取中心点点击。
|
|
377
|
+
- 不需要精确框选!只需要大致框住目标按钮/条目/控件即可,即使框大一点、框歪一点也没关系,中心点通常都会落在目标上。
|
|
378
|
+
- **绝对不要只输出单点 x/y**——单点坐标要求像素级精确,很容易因为视觉估算误差点偏,bbox 容错率高一个数量级。
|
|
379
|
+
- 只有 drag 动作需要额外提供 toX/toY(拖拽终点,也是0-1000比例)。
|
|
380
|
+
- ⚠️ scroll 滚动也必须提供 x/y(框住要滚动的区域,系统会移动鼠标到那里再滚),否则滚轮会作用在当前鼠标位置——如果鼠标在别的应用窗口上,就会滚错地方。
|
|
381
|
+
|
|
382
|
+
**翻阅长列表 / 聊天记录 / 文档,按这个顺序:**
|
|
383
|
+
${TRAVERSAL_GUIDE_LINES.join('\n')}
|
|
384
|
+
- 坐标系统:0-1000 是**全屏比例坐标**(不是像素坐标!):
|
|
385
|
+
- x=0 是屏幕最左,x=1000 是屏幕最右
|
|
386
|
+
- y=0 是屏幕最上,y=1000 是屏幕最下
|
|
387
|
+
- **换算方法(必须按此换算,不要直接输出像素值!)**:
|
|
388
|
+
1. 在截图上目测目标控件的位置
|
|
389
|
+
2. 目测估计:目标左边距离左边缘占屏幕宽度的百分比 → ×1000 得到 x
|
|
390
|
+
3. 目标右边距离左边缘占屏幕宽度的百分比 → ×1000 得到 x2
|
|
391
|
+
4. 目标上边距离上边缘占屏幕高度的百分比 → ×1000 得到 y
|
|
392
|
+
5. 目标下边距离上边缘占屏幕高度的百分比 → ×1000 得到 y2
|
|
393
|
+
- 简单记法:整个屏幕的宽和高都被分成1000份,左0右1000、上0下1000。
|
|
394
|
+
- 辅助定位:macOS AX 无障碍树(elementId / textTarget)。**自绘界面**(Electron、Qt、Flutter、Unity,以及多数跨端客户端)常常拿不到,这时只能用坐标 —— 那不是故障,是常态。
|
|
395
|
+
- 不提供本地 OCR;所有文字识别由 VLM 直接完成。
|
|
396
|
+
|
|
397
|
+
动作类型(已实现):
|
|
398
|
+
${actionValues.join('、')}
|
|
399
|
+
(window 的子操作:focus / minimize / restore / close / move_resize / center)
|
|
400
|
+
|
|
401
|
+
截图与稳定性:
|
|
402
|
+
- ★ 截图自动缩放到约100万像素(对齐UI-TARS MAX_PIXELS),保持宽高比,发给模型的图尺寸固定。
|
|
403
|
+
- 永远返回全屏截图,不返回裁剪图(避免坐标错觉)。
|
|
404
|
+
- 坐标 0-1000 是全屏比例,不需要考虑Retina缩放、不需要考虑图片实际像素尺寸——只需要按比例估算。
|
|
405
|
+
- 动作后系统会自动画红色十字标记你上次点击的位置:如果十字正好在目标上,说明坐标对了;如果十字偏了,根据偏移方向和距离调整下一次的坐标。
|
|
406
|
+
- 改变界面的动作后会快速返回一张最新截图(~150ms),type/key后等待仅50ms。
|
|
407
|
+
- wait 后会自动返回一张最新截图。
|
|
408
|
+
|
|
409
|
+
输入框操作通用规范(所有应用适用,关键!):
|
|
410
|
+
|
|
411
|
+
【第一步:点击输入框后先 observe】
|
|
412
|
+
点击输入框获得焦点后,**必须先 observe 一次**,看清输入框里当前有没有内容、有什么内容。
|
|
413
|
+
|
|
414
|
+
【第二步:判断是「替换」还是「追加/补全」】
|
|
415
|
+
根据当前任务目标语义判断,**判断原则是:这个输入框要填的是一个完整值,还是在已有内容基础上添加?**
|
|
416
|
+
|
|
417
|
+
- **✅ 必须清空后输入(替换模式)—— 90% 的表单输入场景都是这个模式**:
|
|
418
|
+
- 【金融/数字类:高危!】汇款金额、转账金额、支付密码、手机号、身份证号、数量、价格:输入框里有任何残留数字都会造成严重错误(比如原有100,再输100变成100100,多汇了钱)
|
|
419
|
+
- 【全新内容类】发送新消息、搜索新关键词、填写新表单字段(姓名、地址、邮箱、标题)、新建文件名
|
|
420
|
+
- 【内容无关类】输入框里已有内容与本次要输入的内容无关(比如上次测试残留、旧草稿、别人留下的内容)
|
|
421
|
+
- → 标准操作:hotkey:"command+a" 全选 → key:"delete" 删除 → observe 确认输入框为空 → 再输入新内容
|
|
422
|
+
|
|
423
|
+
- **❌ 不清空,直接追加/编辑(追加模式)—— 少数场景**:
|
|
424
|
+
- 【内容创作类】续写小说、续写文章、在现有文档末尾添加内容、补充留言、补全句子
|
|
425
|
+
- 【编辑修改类】修改已有文字(光标定位到要改的位置,只改那部分)、在已有文本中间插入内容
|
|
426
|
+
- 【连续操作类】在同一个输入框里连续追加内容(如逐条添加列表项)
|
|
427
|
+
- → 操作:直接在当前光标位置输入即可,不要全选删除
|
|
428
|
+
|
|
429
|
+
**判断口诀:拿不准的时候,清空了再输——删了重输最多麻烦一次,没清空导致发错/汇错/填错,代价大得多。**
|
|
430
|
+
|
|
431
|
+
【第三步:输入后核对】
|
|
432
|
+
输入文字完成后,**必须 observe 确认**:
|
|
433
|
+
- 屏幕上显示的文字与目标内容完全一致
|
|
434
|
+
- 输入法候选框已消失(没有未上屏的拼音/候选词)
|
|
435
|
+
- 如果内容不对,不要继续提交,重新定位、修正
|
|
436
|
+
|
|
437
|
+
【第四步:提交--果断执行】
|
|
438
|
+
按发送/确认键之前,看一眼最新截图和系统返回的焦点信息:
|
|
439
|
+
- 系统返回的「前台」和「焦点」信息是准确的(比截图可靠,截图可能慢半拍)
|
|
440
|
+
- 只要焦点信息显示前台正确、焦点在输入框、输入框文字正确,就果断按发送,不要反复确认
|
|
441
|
+
- 如果截图和焦点信息有矛盾(比如截图看着像别的应用,但focus信息说前台是目标应用),以focus信息为准--截图可能拍到了窗口切换的中间帧
|
|
442
|
+
|
|
443
|
+
【第五步:提交后确认结果】
|
|
444
|
+
提交(发送、确认)后,**必须 observe 确认动作已生效**:
|
|
445
|
+
- 消息出现在聊天记录里(右侧自己发出的气泡)
|
|
446
|
+
- 表单已提交/关闭/跳转
|
|
447
|
+
- 没有报错提示
|
|
448
|
+
- 无法确认成功时,**只报告状态,禁止重复提交**——重复提交可能导致重复发送消息、重复下单等严重问题。
|
|
449
|
+
|
|
450
|
+
安全性:
|
|
451
|
+
- 登录、密码、OTP、CAPTCHA 等敏感场景会触发人工边界检测,交由宿主接管。
|
|
452
|
+
- 不可逆操作强制声明规则:**任何会造成外部可见、不可撤销后果的动作,必须显式声明 intent:"submit"**,否则引擎无法识别为不可逆操作,会按普通操作处理(但你绝不能依赖这个漏洞绕过检查)。典型需要声明 intent:"submit" 的场景:
|
|
453
|
+
- 按回车/ Cmd+Enter 发送消息、发送表单、提交订单、确认付款
|
|
454
|
+
- 点击"发送""确认""删除""支付""提交"按钮
|
|
455
|
+
- 关闭窗口(window action with close 操作,引擎自动识别,不需要你声明)
|
|
456
|
+
- 不可逆操作采用风险分级机制:
|
|
457
|
+
- critical(高危红线:转账付款、永久删除、删好友、格式化、清空数据等):必须声明 intent:"submit" + 对应 actionClass,需要人工批准;
|
|
458
|
+
- normal(一般不可逆:发消息、发文件、建群、加好友等):声明 intent:"submit" + 正确的 actionClass 即可自动放行,无需人工确认弹窗;
|
|
459
|
+
- 当前应用说明书的 irreversible 清单会随观测结果注入到每次回执里,**只能从那份清单里原样抄 actionClass**。
|
|
460
|
+
清单里没有的、或者当前应用压根没有说明书 —— 就**不要填 actionClass**,只给 intent:"submit",走通用审批即可;
|
|
461
|
+
编一个名字出来不会让它更容易通过,只会退回通用审批还多错一次。
|
|
462
|
+
- 形状:action:"key", key:"return", intent:"submit", actionClass:"<从回执清单里抄的那一条>"
|
|
463
|
+
- permission_denied/permission_required 交由宿主引导授权;stale_state 重新 observe;verification_failed 说明动作未达预期,应根据返回的新截图重新定位后再试,不得重复同一坐标,不得声称成功。
|
|
464
|
+
- 不得用 Bash 或其它工具绕过 Helper。`,
|
|
465
|
+
inputSchema,
|
|
466
|
+
parameters: {
|
|
467
|
+
type: 'object',
|
|
468
|
+
properties: {
|
|
469
|
+
action: { type: 'string', enum: actionValues },
|
|
470
|
+
appId: {
|
|
471
|
+
type: 'string',
|
|
472
|
+
description:
|
|
473
|
+
'用于 activate_app:应用的 bundleId。形如 com.apple.mail(邮件)、com.apple.Safari(浏览器)、'
|
|
474
|
+
+ 'com.tencent.xinWeChat(微信)、com.microsoft.Excel(Excel)—— 任何 macOS 应用都适用,不限于某几个。' +
|
|
475
|
+
'activate_app 用 appId 激活应用,不需要先 observe,也不需要 textTarget。' +
|
|
476
|
+
'如果不知道 bundleId,可以先用 observe 看截图中的 Dock 图标,或用应用名猜测常见格式。',
|
|
477
|
+
},
|
|
478
|
+
// ⚠️ 这五个字段此前**一个描述都没有**,模型只能靠撞 invalid_request 才知道
|
|
479
|
+
// 哪个必填、值从哪来。实测代价:一次 `window` focus 直接报
|
|
480
|
+
// 「window 必须提供 windowId」,白费一整轮往返。
|
|
481
|
+
windowId: {
|
|
482
|
+
type: 'string',
|
|
483
|
+
description:
|
|
484
|
+
'⛔ window 动作**必填**。取值必须原样来自上一次 observe 返回的窗口列表(形如 '
|
|
485
|
+
+ '"com.apple.mail:0",即 "<bundleId>:<序号>"),不要自己拼。'
|
|
486
|
+
+ '拿不到窗口列表说明该应用没有可操作窗口,应先 activate_app 再 observe。',
|
|
487
|
+
},
|
|
488
|
+
displayId: {
|
|
489
|
+
type: 'string',
|
|
490
|
+
description:
|
|
491
|
+
'显示器 id,取自 observe 返回的 displays 列表。单显示器时可省略,系统用主屏。',
|
|
492
|
+
},
|
|
493
|
+
elementId: {
|
|
494
|
+
type: 'string',
|
|
495
|
+
description:
|
|
496
|
+
'无障碍元素 id。可用观测返回的短引用 @1/@2(系统自动换成真实 id)。'
|
|
497
|
+
+ '使用时必须同时提供 expectedRevision。自绘界面(Electron / Qt / 跨端客户端)无障碍树为空,这条路走不通,改用坐标。',
|
|
498
|
+
},
|
|
499
|
+
expectedRevision: {
|
|
500
|
+
type: 'string',
|
|
501
|
+
description:
|
|
502
|
+
'配合 elementId 使用:上一次 observe 返回的 revision,用于确认元素没有过期。'
|
|
503
|
+
+ '与观测不符会返回 stale_state,此时重新 observe 再试。',
|
|
504
|
+
},
|
|
505
|
+
x: {
|
|
506
|
+
type: 'number',
|
|
507
|
+
minimum: 0,
|
|
508
|
+
maximum: 1000,
|
|
509
|
+
description:
|
|
510
|
+
'⛔ 强制bbox:左上角X坐标(0-1000比例坐标,左=0,右=1000)。必须同时提供 x2 和 y2,系统自动取中心点点击。',
|
|
511
|
+
},
|
|
512
|
+
y: {
|
|
513
|
+
type: 'number',
|
|
514
|
+
minimum: 0,
|
|
515
|
+
maximum: 1000,
|
|
516
|
+
description:
|
|
517
|
+
'⛔ 强制bbox:左上角Y坐标(0-1000比例坐标,上=0,下=1000)。必须同时提供 x2 和 y2,系统自动取中心点点击。',
|
|
518
|
+
},
|
|
519
|
+
x2: {
|
|
520
|
+
type: 'number',
|
|
521
|
+
minimum: 0,
|
|
522
|
+
maximum: 1000,
|
|
523
|
+
description:
|
|
524
|
+
'⛔ 强制bbox:右下角X坐标(0-1000比例坐标)。大致框住目标按钮/条目即可,不需要精确,系统自动计算中心点点击,容错率极高。',
|
|
525
|
+
},
|
|
526
|
+
y2: {
|
|
527
|
+
type: 'number',
|
|
528
|
+
minimum: 0,
|
|
529
|
+
maximum: 1000,
|
|
530
|
+
description:
|
|
531
|
+
'⛔ 强制bbox:右下角Y坐标(0-1000比例坐标)。大致框住目标即可,不需要精确,系统自动点中心点。',
|
|
532
|
+
},
|
|
533
|
+
toX: {
|
|
534
|
+
type: 'number',
|
|
535
|
+
minimum: 0,
|
|
536
|
+
maximum: 1000,
|
|
537
|
+
description: '仅用于 drag 动作:拖拽目标X坐标。',
|
|
538
|
+
},
|
|
539
|
+
toY: {
|
|
540
|
+
type: 'number',
|
|
541
|
+
minimum: 0,
|
|
542
|
+
maximum: 1000,
|
|
543
|
+
description: '仅用于 drag 动作:拖拽目标Y坐标。',
|
|
544
|
+
},
|
|
545
|
+
dx: {
|
|
546
|
+
type: 'number',
|
|
547
|
+
description:
|
|
548
|
+
'仅用于 scroll:横向滚动像素(正=向右)。一般用 direction 即可,需要精确距离时才用它。',
|
|
549
|
+
},
|
|
550
|
+
dy: {
|
|
551
|
+
type: 'number',
|
|
552
|
+
description:
|
|
553
|
+
'仅用于 scroll:纵向滚动像素(正=向下,负=向上)。一般用 direction 即可;'
|
|
554
|
+
+ 'direction 与 dx/dy 同时给出时以 direction 为准。',
|
|
555
|
+
},
|
|
556
|
+
text: {
|
|
557
|
+
type: 'string',
|
|
558
|
+
description:
|
|
559
|
+
'用于 type / clipboard_write:要输入的文本。'
|
|
560
|
+
+ '⛔ **原样给出要输入的文字,不要自己转写**(不要把中文名转成拼音、不要翻译成英文)。'
|
|
561
|
+
+ '中文/emoji 由工具负责送达:系统装着输入法时合成按键到不了控件,'
|
|
562
|
+
+ '工具会**自动整串走剪贴板粘贴**(并还原用户原剪贴板),回执里会说明走了哪条路 —— '
|
|
563
|
+
+ '你不需要为此改写文字 —— 自己转写只会把一个工具已经处理好的问题,'
|
|
564
|
+
+ '变成一个搜不到、点不中的问题。'
|
|
565
|
+
+ '输入前务必按「输入框操作通用规范」确认是否需要先清空残留内容。',
|
|
566
|
+
},
|
|
567
|
+
key: {
|
|
568
|
+
type: 'string',
|
|
569
|
+
description:
|
|
570
|
+
'action="key":单个按键(如 "return", "tab", "escape", "delete", "up", "down", "left", "right")。' +
|
|
571
|
+
'action="hotkey":组合键,格式为"修饰键+按键"(如 "command+c", "command+v", "command+a", "command+shift+4")。' +
|
|
572
|
+
'组合键必须用 hotkey,不要用 key。',
|
|
573
|
+
},
|
|
574
|
+
direction: {
|
|
575
|
+
type: 'string',
|
|
576
|
+
enum: ['up', 'down', 'left', 'right'],
|
|
577
|
+
description:
|
|
578
|
+
'仅用于 scroll 动作:滚动方向。每次滚动约500像素(约5行)。' +
|
|
579
|
+
'⚠️ scroll 必须同时提供 x/y(0-1000相对坐标,指向要滚动的区域中心),' +
|
|
580
|
+
'否则滚轮会作用在当前鼠标位置(可能在错误的窗口上)。',
|
|
581
|
+
},
|
|
582
|
+
repeat: {
|
|
583
|
+
type: 'integer',
|
|
584
|
+
minimum: 1,
|
|
585
|
+
maximum: 8,
|
|
586
|
+
description:
|
|
587
|
+
'仅用于 key / scroll:把同一动作连做 N 次,**一轮内完成**,每次后各截一张图一起返回。' +
|
|
588
|
+
'遍历长列表/聊天记录/文档时请用它,不要一页一轮——一页一轮的开销是工具执行的 15 倍。' +
|
|
589
|
+
'翻到边界(目标区域不再变化)会自动提前停止并如实告诉你停在第几次。',
|
|
590
|
+
},
|
|
591
|
+
repeatFrames: {
|
|
592
|
+
type: 'string',
|
|
593
|
+
enum: ['all', 'last'],
|
|
594
|
+
description:
|
|
595
|
+
'配合 repeat 用,决定连做 N 次之后回给你几张图。缺省 all。\n'
|
|
596
|
+
+ '- all(默认):每一次之后各截一张,全部返回。**遍历读内容**时要用它 —— '
|
|
597
|
+
+ '中间翻过去的那些页正是你要读的,只给终点等于全丢。\n'
|
|
598
|
+
+ '- last:只返回最后一张。**快速定位/翻到某一页**时用它 —— 中间过程没有阅读价值,'
|
|
599
|
+
+ '而 repeat:8 会一次回 8 张全屏图(约 1 万 token),纯属浪费还挤占上下文。',
|
|
600
|
+
},
|
|
601
|
+
role: {
|
|
602
|
+
type: 'string',
|
|
603
|
+
description:
|
|
604
|
+
'wait_for_element 使用的无障碍角色,例如 "AXButton"、"AXTextField"。可配合 text 同时要求文字片段。',
|
|
605
|
+
},
|
|
606
|
+
textTarget: {
|
|
607
|
+
type: 'string',
|
|
608
|
+
description:
|
|
609
|
+
'通过可见文字点击控件,无需坐标。根据上一次观测的无障碍树解析为 elementId,自动保持状态新鲜度检查。' +
|
|
610
|
+
'当多个控件匹配时会拒绝而不是猜测。自绘应用(如部分国产软件)无无障碍树时不可用。' +
|
|
611
|
+
'与 elementId 和 x/y 互斥,优先使用。',
|
|
612
|
+
},
|
|
613
|
+
pixelX: {
|
|
614
|
+
type: 'number',
|
|
615
|
+
minimum: 0,
|
|
616
|
+
description:
|
|
617
|
+
'【像素坐标模式】以截图像素为单位的X坐标。比 0-1000 比例坐标更直观,省去心算换算。' +
|
|
618
|
+
'需要与 pixelY 同时提供。系统会根据截图尺寸自动换算为屏幕坐标。' +
|
|
619
|
+
'不能与 x/y(比例坐标)同时使用。',
|
|
620
|
+
},
|
|
621
|
+
pixelY: {
|
|
622
|
+
type: 'number',
|
|
623
|
+
minimum: 0,
|
|
624
|
+
description: '【像素坐标模式】以截图像素为单位的Y坐标。见 pixelX。',
|
|
625
|
+
},
|
|
626
|
+
pixelX2: {
|
|
627
|
+
type: 'number',
|
|
628
|
+
minimum: 0,
|
|
629
|
+
description:
|
|
630
|
+
'【像素坐标模式】bbox 右下角X坐标(像素)。与 pixelY2 同时提供时系统自动取中心点。',
|
|
631
|
+
},
|
|
632
|
+
pixelY2: {
|
|
633
|
+
type: 'number',
|
|
634
|
+
minimum: 0,
|
|
635
|
+
description: '【像素坐标模式】bbox 右下角Y坐标(像素)。见 pixelX2。',
|
|
636
|
+
},
|
|
637
|
+
filePaths: {
|
|
638
|
+
type: 'array',
|
|
639
|
+
items: { type: 'string' },
|
|
640
|
+
maxItems: 20,
|
|
641
|
+
description:
|
|
642
|
+
'仅用于 file_drop:要投放的文件绝对路径。必须落在本次任务已获批的目录范围内,'
|
|
643
|
+
+ '超出会被拒绝。投放后必须 observe 确认文件真的到了目标位置。',
|
|
644
|
+
},
|
|
645
|
+
magnify: {
|
|
646
|
+
type: 'object',
|
|
647
|
+
description:
|
|
648
|
+
'🔍 放大镜(0-1000 相对坐标区域),**只能配 observe 用**。在全屏截图之外**额外**附一张'
|
|
649
|
+
+ '该区域从原始高分辨率帧裁出、放大后的图,专门用来读小字/小数字。'
|
|
650
|
+
+ '为什么需要它:5K 屏原图 5120×2880 会被压到约 1371×771 才发给你,'
|
|
651
|
+
+ '原本 20px 高的小字只剩约 5px —— 那是一团马赛克,靠猜必然出错(实测页码就栽在这里)。'
|
|
652
|
+
+ '⛔ 放大镜只许**读**,不许**定位**:所有点击坐标一律以第 1 张全屏图为准。'
|
|
653
|
+
+ '例:读页面底部中间的页码 → magnify:{x:400,y:800,width:200,height:60}',
|
|
654
|
+
properties: {
|
|
655
|
+
x: { type: 'number' },
|
|
656
|
+
y: { type: 'number' },
|
|
657
|
+
width: { type: 'number', exclusiveMinimum: 0 },
|
|
658
|
+
height: { type: 'number', exclusiveMinimum: 0 },
|
|
659
|
+
},
|
|
660
|
+
required: ['x', 'y', 'width', 'height'],
|
|
661
|
+
},
|
|
662
|
+
magnifyAnchor: {
|
|
663
|
+
type: 'string',
|
|
664
|
+
enum: ['screen', 'window'],
|
|
665
|
+
description:
|
|
666
|
+
'🔍 magnify 的坐标参照系,默认 screen。\n'
|
|
667
|
+
+ '- screen(默认):magnify 的 x/y/width/height 是**全屏**比例坐标\n'
|
|
668
|
+
+ '- window:是**相对前台窗口**的比例坐标,引擎自己按当前窗口位置换算。\n'
|
|
669
|
+
+ '★ 应用窗口常常不是全屏的,"窗口底部那一条"用 screen 坐标每次都得先抠窗口范围再手算,'
|
|
670
|
+
+ '算错就框空。窗口在哪是引擎手里的事实 —— 用 window 让它替你算。\n'
|
|
671
|
+
+ '例:读页脚页码(窗口底部通宽一条)→ magnify:{x:0,y:945,width:1000,height:55}, magnifyAnchor:"window"',
|
|
672
|
+
},
|
|
673
|
+
rect: {
|
|
674
|
+
type: 'object',
|
|
675
|
+
description:
|
|
676
|
+
'0-1000相对坐标区域。★ observe动作永远返回全屏截图(不裁剪,避免坐标错觉),rect仅用于:' +
|
|
677
|
+
'grab_region_to_clipboard(区域截图到剪贴板)和 window move_resize(窗口移动/调整大小)。'
|
|
678
|
+
+ '想放大看清小字请用 magnify,不是这个。',
|
|
679
|
+
properties: {
|
|
680
|
+
x: { type: 'number' },
|
|
681
|
+
y: { type: 'number' },
|
|
682
|
+
width: { type: 'number', exclusiveMinimum: 0 },
|
|
683
|
+
height: { type: 'number', exclusiveMinimum: 0 },
|
|
684
|
+
},
|
|
685
|
+
required: ['x', 'y', 'width', 'height'],
|
|
686
|
+
},
|
|
687
|
+
timeoutMs: {
|
|
688
|
+
type: 'integer',
|
|
689
|
+
minimum: 100,
|
|
690
|
+
maximum: 120000,
|
|
691
|
+
description:
|
|
692
|
+
'wait_for_* 动作使用:轮询超时时间(默认5000ms)。轮询仅获取元数据(不截图),成本很低。',
|
|
693
|
+
},
|
|
694
|
+
quietMs: {
|
|
695
|
+
type: 'integer',
|
|
696
|
+
minimum: 100,
|
|
697
|
+
maximum: 5000,
|
|
698
|
+
description:
|
|
699
|
+
'settle 动作使用:区域需要静止多长时间才算稳定(点击后200ms,页面跳转后800ms)。',
|
|
700
|
+
},
|
|
701
|
+
waitMs: {
|
|
702
|
+
type: 'integer',
|
|
703
|
+
minimum: 10,
|
|
704
|
+
maximum: 30000,
|
|
705
|
+
description:
|
|
706
|
+
'wait 动作使用:等待多少毫秒(默认2000ms)。等待结束后自动返回一张最新截图查看结果。' +
|
|
707
|
+
'在动作后等待UI响应时使用(如activate_app、click、type之后)。',
|
|
708
|
+
},
|
|
709
|
+
intent: {
|
|
710
|
+
type: 'string',
|
|
711
|
+
enum: ['normal', 'submit'],
|
|
712
|
+
description:
|
|
713
|
+
'当此步骤会造成外部可见、不可逆后果时设为"submit"(如发送消息、下单、永久删除)。' +
|
|
714
|
+
'需要单独授权,无人批准时会被拒绝。',
|
|
715
|
+
},
|
|
716
|
+
actionClass: {
|
|
717
|
+
type: 'string',
|
|
718
|
+
description:
|
|
719
|
+
'此submit对应的不可逆操作类别,例如 "intent:send-message"。仅在 intent:"submit" 时有效。' +
|
|
720
|
+
'值必须从匹配的应用说明书的 irreversible 列表中原样复制(引擎会自动注入到观测回执中),不要编造。' +
|
|
721
|
+
'不在列表中的值会被忽略(回退到通用 intent:submit 审批)。此处只接受 "intent:*" 类别。',
|
|
722
|
+
},
|
|
723
|
+
frames: {
|
|
724
|
+
type: 'integer',
|
|
725
|
+
minimum: 1,
|
|
726
|
+
maximum: 6,
|
|
727
|
+
description: `请求多少帧1Hz画面(默认${DEFAULT_OBSERVE_FRAMES}帧)。第1帧是完整关键帧;后续帧会变暗未变化区域。除非需要看动作过程,否则请求1帧即可。`,
|
|
728
|
+
},
|
|
729
|
+
force: {
|
|
730
|
+
type: 'boolean',
|
|
731
|
+
description:
|
|
732
|
+
'window center 用:true = 无条件把窗口摆到可用区正中;'
|
|
733
|
+
+ '缺省(false)只在窗口有一部分在屏外、或被 Dock/菜单栏压住时才动它 —— '
|
|
734
|
+
+ '已经摆得好好的就不折腾,给「同屏多应用」留空间。',
|
|
735
|
+
},
|
|
736
|
+
windowOperation: {
|
|
737
|
+
type: 'string',
|
|
738
|
+
enum: ['focus', 'minimize', 'restore', 'close', 'move_resize', 'center'],
|
|
739
|
+
description:
|
|
740
|
+
'window 动作使用。'
|
|
741
|
+
+ '"center"=把窗口摆进**可用区**(扣掉顶部菜单栏与底部 Dock)的正中,装不下就先缩小;'
|
|
742
|
+
+ '它不是最大化也不是全屏(全屏会独占一个 Space,多应用来回切就废了)。'
|
|
743
|
+
+ '窗口有一部分在屏外、或底部被 Dock 压住时用它 —— 被压住的往往正是输入框或提交按钮,'
|
|
744
|
+
+ '坐标点得到、实际点不着。`activate_app` 已经会在必要时自动做一次,'
|
|
745
|
+
+ '只有想无条件摆正中时才需要显式调。'
|
|
746
|
+
+ '"close"(关闭窗口)被视为不可逆操作(未保存工作会丢失),需要与 intent:"submit" 相同的授权。'
|
|
747
|
+
+ '"move_resize"需要 rect 参数。',
|
|
748
|
+
},
|
|
749
|
+
dropMethod: {
|
|
750
|
+
type: 'string',
|
|
751
|
+
enum: ['paste', 'open_panel'],
|
|
752
|
+
description:
|
|
753
|
+
'file_drop 动作使用。"paste"(默认):将文件放到剪贴板然后发送Cmd+V粘贴,兼容性最广。' +
|
|
754
|
+
'"open_panel":通过Cmd+Shift+G操作**已打开**的文件对话框(不能帮你打开对话框)。' +
|
|
755
|
+
'两者都不是真正的拖拽(macOS要求源应用发起拖拽,只接受真拖拽的应用无法使用)。操作后务必验证文件是否已到达。',
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
required: ['action'],
|
|
759
|
+
},
|
|
760
|
+
isReadOnly: false,
|
|
761
|
+
isConcurrencySafe: false,
|
|
762
|
+
interruptBehavior: () => 'cancel',
|
|
763
|
+
gateRequest(input, context) {
|
|
764
|
+
const run = context.computerRun;
|
|
765
|
+
const identity = run
|
|
766
|
+
? {
|
|
767
|
+
runContext: {
|
|
768
|
+
ownerId: run.ownerId,
|
|
769
|
+
conversationKey: run.conversationKey,
|
|
770
|
+
runId: run.runId,
|
|
771
|
+
surface: run.surface,
|
|
772
|
+
},
|
|
773
|
+
}
|
|
774
|
+
: {};
|
|
775
|
+
// 参数没通过校验(input === undefined)时**仍然给出身份** —— 见 Tool.gateRequest
|
|
776
|
+
// 的注释:身份来自宿主,与参数无关。只省掉依赖参数的 action / isReadOnly,
|
|
777
|
+
// 让真正的 zod 错误由工具层报给模型(那里有完整的 issue 文案,模型能自己改)。
|
|
778
|
+
if (!input) return identity;
|
|
779
|
+
return {
|
|
780
|
+
isReadOnly: !isMutatingComputerUseAction(input.action),
|
|
781
|
+
...identity,
|
|
782
|
+
action: actionDescription(input),
|
|
783
|
+
};
|
|
784
|
+
},
|
|
785
|
+
async call(): Promise<ToolResult> {
|
|
786
|
+
return {
|
|
787
|
+
ok: false,
|
|
788
|
+
error: 'ComputerUse 必须由提供运行身份和权限的宿主调用',
|
|
789
|
+
};
|
|
790
|
+
},
|
|
791
|
+
/**
|
|
792
|
+
* 每次调用都记一条遥测,并在此之前做连续失败熔断。
|
|
793
|
+
*
|
|
794
|
+
* 包一层而不是在十几个 return 点各写一遍:漏一个点,失败率统计就是错的,
|
|
795
|
+
* 而熔断恰好依赖那个统计。
|
|
796
|
+
*/
|
|
797
|
+
async callWithContext(input, signal, context): Promise<ToolResult> {
|
|
798
|
+
// 熔断判定放在**记录之前且提前返回**,而不是放进 runComputerUse。
|
|
799
|
+
//
|
|
800
|
+
// 这不是代码组织偏好,是正确性要求:熔断的判据来自遥测里的失败计数,
|
|
801
|
+
// 如果它自己的拒绝也被记成一次失败,计数就会每被拒一次涨一格 ——
|
|
802
|
+
// 熔断变成永久闭锁,谁都解不开。放在这里,结构上就不可能记到自己。
|
|
803
|
+
// (单测锁定:拒绝前后遥测行数必须相等。)
|
|
804
|
+
const breaker = refuseWhenStuck(input, context);
|
|
805
|
+
if (breaker) {
|
|
806
|
+
if (context.computerRun) {
|
|
807
|
+
await recordComputerUseDiagnosticStep({
|
|
808
|
+
context: context.computerRun,
|
|
809
|
+
request: input,
|
|
810
|
+
result: breaker,
|
|
811
|
+
elapsedMs: 0,
|
|
812
|
+
diagnostics: { circuitBreaker: true },
|
|
813
|
+
}).catch(() => {});
|
|
814
|
+
}
|
|
815
|
+
return breaker;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const startedAt = Date.now();
|
|
819
|
+
const extra: Record<string, unknown> = {};
|
|
820
|
+
const result = await runComputerUse(input, signal, context, extra);
|
|
821
|
+
// 熔断计数(内存、按运行隔离)与遥测(磁盘、纯观测)是两件事,分开记。
|
|
822
|
+
// 把安全策略押在一个全机共享的日志文件上,是这条修复要纠正的错误。
|
|
823
|
+
if (context.computerRun) {
|
|
824
|
+
computerUseService.recordActionOutcome(
|
|
825
|
+
context.computerRun,
|
|
826
|
+
input,
|
|
827
|
+
result.ok,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
void recordComputerUseCall({
|
|
831
|
+
cmd: input.action,
|
|
832
|
+
ms: Date.now() - startedAt,
|
|
833
|
+
ok: result.ok,
|
|
834
|
+
images: result.visionImages?.length ?? 0,
|
|
835
|
+
...extra,
|
|
836
|
+
});
|
|
837
|
+
if (context.computerRun) {
|
|
838
|
+
const observation = computerUseService.lastObservation(
|
|
839
|
+
context.computerRun,
|
|
840
|
+
);
|
|
841
|
+
await recordComputerUseDiagnosticStep({
|
|
842
|
+
context: context.computerRun,
|
|
843
|
+
request: input,
|
|
844
|
+
result,
|
|
845
|
+
elapsedMs: Date.now() - startedAt,
|
|
846
|
+
diagnostics: {
|
|
847
|
+
...extra,
|
|
848
|
+
...(observation?.activeApp?.id
|
|
849
|
+
? { activeAppId: observation.activeApp.id }
|
|
850
|
+
: {}),
|
|
851
|
+
...(observation?.revision ? { revision: observation.revision } : {}),
|
|
852
|
+
...(observation?.epoch !== undefined
|
|
853
|
+
? { epoch: observation.epoch }
|
|
854
|
+
: {}),
|
|
855
|
+
},
|
|
856
|
+
}).catch(() => {});
|
|
857
|
+
}
|
|
858
|
+
return result;
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* 人工接管的统一处置。AX 判据与像素兜底共用这一份 —— 两条判据只是**怎么发现**
|
|
864
|
+
* 不同,「发现之后怎么办」必须完全一致,否则弱判据那条路迟早会漏掉某一步。
|
|
865
|
+
*
|
|
866
|
+
* 返回 `refusal` 表示要终止;返回 `state` 表示人工处理后拿到了新观测。
|
|
867
|
+
*/
|
|
868
|
+
async function handleHumanBoundary(
|
|
869
|
+
boundary: ComputerHumanBoundary | null,
|
|
870
|
+
state: ComputerState,
|
|
871
|
+
context: import('../../types.js').ToolCallContext,
|
|
872
|
+
signal?: AbortSignal,
|
|
873
|
+
): Promise<{ refusal?: ToolResult; state?: ComputerState }> {
|
|
874
|
+
if (boundary && !context.computerIntervention) {
|
|
875
|
+
const weak =
|
|
876
|
+
boundary.confidence === 'pixel-only'
|
|
877
|
+
? '(这条判断来自像素变化,无法确定弹出的到底是什么)'
|
|
878
|
+
: '';
|
|
879
|
+
return {
|
|
880
|
+
refusal: {
|
|
881
|
+
ok: false,
|
|
882
|
+
error: `human_intervention_required: ${boundary.summary}${weak}。当前宿主没有安全的人工接管通道,任务已停止。`,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
if (!context.computerIntervention) return {};
|
|
887
|
+
const intervention = await context.computerIntervention.inspect(
|
|
888
|
+
state,
|
|
889
|
+
signal,
|
|
890
|
+
);
|
|
891
|
+
if (intervention.resolution === 'cancelled') {
|
|
892
|
+
return {
|
|
893
|
+
refusal: {
|
|
894
|
+
ok: false,
|
|
895
|
+
error:
|
|
896
|
+
'human_intervention_required: 人工处理已取消,Computer Use 任务已停止',
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
if (intervention.resolution === 'resumed' && intervention.state) {
|
|
901
|
+
return { state: intervention.state };
|
|
902
|
+
}
|
|
903
|
+
return {};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
interface WindowBoundsRatio {
|
|
907
|
+
left: number;
|
|
908
|
+
top: number;
|
|
909
|
+
right: number;
|
|
910
|
+
bottom: number;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* 将窗口逻辑像素 bounds 转换为 0-1000 屏幕比例坐标(相对指定显示器)。
|
|
915
|
+
*
|
|
916
|
+
* 必须减去 display.logicalBounds 的原点:
|
|
917
|
+
* - 单屏时 bounds.x 通常为 0,减不减看起来一样;
|
|
918
|
+
* - 多屏时副屏可能在主屏左边(x 为负)或右边(x 大于主屏宽度),
|
|
919
|
+
* 不减去 display origin 会得到完全错误的比例值。
|
|
920
|
+
*/
|
|
921
|
+
/**
|
|
922
|
+
* 挑出某个应用的「主窗口」。
|
|
923
|
+
*
|
|
924
|
+
* ⚠️ 两处调用点原来各写各的 `find(...)`,都等价于「取列表里第一个」。这在单窗口
|
|
925
|
+
* 应用上看不出问题,多窗口应用上必错:微信的窗口列表首项是一块 678pt 宽的辅助
|
|
926
|
+
* 面板(图片查看器 / 侧栏),2560pt 的主窗口排在后面。后果是模型每次点对了都被
|
|
927
|
+
* 警告「点到窗口外面去了」——它不是提示错了,是把对的坐标算错了。
|
|
928
|
+
*
|
|
929
|
+
* 顺序:先认 `focused`(守护进程的权威答案),再退到面积最大的那个(主窗口几乎
|
|
930
|
+
* 总是最大的),最后才是第一个。
|
|
931
|
+
*/
|
|
932
|
+
export function pickFrontWindow<
|
|
933
|
+
W extends {
|
|
934
|
+
appId: string;
|
|
935
|
+
minimized?: boolean;
|
|
936
|
+
focused?: boolean;
|
|
937
|
+
bounds: { width: number; height: number };
|
|
938
|
+
},
|
|
939
|
+
>(windows: W[] | undefined, appId: string | undefined): W | undefined {
|
|
940
|
+
if (!windows?.length || !appId) return undefined;
|
|
941
|
+
const candidates = windows.filter(
|
|
942
|
+
(w) =>
|
|
943
|
+
w.appId === appId
|
|
944
|
+
&& !w.minimized
|
|
945
|
+
&& w.bounds.width > 0
|
|
946
|
+
&& w.bounds.height > 0,
|
|
947
|
+
);
|
|
948
|
+
if (!candidates.length) return undefined;
|
|
949
|
+
return (
|
|
950
|
+
candidates.find((w) => w.focused === true)
|
|
951
|
+
?? candidates.reduce((biggest, w) =>
|
|
952
|
+
w.bounds.width * w.bounds.height
|
|
953
|
+
> biggest.bounds.width * biggest.bounds.height
|
|
954
|
+
? w
|
|
955
|
+
: biggest,
|
|
956
|
+
)
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function windowBoundsToRatio(
|
|
961
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
962
|
+
display: {
|
|
963
|
+
logicalBounds: { x: number; y: number; width: number; height: number };
|
|
964
|
+
},
|
|
965
|
+
): WindowBoundsRatio {
|
|
966
|
+
const screenW = display.logicalBounds.width;
|
|
967
|
+
const screenH = display.logicalBounds.height;
|
|
968
|
+
const originX = bounds.x - display.logicalBounds.x;
|
|
969
|
+
const originY = bounds.y - display.logicalBounds.y;
|
|
970
|
+
return {
|
|
971
|
+
left: (originX / screenW) * 1000,
|
|
972
|
+
top: (originY / screenH) * 1000,
|
|
973
|
+
right: ((originX + bounds.width) / screenW) * 1000,
|
|
974
|
+
bottom: ((originY + bounds.height) / screenH) * 1000,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 把 `magnify` 的框换算成**全屏**比例坐标。
|
|
980
|
+
*
|
|
981
|
+
* `magnifyAnchor:'window'` 时,传进来的 x/y/width/height 是**相对前台窗口**的
|
|
982
|
+
* 0–1000 比例,这里用当前观测里的窗口位置换算成全屏比例。
|
|
983
|
+
*
|
|
984
|
+
* ⚠️ 为什么值得让引擎来算:应用窗口常常不是全屏的。「窗口底部那一条」用全屏坐标表达,
|
|
985
|
+
* 模型每次都得先从回执里抠出窗口范围、再手算 `L + W×0.25` 这类式子 —— 算错就框空,
|
|
986
|
+
* 而框空的表现是「读不到页码」,一路指不向真因。窗口在哪是**引擎手里的事实**,
|
|
987
|
+
* 不该让模型每次重算。这也让确定性的 `tool` 步用得上放大镜(那里根本没有模型来算)。
|
|
988
|
+
*
|
|
989
|
+
* 拿不到窗口时**原样返回**并当作全屏坐标 —— 宁可框偏,也不要静默不给放大镜
|
|
990
|
+
* (后者的表现是「放大镜时有时无」,比框偏难查得多)。
|
|
991
|
+
*/
|
|
992
|
+
/** @internal 导出仅供单测钉住窗口锚定的换算。 */
|
|
993
|
+
export function _resolveMagnifyRect(
|
|
994
|
+
magnify: ComputerUseRect | undefined,
|
|
995
|
+
anchor: 'screen' | 'window' | undefined,
|
|
996
|
+
observation: ComputerState | undefined,
|
|
997
|
+
): ComputerUseRect | undefined {
|
|
998
|
+
if (!magnify || anchor !== 'window') return magnify;
|
|
999
|
+
const appId = observation?.activeApp?.id;
|
|
1000
|
+
const win = pickFrontWindow(
|
|
1001
|
+
observation?.windows as unknown as {
|
|
1002
|
+
appId: string;
|
|
1003
|
+
minimized?: boolean;
|
|
1004
|
+
focused?: boolean;
|
|
1005
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
1006
|
+
}[],
|
|
1007
|
+
appId,
|
|
1008
|
+
);
|
|
1009
|
+
const display =
|
|
1010
|
+
observation?.displays?.find((d) => d.primary) ?? observation?.displays?.[0];
|
|
1011
|
+
if (!win || !display?.logicalBounds) return magnify;
|
|
1012
|
+
const ratio = windowBoundsToRatio(win.bounds, display);
|
|
1013
|
+
const w = ratio.right - ratio.left;
|
|
1014
|
+
const h = ratio.bottom - ratio.top;
|
|
1015
|
+
if (!(w > 0 && h > 0)) return magnify;
|
|
1016
|
+
return {
|
|
1017
|
+
x: ratio.left + (magnify.x / 1000) * w,
|
|
1018
|
+
y: ratio.top + (magnify.y / 1000) * h,
|
|
1019
|
+
width: Math.max(1, (magnify.width / 1000) * w),
|
|
1020
|
+
height: Math.max(1, (magnify.height / 1000) * h),
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** 从 ActionResult 里取某个变化率。没测就是 undefined —— 与「测了但是 0」不同。 */
|
|
1025
|
+
/** @internal 导出仅供单测钉住「取哪一层」这个反复踩的坑。 */
|
|
1026
|
+
export function _actionChangeRatio(
|
|
1027
|
+
data: unknown,
|
|
1028
|
+
field: 'changeRatio' | 'regionChangeRatio' | 'movedRatio',
|
|
1029
|
+
): number | undefined {
|
|
1030
|
+
return actionChangeRatio(data, field);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function actionChangeRatio(
|
|
1034
|
+
data: unknown,
|
|
1035
|
+
field: 'changeRatio' | 'regionChangeRatio' | 'movedRatio',
|
|
1036
|
+
): number | undefined {
|
|
1037
|
+
const record = data as Record<string, unknown> | null | undefined;
|
|
1038
|
+
const finite = (value: unknown): number | undefined =>
|
|
1039
|
+
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
1040
|
+
// ⚠️ **两层都要看。** `verified_action` 的载荷是三段式 `{ type, action, settle,
|
|
1041
|
+
// observation }`,变化率在 `data.action.<field>`;只有未走验证事务的普通动作才把它
|
|
1042
|
+
// 放在 `data.<field>`。
|
|
1043
|
+
//
|
|
1044
|
+
// 这里原先只读外层,于是绝大多数动作(写操作都走验证事务)恒得 undefined ——
|
|
1045
|
+
// 而取错一层**不会报错**,只会让所有依赖它的东西安静失效:滚动边界判定恒 unknown、
|
|
1046
|
+
// trace 里的 changeRatio/regionChangeRatio 恒缺席、「滚动没生效」提示恒不触发。
|
|
1047
|
+
// 同一个坑本文件里已经踩过一次(`data.summary` vs `data.action.summary`,
|
|
1048
|
+
// 那次是取 type 的剪贴板还原提示),这是第二次 —— 所以判据收在这一个函数里,
|
|
1049
|
+
// 别再各自取。
|
|
1050
|
+
const nested = isRecord(record?.action) ? finite(record.action[field]) : undefined;
|
|
1051
|
+
return nested ?? finite(record?.[field]);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1055
|
+
return typeof value === 'object' && value !== null;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* 焦点控件占全屏的面积比例,用于把变化率归一化。
|
|
1060
|
+
*
|
|
1061
|
+
* 在输入框里打字必然改变输入框本身;只有当变化面积**超出控件能解释的范围**
|
|
1062
|
+
* 时才说明控件之外多了东西。这个比例 TS 自己就能算(AX 给了控件矩形、
|
|
1063
|
+
* 观测给了显示器尺寸),不必让原生再测一遍。
|
|
1064
|
+
*/
|
|
1065
|
+
function focusAreaRatio(state: ComputerState): number | undefined {
|
|
1066
|
+
const caret = deriveFocusSnapshot(state)?.caret;
|
|
1067
|
+
if (!caret) return undefined;
|
|
1068
|
+
const display = state.displays.find((d) => d.primary) ?? state.displays[0];
|
|
1069
|
+
const screenArea =
|
|
1070
|
+
(display?.logicalBounds.width ?? 0) * (display?.logicalBounds.height ?? 0);
|
|
1071
|
+
if (screenArea <= 0) return undefined;
|
|
1072
|
+
const ratio = (caret.width * caret.height) / screenArea;
|
|
1073
|
+
return Number.isFinite(ratio) && ratio > 0 ? Math.min(ratio, 1) : undefined;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* 连续失败熔断(R6)。返回非 null 表示拒绝执行。
|
|
1078
|
+
*
|
|
1079
|
+
* 模型在困惑时最容易反复重试同一个失败动作,而它自己看不出自己在打转 ——
|
|
1080
|
+
* 这是引擎级兜底,不指望它自觉停。
|
|
1081
|
+
*
|
|
1082
|
+
* 三条刻意的边界:
|
|
1083
|
+
* - **只拦写类**。观测类必须放行,否则模型连「我卡在哪」都看不到,
|
|
1084
|
+
* 而看清现状正是它需要做的下一件事。
|
|
1085
|
+
* - **按运行 + 动作指纹隔离**。别的会话在失败不该影响这个会话;
|
|
1086
|
+
* 换个目标重试也不该继承上一个目标的计数。
|
|
1087
|
+
* - **自己的拒绝不记录**(由调用点的位置保证:判定在登记之前且提前返回)。
|
|
1088
|
+
*
|
|
1089
|
+
* ★ 一条被推翻的旧论断,留在这里免得有人再推一遍:曾经的写法是「一次成功的
|
|
1090
|
+
* observe 就清零,因为『先看清楚再动手』正好是我们希望模型做的事」。
|
|
1091
|
+
* 听着合理,实际是个洞 —— `失败 → observe → 失败 → observe …` 是模型打转
|
|
1092
|
+
* 时**最常见**的形状,于是熔断永远数不到 3。现在清零的条件是「**这个动作**
|
|
1093
|
+
* 成功了一次」,observe 成功不清写操作的账。
|
|
1094
|
+
*/
|
|
1095
|
+
function refuseWhenStuck(
|
|
1096
|
+
input: ComputerUseInput,
|
|
1097
|
+
context: import('../../types.js').ToolCallContext,
|
|
1098
|
+
): ToolResult | null {
|
|
1099
|
+
const run = context.computerRun;
|
|
1100
|
+
if (!run) return null;
|
|
1101
|
+
if (!isMutatingComputerUseAction(input.action)) return null;
|
|
1102
|
+
const failures = computerUseService.consecutiveFailuresFor(run, input);
|
|
1103
|
+
if (failures < CONSECUTIVE_FAILURE_LIMIT) return null;
|
|
1104
|
+
return {
|
|
1105
|
+
ok: false,
|
|
1106
|
+
error: `这个动作(${input.action}${input.elementId ? ` ${input.elementId}` : ''})已经连续失败 ${failures} 次,引擎暂停了对同一目标的重试。请先用 observe 看清楚当前屏幕到底是什么状态(观测不受限制),换一条路径或换一个目标再试;如果判断不出来,就把现状如实告诉用户,不要继续盲试。`,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* 工具主体。返回值与 `callWithContext` 一致;`extra` 用来把只有这里才知道的
|
|
1112
|
+
* 遥测字段(帧数、AX 节点数、变化率…)带给外层的记录点。
|
|
1113
|
+
*/
|
|
1114
|
+
async function runComputerUse(
|
|
1115
|
+
input: ComputerUseInput,
|
|
1116
|
+
signal: AbortSignal | undefined,
|
|
1117
|
+
context: import('../../types.js').ToolCallContext,
|
|
1118
|
+
extra: Record<string, unknown>,
|
|
1119
|
+
): Promise<ToolResult> {
|
|
1120
|
+
const run = context.computerRun;
|
|
1121
|
+
if (!run) return { ok: false, error: '当前宿主未启用 Computer Use' };
|
|
1122
|
+
|
|
1123
|
+
// 不可逆审批必须排在能力检查**之前**。
|
|
1124
|
+
//
|
|
1125
|
+
// `requiredCapability` 把 `intent:'submit'` 映射成 `submit` 能力,而没有
|
|
1126
|
+
// 任何路径会预先授予 submit(`grants.ts` 有意不再从用户措辞里猜它)。
|
|
1127
|
+
// 于是审批一旦排在能力检查之后,就永远跑不到:模型报了 submit → 能力检查
|
|
1128
|
+
// 拒 → 人根本没被问过。这不是「更严格」,是把一条产品能力做成了死路。
|
|
1129
|
+
const authorization = await authorizeIrreversibleAction(input, run, signal);
|
|
1130
|
+
if (authorization.error) {
|
|
1131
|
+
return attachNotice(
|
|
1132
|
+
{ ok: false, error: authorization.error },
|
|
1133
|
+
authorization.notice,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// 「你自报的类别没被说明书认领」必须回到模型手上,成败两条路都要说。
|
|
1138
|
+
// 只在拒绝时说会漏掉最要紧的那种情况:降级后的通用审批**通过了**,模型于是
|
|
1139
|
+
// 以为自己那个类别生效了,下次继续报同一个错名字,而它拿到的授权其实一直
|
|
1140
|
+
// 是最宽的 intent:submit。
|
|
1141
|
+
const result = await runAuthorizedComputerUse(
|
|
1142
|
+
input,
|
|
1143
|
+
signal,
|
|
1144
|
+
context,
|
|
1145
|
+
extra,
|
|
1146
|
+
run,
|
|
1147
|
+
authorization.authorized,
|
|
1148
|
+
);
|
|
1149
|
+
return attachNotice(result, authorization.notice);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* 语义等待。轮询**元数据观测**(不组装图),命中或超时即返回。
|
|
1154
|
+
*
|
|
1155
|
+
* `list_apps` 是既有的「只要状态、不要截图」路径(`includeScreenshot:false`),
|
|
1156
|
+
* 直接复用 —— 不为轮询新造一个动作。
|
|
1157
|
+
*
|
|
1158
|
+
* 简单 `wait` 动作是纯 sleep,不截图不查 AX 树,用于动作后等待 UI 响应。
|
|
1159
|
+
*/
|
|
1160
|
+
async function runWait(
|
|
1161
|
+
input: ComputerUseInput,
|
|
1162
|
+
run: NonNullable<import('../../types.js').ToolCallContext['computerRun']>,
|
|
1163
|
+
extra: Record<string, unknown>,
|
|
1164
|
+
signal?: AbortSignal,
|
|
1165
|
+
): Promise<ToolResult> {
|
|
1166
|
+
// 简单 wait 动作:sleep 后自动截一张图返回,保证模型能看到等待后的界面状态
|
|
1167
|
+
// (参考 UI-TARS 的 onAfterToolCall 强制截图闭环:wait 后不截图,模型就看不到 UI 响应结果)
|
|
1168
|
+
if (isSimpleWaitAction(input.action)) {
|
|
1169
|
+
const outcome = await waitForCondition(
|
|
1170
|
+
{
|
|
1171
|
+
action: 'wait',
|
|
1172
|
+
...(input.waitMs !== undefined ? { waitMs: input.waitMs } : {}),
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
observeOnce: async () => null, // 不会被调用
|
|
1176
|
+
...(signal ? { signal } : {}),
|
|
1177
|
+
},
|
|
1178
|
+
);
|
|
1179
|
+
extra.ms = outcome.waitedMs;
|
|
1180
|
+
extra.polls = 0;
|
|
1181
|
+
if (!outcome.ok) {
|
|
1182
|
+
return { ok: false, error: outcome.content };
|
|
1183
|
+
}
|
|
1184
|
+
// wait 完成后自动截图,让模型看到等待后的界面
|
|
1185
|
+
const obsResponse = await computerUseService.request(
|
|
1186
|
+
run,
|
|
1187
|
+
{ action: 'observe', frames: 1 },
|
|
1188
|
+
signal,
|
|
1189
|
+
);
|
|
1190
|
+
if (obsResponse.ok && obsResponse.state) {
|
|
1191
|
+
const assembled = await buildObservationImages(obsResponse.state);
|
|
1192
|
+
// ★ AX吸附:存储短id->真实id映射
|
|
1193
|
+
if (assembled?.axIdMap) {
|
|
1194
|
+
computerUseService.updateAxIdMap(run, assembled.axIdMap);
|
|
1195
|
+
}
|
|
1196
|
+
const lines = [
|
|
1197
|
+
outcome.content,
|
|
1198
|
+
'等待完成,下面是当前界面截图,请根据实际界面状态决定下一步操作。',
|
|
1199
|
+
...(assembled?.summary ?? []),
|
|
1200
|
+
].filter(Boolean);
|
|
1201
|
+
return {
|
|
1202
|
+
ok: true,
|
|
1203
|
+
content: lines.join('\n'),
|
|
1204
|
+
...(assembled?.images.length
|
|
1205
|
+
? { visionImages: assembled.images, visionNote: assembled.note }
|
|
1206
|
+
: {}),
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
return { ok: true, content: outcome.content };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// wait_for_* 动作:轮询元数据观测
|
|
1213
|
+
const outcome = await waitForCondition(
|
|
1214
|
+
{
|
|
1215
|
+
action: input.action as WaitAction,
|
|
1216
|
+
...(input.role !== undefined ? { role: input.role } : {}),
|
|
1217
|
+
...(input.text !== undefined ? { text: input.text } : {}),
|
|
1218
|
+
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
observeOnce: async () => {
|
|
1222
|
+
const response = await computerUseService.request(
|
|
1223
|
+
run,
|
|
1224
|
+
{ action: 'list_apps' },
|
|
1225
|
+
signal,
|
|
1226
|
+
);
|
|
1227
|
+
return response.ok ? (response.state ?? null) : null;
|
|
1228
|
+
},
|
|
1229
|
+
...(signal ? { signal } : {}),
|
|
1230
|
+
},
|
|
1231
|
+
);
|
|
1232
|
+
extra.ms = outcome.waitedMs;
|
|
1233
|
+
extra.polls = outcome.polls;
|
|
1234
|
+
return outcome.ok
|
|
1235
|
+
? {
|
|
1236
|
+
ok: true,
|
|
1237
|
+
content: outcome.elementId
|
|
1238
|
+
? `${outcome.content}\n可直接用 elementId=${outcome.elementId} 作为下一步的目标。`
|
|
1239
|
+
: outcome.content,
|
|
1240
|
+
}
|
|
1241
|
+
: { ok: false, error: outcome.content };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* 把 `textTarget` 换成 `elementId`。
|
|
1246
|
+
*
|
|
1247
|
+
* ★ **不新增定位通道**:文字只负责**解析出** elementId,之后仍走既有的
|
|
1248
|
+
* `ax:<路径>#<指纹>` 路径 —— 动作前会重算指纹比对,滚过的列表报
|
|
1249
|
+
* `stale_state` 而不是点错行。新造一条「按文字直接点」的通道会绕过这层自证。
|
|
1250
|
+
*
|
|
1251
|
+
* 三条边界都是**拒绝**而不是猜:
|
|
1252
|
+
* · 多个匹配 → 列出候选让模型自己选。点错东西的代价远大于多问一轮
|
|
1253
|
+
* · 零匹配 → 建议 `wait_for_text`(很可能只是还没渲染出来)
|
|
1254
|
+
* · AX 不可用 → 明说这是 AX 能力,自绘应用上没有;别让模型以为是自己写错了
|
|
1255
|
+
*/
|
|
1256
|
+
function resolveTextTarget(
|
|
1257
|
+
input: ComputerUseInput,
|
|
1258
|
+
run: NonNullable<import('../../types.js').ToolCallContext['computerRun']>,
|
|
1259
|
+
):
|
|
1260
|
+
| { input: ComputerUseInput; info?: string }
|
|
1261
|
+
| { error: string; fallbackToCoord?: boolean } {
|
|
1262
|
+
const text = input.textTarget?.trim() ?? '';
|
|
1263
|
+
if (!text) return { error: 'textTarget 不能是空字符串。' };
|
|
1264
|
+
if (input.elementId) {
|
|
1265
|
+
return {
|
|
1266
|
+
error: 'textTarget 与 elementId 是二选一的定位方式,不要同时给。',
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const hasCoord = input.x !== undefined && input.y !== undefined;
|
|
1271
|
+
|
|
1272
|
+
// ★ 输入形状校验**先于**状态校验。
|
|
1273
|
+
//
|
|
1274
|
+
// 参数自相矛盾时要直说,而不是先怪「你还没 observe」—— 后者会把模型引向
|
|
1275
|
+
// 「那我先 observe 一次再原样重发」,而它下一次仍然是同样矛盾的参数。
|
|
1276
|
+
//
|
|
1277
|
+
// schema 里 `textTarget` 的说明本来就写着「与 elementId / x,y **三选一**」,
|
|
1278
|
+
// 而这里的实现却在点击类动作上把坐标当成静默 fallback,等于代码违反自己
|
|
1279
|
+
// 文档化的契约。低风险动作(hover/scroll)保留 fallback:最坏只是悬停位置
|
|
1280
|
+
// 不准;点击类不行,点错东西的代价太高(见调用处 `canFallbackToCoord`)。
|
|
1281
|
+
const LOW_RISK_FALLBACK_ACTIONS = ['hover', 'scroll'];
|
|
1282
|
+
if (hasCoord && !LOW_RISK_FALLBACK_ACTIONS.includes(input.action)) {
|
|
1283
|
+
return {
|
|
1284
|
+
error:
|
|
1285
|
+
'textTarget 与 elementId、x/y 坐标是三选一的定位方式,不要同时给。'
|
|
1286
|
+
+ '想按文字点就只给 textTarget;确实要用坐标就去掉 textTarget。',
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const fallbackFields = hasCoord ? { fallbackToCoord: true as const } : {};
|
|
1291
|
+
|
|
1292
|
+
const state = computerUseService.lastObservation(run);
|
|
1293
|
+
if (!state) {
|
|
1294
|
+
return {
|
|
1295
|
+
error: '还没有可用的观测 —— 先 observe 一次再用 textTarget 定位。',
|
|
1296
|
+
...fallbackFields,
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
if (!state.accessibility || state.accessibility.truncated === true) {
|
|
1300
|
+
return {
|
|
1301
|
+
error:
|
|
1302
|
+
'按文字定位需要无障碍树,而当前应用没有提供 —— 自绘界面(Electron / Qt / 跨端客户端)的常态。',
|
|
1303
|
+
...fallbackFields,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const allNodes = flattenAx(state.accessibility.root as AxSearchNode);
|
|
1308
|
+
let candidates = findClickableByText(allNodes, text);
|
|
1309
|
+
|
|
1310
|
+
if (candidates.length === 0) {
|
|
1311
|
+
return {
|
|
1312
|
+
error: `无障碍树里没有文字含「${text}」的可点控件。如果它可能还没渲染出来,先用 wait_for_text 等;否则换个更短的片段。`,
|
|
1313
|
+
...fallbackFields,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
if (candidates.length > 1) {
|
|
1317
|
+
// 如果同时提供了 x/y 坐标,选距离最近的那个(模型的坐标作为"大概位置"的提示)
|
|
1318
|
+
const primaryDisplay =
|
|
1319
|
+
state.displays?.find((d) => d.primary) ?? state.displays?.[0];
|
|
1320
|
+
if (hasCoord && primaryDisplay) {
|
|
1321
|
+
const display = primaryDisplay;
|
|
1322
|
+
const px =
|
|
1323
|
+
display.logicalBounds.x +
|
|
1324
|
+
(input.x! / 1000) * display.logicalBounds.width;
|
|
1325
|
+
const py =
|
|
1326
|
+
display.logicalBounds.y +
|
|
1327
|
+
(input.y! / 1000) * display.logicalBounds.height;
|
|
1328
|
+
const scored = candidates.map((node) => {
|
|
1329
|
+
const b = node.bounds!;
|
|
1330
|
+
const cx = b.x + b.width / 2;
|
|
1331
|
+
const cy = b.y + b.height / 2;
|
|
1332
|
+
return { node, dist: Math.hypot(cx - px, cy - py) };
|
|
1333
|
+
});
|
|
1334
|
+
scored.sort((a, b) => a.dist - b.dist);
|
|
1335
|
+
const nearest = scored[0]!;
|
|
1336
|
+
// 最近的距离在 200 逻辑点以内才接受,否则还是列出候选让模型确认
|
|
1337
|
+
if (nearest.dist <= 200) {
|
|
1338
|
+
const target = nearest.node;
|
|
1339
|
+
return {
|
|
1340
|
+
input: {
|
|
1341
|
+
...input,
|
|
1342
|
+
elementId: target.elementId!,
|
|
1343
|
+
expectedRevision: input.expectedRevision ?? state.revision,
|
|
1344
|
+
textTarget: undefined,
|
|
1345
|
+
x: undefined,
|
|
1346
|
+
y: undefined,
|
|
1347
|
+
},
|
|
1348
|
+
info: `textTarget「${text}」匹配到 ${candidates.length} 个,按坐标邻近度选择了最近的:${describeNode(target)}`,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
error: `文字「${text}」匹配到 ${candidates.length} 个可点控件,不替你猜是哪一个:\n${candidates
|
|
1354
|
+
.slice(0, 8)
|
|
1355
|
+
.map((node) => ` - ${describeNode(node)}`)
|
|
1356
|
+
.join(
|
|
1357
|
+
'\n',
|
|
1358
|
+
)}\n用更长的片段区分,或直接给上面某个 elementId,或同时提供 x/y 大致坐标让系统选最近的。`,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const target = candidates[0]!;
|
|
1363
|
+
return {
|
|
1364
|
+
input: {
|
|
1365
|
+
...input,
|
|
1366
|
+
elementId: target.elementId!,
|
|
1367
|
+
expectedRevision: input.expectedRevision ?? state.revision,
|
|
1368
|
+
textTarget: undefined,
|
|
1369
|
+
x: undefined,
|
|
1370
|
+
y: undefined,
|
|
1371
|
+
},
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** 把一条引擎侧提示贴到工具回执上。成功走 content、失败走 error,两条都不能漏。 */
|
|
1376
|
+
function attachNotice(
|
|
1377
|
+
result: ToolResult,
|
|
1378
|
+
notice: string | undefined,
|
|
1379
|
+
): ToolResult {
|
|
1380
|
+
if (!notice) return result;
|
|
1381
|
+
return result.ok
|
|
1382
|
+
? {
|
|
1383
|
+
...result,
|
|
1384
|
+
content: [notice, result.content].filter(Boolean).join('\n'),
|
|
1385
|
+
}
|
|
1386
|
+
: { ...result, error: `${result.error}\n${notice}` };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** 审批已过之后的主体:能力检查、发请求、组装回执。 */
|
|
1390
|
+
async function runAuthorizedComputerUse(
|
|
1391
|
+
input: ComputerUseInput,
|
|
1392
|
+
signal: AbortSignal | undefined,
|
|
1393
|
+
context: import('../../types.js').ToolCallContext,
|
|
1394
|
+
extra: Record<string, unknown>,
|
|
1395
|
+
run: NonNullable<import('../../types.js').ToolCallContext['computerRun']>,
|
|
1396
|
+
authorized: boolean,
|
|
1397
|
+
): Promise<ToolResult> {
|
|
1398
|
+
const capability = capabilityForInput(input);
|
|
1399
|
+
// `submit` 不查 `capabilities` 数组:它的授权来自上面那次人工批准,且**限定
|
|
1400
|
+
// 到 (应用, 动作类别)**。查数组等于回到「批准一次 = 本回合任何不可逆动作
|
|
1401
|
+
// 都放行」的老路 —— 那正是这条修复要收窄掉的东西。
|
|
1402
|
+
if (capability === 'submit') {
|
|
1403
|
+
if (!authorized) {
|
|
1404
|
+
return {
|
|
1405
|
+
ok: false,
|
|
1406
|
+
error: '这一步被判为不可逆动作,但没有取得对应的人工批准',
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
} else if (!run.capabilities.includes(capability)) {
|
|
1410
|
+
return {
|
|
1411
|
+
ok: false,
|
|
1412
|
+
error: `当前运行未获得 Computer Use ${capability} 权限`,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
if (
|
|
1416
|
+
input.appId &&
|
|
1417
|
+
run.allowedApps.length > 0 &&
|
|
1418
|
+
!run.allowedApps.includes(input.appId)
|
|
1419
|
+
) {
|
|
1420
|
+
return { ok: false, error: '目标应用不在当前 Computer Use 授权范围内' };
|
|
1421
|
+
}
|
|
1422
|
+
if (
|
|
1423
|
+
input.filePaths?.length &&
|
|
1424
|
+
capability === 'file-drop' &&
|
|
1425
|
+
(run.allowedFileRoots?.length ?? 0) === 0
|
|
1426
|
+
) {
|
|
1427
|
+
return { ok: false, error: '当前运行没有可拖入文件的授权目录' };
|
|
1428
|
+
}
|
|
1429
|
+
let authorizedFilePaths: string[] | undefined;
|
|
1430
|
+
if (input.filePaths?.length && capability === 'file-drop') {
|
|
1431
|
+
const authorization = await authorizeComputerUseFileDropPaths(
|
|
1432
|
+
input.filePaths,
|
|
1433
|
+
run.allowedFileRoots ?? [],
|
|
1434
|
+
);
|
|
1435
|
+
if (!authorization.ok) return { ok: false, error: authorization.error };
|
|
1436
|
+
authorizedFilePaths = authorization.paths;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// 语义等待在这里接管:它是**观测的编排**,不是一个守护进程动作。
|
|
1440
|
+
// 放在能力检查之后,是因为它照样要 observe 权限。
|
|
1441
|
+
if (isWaitAction(input.action)) {
|
|
1442
|
+
return runWait(input, run, extra, signal);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// 像素坐标模式:把 pixelX/pixelY(截图像素)换算为 0-1000 比例坐标。
|
|
1446
|
+
// 必须在 textTarget 解析之前完成,因为它依赖比例坐标。
|
|
1447
|
+
let resolvedInput = input;
|
|
1448
|
+
const usesCoordinates = POINTING_ACTIONS.has(input.action);
|
|
1449
|
+
const hasPixelCoord =
|
|
1450
|
+
usesCoordinates && (input.pixelX !== undefined || input.pixelY !== undefined);
|
|
1451
|
+
const hasRatioCoord =
|
|
1452
|
+
usesCoordinates && (input.x !== undefined || input.y !== undefined);
|
|
1453
|
+
if (hasPixelCoord && hasRatioCoord) {
|
|
1454
|
+
return {
|
|
1455
|
+
ok: false,
|
|
1456
|
+
error:
|
|
1457
|
+
'pixelX/pixelY(像素坐标)与 x/y(比例坐标)不能同时提供,选一种即可。',
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
if (hasPixelCoord) {
|
|
1461
|
+
if (input.pixelX === undefined || input.pixelY === undefined) {
|
|
1462
|
+
return { ok: false, error: 'pixelX 和 pixelY 必须同时提供。' };
|
|
1463
|
+
}
|
|
1464
|
+
const state = computerUseService.lastObservation(run);
|
|
1465
|
+
// ⚠️ 分母必须是**模型实际看到的那张图**的尺寸,不是 daemon 的原始截图尺寸。
|
|
1466
|
+
// 5K 屏上原始截图 5120×2880,压进预算后模型看到 1371×771 —— 差 3.73 倍。
|
|
1467
|
+
// 用原始尺寸做分母,模型量出来的每一个像素坐标都会朝左上偏出近四倍距离,
|
|
1468
|
+
// 在多窗口叠放的桌面上通常意味着点到目标窗口后面的另一个应用。
|
|
1469
|
+
const shown =
|
|
1470
|
+
state?.screenshot?.width && state?.screenshot?.height
|
|
1471
|
+
? modelFacingImageSize(state.screenshot.width, state.screenshot.height)
|
|
1472
|
+
: undefined;
|
|
1473
|
+
const imgW = shown?.width;
|
|
1474
|
+
const imgH = shown?.height;
|
|
1475
|
+
if (!imgW || !imgH) {
|
|
1476
|
+
return {
|
|
1477
|
+
ok: false,
|
|
1478
|
+
error: '像素坐标模式需要先 observe 一次(获取截图尺寸用于换算)。',
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
const ratioX = (input.pixelX / imgW) * 1000;
|
|
1482
|
+
const ratioY = (input.pixelY / imgH) * 1000;
|
|
1483
|
+
resolvedInput = { ...resolvedInput, x: ratioX, y: ratioY };
|
|
1484
|
+
if (input.pixelX2 !== undefined && input.pixelY2 !== undefined) {
|
|
1485
|
+
const ratioX2 = (input.pixelX2 / imgW) * 1000;
|
|
1486
|
+
const ratioY2 = (input.pixelY2 / imgH) * 1000;
|
|
1487
|
+
resolvedInput = { ...resolvedInput, x2: ratioX2, y2: ratioY2 };
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// click-by-text:把文字换成 elementId,再走既有的带指纹定位路径。
|
|
1492
|
+
// 放在像素坐标换算之后,因为多匹配选最近时用换算后的坐标作为"大概位置"提示。
|
|
1493
|
+
let textTargetNotice: string | undefined;
|
|
1494
|
+
if (resolvedInput.textTarget !== undefined) {
|
|
1495
|
+
const resolved = resolveTextTarget(resolvedInput, run);
|
|
1496
|
+
if ('error' in resolved) {
|
|
1497
|
+
// 点击类动作不允许 fallback:模型明确想用文字定位,
|
|
1498
|
+
// 没找到就是没找到,不能瞎点(误点击风险太高)。
|
|
1499
|
+
// hover/scroll 等低风险动作允许 fallback,最多只是悬停位置不准或滚错区域。
|
|
1500
|
+
const lowRiskActions = ['hover', 'scroll'];
|
|
1501
|
+
const canFallbackToCoord =
|
|
1502
|
+
resolved.fallbackToCoord &&
|
|
1503
|
+
resolvedInput.x !== undefined &&
|
|
1504
|
+
resolvedInput.y !== undefined &&
|
|
1505
|
+
lowRiskActions.includes(resolvedInput.action);
|
|
1506
|
+
if (canFallbackToCoord) {
|
|
1507
|
+
textTargetNotice = `⚠️ textTarget「${resolvedInput.textTarget}」未找到匹配,已 fallback 到坐标(${resolved.error})`;
|
|
1508
|
+
resolvedInput = { ...resolvedInput, textTarget: undefined };
|
|
1509
|
+
} else {
|
|
1510
|
+
return { ok: false, error: resolved.error };
|
|
1511
|
+
}
|
|
1512
|
+
} else {
|
|
1513
|
+
resolvedInput = resolved.input;
|
|
1514
|
+
extra.textTarget = 'resolved';
|
|
1515
|
+
if (resolved.info) textTargetNotice = `✅ ${resolved.info}`;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// 长文本 / 非 ASCII 的输入路径:**先直接合成 Unicode 键盘事件,粘贴只做兜底**。
|
|
1520
|
+
//
|
|
1521
|
+
// ⚠️ 这里原来是反的,注释写着「Unicode keyboard synthesis is not reliable across
|
|
1522
|
+
// native macOS controls (notably WeChat's self-drawn inputs)」。在这台机器上实测
|
|
1523
|
+
// 的结论正相反,而且恰恰是在被点名的微信上:
|
|
1524
|
+
// · 微信**搜索框**:直接打字 ✅(打 "wanglei" 立刻搜出联系人);Cmd+V ❌ ——
|
|
1525
|
+
// 换成带 flagsChanged 的完整 chord + 真 HID 事件源重试,仍然 ❌
|
|
1526
|
+
// · 微信**消息输入框**:直接打字 ✅(CJK 也正常);Cmd+V ✅
|
|
1527
|
+
// 也就是说打字在两处都成立,粘贴只在一处成立。把粘贴摆在前面,等于让中文输入在
|
|
1528
|
+
// 搜索框里必然失败 —— 而这条改写路径**不做验证**就回「已通过剪贴板粘贴输入 N 个
|
|
1529
|
+
// 字符」,模型于是永远不知道自己没打进去,只会换个坐标一遍遍重试。
|
|
1530
|
+
//
|
|
1531
|
+
// 保留粘贴作为兜底而不是删掉:确实存在只吃粘贴不吃合成按键的控件,而且超长文本
|
|
1532
|
+
// 逐字符合成有其代价。顺序按「哪个被实测证明覆盖面更广」排,不按猜测排。
|
|
1533
|
+
// 正文本身不进结果文案,也不进审计记录。
|
|
1534
|
+
let pasteUsed = false;
|
|
1535
|
+
if (
|
|
1536
|
+
resolvedInput.action === 'type' &&
|
|
1537
|
+
resolvedInput.text &&
|
|
1538
|
+
shouldUseClipboardPaste(resolvedInput.text)
|
|
1539
|
+
) {
|
|
1540
|
+
const textToPaste = resolvedInput.text;
|
|
1541
|
+
|
|
1542
|
+
// 第一路:直接打字。验证判失败(确定性的「屏幕没动」)才继续往下走兜底。
|
|
1543
|
+
const typedResponse = await computerUseService.executeVerifiedAction(
|
|
1544
|
+
run,
|
|
1545
|
+
{ action: 'type', text: textToPaste } as Omit<
|
|
1546
|
+
ComputerUseRequest,
|
|
1547
|
+
'protocolVersion' | 'requestId' | 'context'
|
|
1548
|
+
>,
|
|
1549
|
+
signal,
|
|
1550
|
+
);
|
|
1551
|
+
// ⚠️ 判断成败之前,先问「守护进程是不是已经用确定性路径做成了」。
|
|
1552
|
+
//
|
|
1553
|
+
// ⛔ **`probe_change` 对剪贴板粘贴是错的仪器。** 它测的是
|
|
1554
|
+
// `settle_timeline` —— 动作**之后**一个窗口内的逐帧 churn,回答「画面还在不在动」。
|
|
1555
|
+
// 逐字打字有持续重绘所以适用;**粘贴是瞬间完成的**,采样开始时重绘早已结束,
|
|
1556
|
+
// 于是恒测出 0.0000 并断言「这次操作很可能根本没有落到界面上」。
|
|
1557
|
+
// 这和 `computerUseService.ts` 里把 `click` 排除在 PROBE_CHANGE_ACTIONS 之外
|
|
1558
|
+
// 是同一个理由、同一个坑。
|
|
1559
|
+
//
|
|
1560
|
+
// 实测代价:微信搜索框里「王磊」已经**成功输入并弹出了联系人列表**,回执却说
|
|
1561
|
+
// 变化率 0.0000;模型据此判定输入失败,只试一次就整个任务放弃 ——
|
|
1562
|
+
// 用户看到的是「点中了输入框就是不输入,干着急」。
|
|
1563
|
+
//
|
|
1564
|
+
// 守护进程报告的路径是权威证据(它自己知道走了哪条路、每步都有真实返回值),
|
|
1565
|
+
// 优先于一个已知不适用的屏幕判据。
|
|
1566
|
+
const typedAction = (
|
|
1567
|
+
typedResponse.data as
|
|
1568
|
+
| { action?: { summary?: unknown; inputPath?: unknown } }
|
|
1569
|
+
| undefined
|
|
1570
|
+
)?.action;
|
|
1571
|
+
const typedSummary =
|
|
1572
|
+
typeof typedAction?.summary === 'string' ? typedAction.summary : '';
|
|
1573
|
+
// 结构化契约(protocol::TextInputPath),**不是**匹配 summary 里的中文。
|
|
1574
|
+
// 第一版就是 `summary.includes('剪贴板')` —— Rust 那边改一个字,这里就静默
|
|
1575
|
+
// 失效,而且没有任何编译期或测试期信号。人话给人看,判据走字段。
|
|
1576
|
+
const daemonUsedClipboard = typedAction?.inputPath === 'clipboard';
|
|
1577
|
+
// ⚠️ 这里**不能**再要求 `typedResponse.ok` —— 走剪贴板成功时它恰恰是 `false`,
|
|
1578
|
+
// 因为 ok 被同一个不适用的 churn 判据拉下来了(实测:summary 明写
|
|
1579
|
+
// 「已粘贴 2 个字符(剪贴板路径);原剪贴板内容已还原」,ok 仍是 false)。
|
|
1580
|
+
if (daemonUsedClipboard) {
|
|
1581
|
+
const assembled = typedResponse.state
|
|
1582
|
+
? await buildObservationImages(
|
|
1583
|
+
typedResponse.state,
|
|
1584
|
+
typedResponse.actionMarker,
|
|
1585
|
+
)
|
|
1586
|
+
: undefined;
|
|
1587
|
+
return {
|
|
1588
|
+
ok: true,
|
|
1589
|
+
content: `已输入 ${textToPaste.length} 个字符。${typedSummary}`,
|
|
1590
|
+
...(assembled?.images.length
|
|
1591
|
+
? { visionImages: assembled.images, visionNote: assembled.note }
|
|
1592
|
+
: {}),
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
if (typedResponse.ok && typedResponse.verification?.status !== 'failed') {
|
|
1596
|
+
const assembled = typedResponse.state
|
|
1597
|
+
? await buildObservationImages(
|
|
1598
|
+
typedResponse.state,
|
|
1599
|
+
typedResponse.actionMarker,
|
|
1600
|
+
)
|
|
1601
|
+
: undefined;
|
|
1602
|
+
// ⚠️ **只有验证真的通过时才敢说「已输入」。**
|
|
1603
|
+
//
|
|
1604
|
+
// `verification.status` 有三态,而这个分支收的是 `!== 'failed'` —— 也就是
|
|
1605
|
+
// `inconclusive` 也走这里。以前它和 passed 说同一句「已直接输入 N 个字符」,
|
|
1606
|
+
// 于是「没验出来」被讲成了「做成了」。模型据此往下走,下一步看截图发现输入框
|
|
1607
|
+
// 是空的,只能重新点、重新打;实测一次任务因此多跑 5 轮 click→type,
|
|
1608
|
+
// 每轮都是一次完整的 LLM 往返(含截图),约占总耗时三分之一。
|
|
1609
|
+
//
|
|
1610
|
+
// 措辞要能让模型直接决定下一步:确认了就往下走,没确认就先核对。
|
|
1611
|
+
const verified = typedResponse.verification?.status === 'passed';
|
|
1612
|
+
return {
|
|
1613
|
+
ok: true,
|
|
1614
|
+
content: verified
|
|
1615
|
+
? `已输入 ${textToPaste.length} 个字符(已确认落到界面上)。`
|
|
1616
|
+
: `已发出 ${textToPaste.length} 个字符的输入,但**未能确认**它真的进了目标控件`
|
|
1617
|
+
+ `(${typedResponse.verification?.reason ?? '缺少目标区域的变化证据'})。\n`
|
|
1618
|
+
+ `请对照下面的最新截图确认内容是否已经出现:出现了就继续;没出现就重新定位`
|
|
1619
|
+
+ `输入框,不要按「已经输入过」继续往下走。`,
|
|
1620
|
+
...(assembled?.images.length
|
|
1621
|
+
? { visionImages: assembled.images, visionNote: assembled.note }
|
|
1622
|
+
: {}),
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// ⛔ **不再有「TS 层剪贴板兜底」这条路。**
|
|
1627
|
+
//
|
|
1628
|
+
// 它原本是为了救「中文输不进去」,但真实成因不在这里,而且它自己会造成更坏的后果:
|
|
1629
|
+
//
|
|
1630
|
+
// ① 真实成因有两条,都已在守护进程侧确定性解决:
|
|
1631
|
+
// · 输入法激活时合成按键到不了控件 → `macos.rs::input_method_is_active` 直接改走剪贴板
|
|
1632
|
+
// · `pbcopy` 无 locale 时丢掉全部非 ASCII → `clipboard_write_text` 显式带 `LC_CTYPE=UTF-8`
|
|
1633
|
+
// (还有第三条:修饰键卡在按下状态让每个字符变成快捷键,见 `post_key`)
|
|
1634
|
+
//
|
|
1635
|
+
// ② 这条兜底的触发判据是**屏幕变化率**,而它在这个场景里恰恰最不可信:
|
|
1636
|
+
// 搜索框里多两个字,在全屏差分上远低于阈值 → 判 `failed` → 再粘一遍。
|
|
1637
|
+
// 实测微信搜索框出现 **「王磊王磊」**:直接输入本来就成功了,兜底又补了一次。
|
|
1638
|
+
// 静默重复输入比失败更糟 —— 它看起来成功,然后搜不到人,而且没人知道为什么。
|
|
1639
|
+
//
|
|
1640
|
+
// 现在的契约:输入走哪条路由守护进程按**输入源**确定性决定,只走一次;
|
|
1641
|
+
// 验证测不出来就在回执里如实说「未能确认」并附截图,让模型自己看,不替它猜。
|
|
1642
|
+
const unverified = typedResponse.state
|
|
1643
|
+
? await buildObservationImages(
|
|
1644
|
+
typedResponse.state,
|
|
1645
|
+
typedResponse.actionMarker,
|
|
1646
|
+
)
|
|
1647
|
+
: undefined;
|
|
1648
|
+
return {
|
|
1649
|
+
ok: true as const,
|
|
1650
|
+
content:
|
|
1651
|
+
`已发出 ${textToPaste.length} 个字符的输入,但**未能确认**它真的进了目标控件`
|
|
1652
|
+
+ `(${typedResponse.verification?.reason ?? '缺少目标区域的变化证据'})。\n`
|
|
1653
|
+
+ `请对照下面的最新截图确认内容是否已经出现:出现了就继续;没出现就先 click`
|
|
1654
|
+
+ `聚焦输入框再 type,不要直接重复调用 type —— 重复会把文字输入两遍。`,
|
|
1655
|
+
...(unverified?.images.length
|
|
1656
|
+
? { visionImages: unverified.images, visionNote: unverified.note }
|
|
1657
|
+
: {}),
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const observationFrames =
|
|
1662
|
+
input.action === 'observe'
|
|
1663
|
+
? await configService
|
|
1664
|
+
.computerUse()
|
|
1665
|
+
.then((config) =>
|
|
1666
|
+
resolveObservationFrames(input.frames, config.observationFrames),
|
|
1667
|
+
)
|
|
1668
|
+
: undefined;
|
|
1669
|
+
const requestInput = {
|
|
1670
|
+
...resolvedInput,
|
|
1671
|
+
// `actionClass` 只服务本进程的授权判定,守护进程不认识它 ——
|
|
1672
|
+
// 置 undefined 让它在序列化时消失,别往线路协议里塞私货字段。
|
|
1673
|
+
actionClass: undefined,
|
|
1674
|
+
// 观测默认带帧序列。模型显式要 1 帧时尊重它(只想看当前状态、省 token)。
|
|
1675
|
+
...(input.action === 'observe' ? { frames: observationFrames! } : {}),
|
|
1676
|
+
...(authorizedFilePaths ? { filePaths: authorizedFilePaths } : {}),
|
|
1677
|
+
} as Omit<ComputerUseRequest, 'protocolVersion' | 'requestId' | 'context'>;
|
|
1678
|
+
let response = isMutatingComputerUseAction(requestInput.action)
|
|
1679
|
+
? await computerUseService.executeVerifiedAction(run, requestInput, signal)
|
|
1680
|
+
: await computerUseService.request(run, requestInput, signal);
|
|
1681
|
+
|
|
1682
|
+
// ===== repeat:一轮往返内把同一动作连做 N 次(见 inputSchema.repeat 的注释)=====
|
|
1683
|
+
//
|
|
1684
|
+
// 只有第一次已经成功才继续 —— 首次就失败时重复毫无意义,而且会把一个错误动作
|
|
1685
|
+
// 连做 8 遍。到边界(目标区域不再变化)立刻停,剩下的次数不做。
|
|
1686
|
+
const repeatTimes = REPEATABLE_ACTIONS.has(input.action)
|
|
1687
|
+
? Math.min(input.repeat ?? 1, MAX_REPEAT)
|
|
1688
|
+
: 1;
|
|
1689
|
+
let repeatDone = 1;
|
|
1690
|
+
let repeatStoppedAtEdge = false;
|
|
1691
|
+
/** 一次都没测出 ROI —— 到底有没有到边界,我们不知道,回执里要说。 */
|
|
1692
|
+
let repeatEdgeUnknown = false;
|
|
1693
|
+
/** 连续多少次目标区域没动。单次可能只是动画没稳,两次才算到底。 */
|
|
1694
|
+
let stillStreak = 0;
|
|
1695
|
+
const repeatFrames: ImageInputSpec[] = [];
|
|
1696
|
+
/**
|
|
1697
|
+
* 把「连做 N 次」抽成一份:**正常路径与焦点恢复之后都要用它**。
|
|
1698
|
+
*
|
|
1699
|
+
* ⚠️ 恢复路径此前直接 `return`,剩余次数被静默丢弃 —— 实测事故:
|
|
1700
|
+
* 模型请求 `repeat:8`,第一次按键撞上前台被别的窗口抢走,于是
|
|
1701
|
+
* `if (repeatTimes > 1 && response.ok)` 判假、整个批量循环被跳过;
|
|
1702
|
+
* 错误路径里 `recoverLostFocus` 重新激活并**只重试一次**就早返回。
|
|
1703
|
+
* 净效果是 8 次变 1 次,而回执里一个字都没提(那句「repeat:已连续执行 M/N 次」
|
|
1704
|
+
* 在下面的正常返回路径上,早返回够不到它)。
|
|
1705
|
+
* 在 seek 循环里这能自愈(下一轮重读页码),但在「翻 20 条聊天记录」这类遍历里
|
|
1706
|
+
* 表现是**静默少读** —— 看起来像「内容只有这么多」,一个像事实的错误结论。
|
|
1707
|
+
*
|
|
1708
|
+
* 从 `fromRound` 开始跑到 `repeatTimes`,就地更新外层的
|
|
1709
|
+
* `response / repeatDone / repeatStoppedAtEdge / repeatEdgeUnknown / repeatFrames`。
|
|
1710
|
+
*/
|
|
1711
|
+
const runRepeatRounds = async (fromRound: number): Promise<void> => {
|
|
1712
|
+
for (let round = fromRound; round <= repeatTimes; round += 1) {
|
|
1713
|
+
if (signal?.aborted) break;
|
|
1714
|
+
// 连续两次「目标区域没动」= 到头了。继续按只是白费往返,
|
|
1715
|
+
// 而且会让模型误以为自己还在前进。
|
|
1716
|
+
// 只判一次容易被动画未稳误伤(第一下正在渲染,ROI 恰好没变)。
|
|
1717
|
+
const edge = isAtScrollEdge(response);
|
|
1718
|
+
if (edge === 'unknown') repeatEdgeUnknown = true;
|
|
1719
|
+
stillStreak = edge === 'at-edge' ? stillStreak + 1 : 0;
|
|
1720
|
+
if (stillStreak >= EDGE_CONFIRM_ROUNDS) {
|
|
1721
|
+
repeatStoppedAtEdge = true;
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
const next = isMutatingComputerUseAction(requestInput.action)
|
|
1725
|
+
? await computerUseService.executeVerifiedAction(run, requestInput, signal)
|
|
1726
|
+
: await computerUseService.request(run, requestInput, signal);
|
|
1727
|
+
if (!next.ok) break; // 半路失败:保留已采到的内容,按已完成次数如实汇报
|
|
1728
|
+
// 每一次的画面都要留下来 —— 遍历类任务要读的正是中间过程,
|
|
1729
|
+
// 只给最后一张等于只给终点,中间翻过的内容全丢。
|
|
1730
|
+
//
|
|
1731
|
+
// ⚠️ 但「快速翻到第 N 页」不是遍历:中间那些页没有阅读价值,而 repeat:8
|
|
1732
|
+
// 会一次回 8 张全屏图(约 1 万 token)。所以给调用方一个开关,
|
|
1733
|
+
// 默认仍是 all(遍历是 repeat 的主要用途,不能因为定位场景改默认值)。
|
|
1734
|
+
if (next.state && input.repeatFrames !== 'last') {
|
|
1735
|
+
const shot = await buildObservationImages(next.state, next.actionMarker);
|
|
1736
|
+
if (shot?.images.length) repeatFrames.push(...shot.images);
|
|
1737
|
+
}
|
|
1738
|
+
response = next;
|
|
1739
|
+
repeatDone = round;
|
|
1740
|
+
}
|
|
1741
|
+
if (!repeatStoppedAtEdge) {
|
|
1742
|
+
const edge = isAtScrollEdge(response);
|
|
1743
|
+
if (edge === 'unknown') repeatEdgeUnknown = true;
|
|
1744
|
+
else if (edge === 'at-edge' && stillStreak + 1 >= EDGE_CONFIRM_ROUNDS) {
|
|
1745
|
+
repeatStoppedAtEdge = true;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
if (repeatTimes > 1 && response.ok) await runRepeatRounds(2);
|
|
1750
|
+
|
|
1751
|
+
/** 焦点被抢后自动恢复了 —— 这句要并进回执,让模型知道中间出过岔子。 */
|
|
1752
|
+
let focusRecoveryNote: string | null = null;
|
|
1753
|
+
|
|
1754
|
+
// ⚠️ 带标签是为了让**焦点恢复成功**这一支能跳出整个错误处理块、汇回主流程,
|
|
1755
|
+
// 而不是像以前那样自己拼一个 ToolResult 早返回。汇回来才拿得到:
|
|
1756
|
+
// 补跑剩余 repeat 次数、「repeat:M/N 次」文本、extra 诊断、movedRatio、图片组装。
|
|
1757
|
+
errorHandling: if (!response.ok) {
|
|
1758
|
+
const errorCode = response.error?.code ?? 'failed';
|
|
1759
|
+
const failure = `${errorCode}: ${response.error?.message ?? 'Computer Use 操作失败'}`;
|
|
1760
|
+
const hint = response.error?.hint ? `\n${response.error.hint}` : '';
|
|
1761
|
+
|
|
1762
|
+
const focusLost =
|
|
1763
|
+
errorCode === 'target_not_found' &&
|
|
1764
|
+
response.error?.details?.reason === 'target_focus_lost';
|
|
1765
|
+
if (errorCode === 'human_input_detected' || focusLost) {
|
|
1766
|
+
if (!context.computerIntervention) {
|
|
1767
|
+
// ⚠️ **无人接管 ≠ 只能放弃。**
|
|
1768
|
+
//
|
|
1769
|
+
// 这条闸门是为有人值守的桌面端设计的:焦点被抢 → 暂停 → 等人处理。但 `-p`
|
|
1770
|
+
// 模式下根本没有人,于是它退化成「直接终止整个任务」。实测代价:两次读取任务的
|
|
1771
|
+
// **全部失败**都出自这里(cu-read2 9 步里 3 次、cu-read3 5 步里 2 次)。
|
|
1772
|
+
//
|
|
1773
|
+
// 而焦点丢失的恢复动作极其简单 —— 重新激活目标应用即可,模型在别的运行里
|
|
1774
|
+
// 正是这么自己救回来的。与其让它多烧一轮往返去做同一件事,不如就地做掉。
|
|
1775
|
+
//
|
|
1776
|
+
// 只对**焦点丢失**自恢复:`human_input_detected` 是真人正在动鼠标键盘,
|
|
1777
|
+
// 那种情况下抢回前台是在和用户打架,必须让路。
|
|
1778
|
+
const recovery = focusLost
|
|
1779
|
+
? await recoverLostFocus(run, requestInput, signal)
|
|
1780
|
+
: null;
|
|
1781
|
+
if (recovery) {
|
|
1782
|
+
response = recovery.response;
|
|
1783
|
+
focusRecoveryNote = recovery.note;
|
|
1784
|
+
// 恢复只重试了**这一次**动作。批量的剩余次数必须在这里补完 ——
|
|
1785
|
+
// 不补的话 repeat:8 就静默变成 1 次(见 runRepeatRounds 头注的实测事故)。
|
|
1786
|
+
if (repeatTimes > 1) await runRepeatRounds(repeatDone + 1);
|
|
1787
|
+
break errorHandling;
|
|
1788
|
+
}
|
|
1789
|
+
return {
|
|
1790
|
+
ok: false,
|
|
1791
|
+
error: `human_intervention_required: ${focusLost ? '目标应用已失去焦点,且自动重新激活未成功' : '检测到用户正在操作电脑'}。当前宿主没有可用的人工接管通道,任务已停止。`,
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
const resumed = await context.computerIntervention.pause(
|
|
1795
|
+
'manual',
|
|
1796
|
+
focusLost
|
|
1797
|
+
? '目标应用已失去焦点,AI 已暂停电脑操作'
|
|
1798
|
+
: '检测到你正在使用鼠标或键盘,AI 已暂停电脑操作',
|
|
1799
|
+
signal,
|
|
1800
|
+
);
|
|
1801
|
+
if (resumed.resolution !== 'resumed' || !resumed.state) {
|
|
1802
|
+
return {
|
|
1803
|
+
ok: false,
|
|
1804
|
+
error: 'human_intervention_required: 用户已结束本次电脑操作任务。',
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
const refreshed = await buildObservationImages(resumed.state);
|
|
1808
|
+
return {
|
|
1809
|
+
ok: true,
|
|
1810
|
+
content:
|
|
1811
|
+
'用户已完成接管,旧动作已取消。下面是重新观察后的界面;请重新定位并决定下一步,不能假设旧动作已经完成。',
|
|
1812
|
+
...(refreshed?.images.length
|
|
1813
|
+
? { visionImages: refreshed.images, visionNote: refreshed.note }
|
|
1814
|
+
: {}),
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// permission_denied 特殊处理:自动打开对应权限的系统设置页面,并给出清晰引导
|
|
1819
|
+
if (errorCode === 'permission_denied') {
|
|
1820
|
+
const errMsg = response.error?.message ?? '';
|
|
1821
|
+
const errHint = response.error?.hint ?? '';
|
|
1822
|
+
// 通过错误消息区分是屏幕录制权限还是辅助功能权限
|
|
1823
|
+
// Rust 侧消息: "Screen Recording permission" / "Accessibility permission"
|
|
1824
|
+
// 以及 screencapture 命令失败的各种提示
|
|
1825
|
+
const isScreenCapture =
|
|
1826
|
+
/Screen Recording|screen.?capture|屏幕录制|screencapture/i.test(
|
|
1827
|
+
errMsg,
|
|
1828
|
+
) || /Screen Recording/i.test(errHint);
|
|
1829
|
+
|
|
1830
|
+
// 打开对应的系统设置页面
|
|
1831
|
+
if (isScreenCapture) {
|
|
1832
|
+
openScreenCaptureSettings();
|
|
1833
|
+
} else {
|
|
1834
|
+
openAccessibilitySettings();
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// 生成引导信息
|
|
1838
|
+
const targetName = getPermissionTargetName();
|
|
1839
|
+
const inAppBundle = isRunningInAppBundle();
|
|
1840
|
+
const permType = isScreenCapture ? '屏幕录制' : '辅助功能';
|
|
1841
|
+
const settingsPath = isScreenCapture
|
|
1842
|
+
? '隐私与安全性 → 屏幕录制'
|
|
1843
|
+
: '隐私与安全性 → 辅助功能';
|
|
1844
|
+
|
|
1845
|
+
let guidance: string;
|
|
1846
|
+
if (inAppBundle) {
|
|
1847
|
+
// 生产模式:在 .app 内运行,告诉用户找 KakadeAI
|
|
1848
|
+
guidance = [
|
|
1849
|
+
'',
|
|
1850
|
+
`【${permType}权限未授权 - 已为你打开系统设置】`,
|
|
1851
|
+
`请在系统设置 → ${settingsPath} 中,找到并勾选「${targetName}」。`,
|
|
1852
|
+
'勾选后回来告诉我「已授权」即可继续操作。',
|
|
1853
|
+
'',
|
|
1854
|
+
`提示:如果在列表中没看到 ${targetName},可以先尝试执行一个操作(如点击),`,
|
|
1855
|
+
'系统会自动弹出授权请求对话框。',
|
|
1856
|
+
].join('\n');
|
|
1857
|
+
} else {
|
|
1858
|
+
// 开发模式:告诉用户这是正常的,以及怎么授权 cu-helperd
|
|
1859
|
+
guidance = [
|
|
1860
|
+
'',
|
|
1861
|
+
`【${permType}权限未授权 - 已为你打开系统设置】`,
|
|
1862
|
+
'当前是开发模式(bun run dev),cu-helperd 作为独立进程运行,',
|
|
1863
|
+
`没有 .app 包裹,所以权限弹窗会显示 "cu-helperd" 而不是 "KakadeAI"。`,
|
|
1864
|
+
'这是正常现象——打包成 .app 安装后弹窗会正确显示 KakadeAI。',
|
|
1865
|
+
'',
|
|
1866
|
+
`请在系统设置 → ${settingsPath} 中:`,
|
|
1867
|
+
' 1. 点击左下角锁图标解锁',
|
|
1868
|
+
` 2. 点击 "+" 号添加 cu-helperd(位于 computer-use-native/target/release/ 下)`,
|
|
1869
|
+
` 或者直接勾选列表中已出现的 "cu-helperd"`,
|
|
1870
|
+
' 3. 确保它前面的开关是打开的',
|
|
1871
|
+
'',
|
|
1872
|
+
'授权一次后权限会保留(直到重新编译 cu-helperd)。',
|
|
1873
|
+
].join('\n');
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
const salvagedPerm = response.state
|
|
1877
|
+
? await buildObservationImages(response.state, response.actionMarker)
|
|
1878
|
+
: null;
|
|
1879
|
+
return {
|
|
1880
|
+
ok: false,
|
|
1881
|
+
error: `❌ ${failure}${hint}${guidance}`,
|
|
1882
|
+
...(salvagedPerm?.images.length
|
|
1883
|
+
? { visionImages: salvagedPerm.images, visionNote: salvagedPerm.note }
|
|
1884
|
+
: {}),
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
// verification_failed 特殊引导:动作已执行但未达到预期效果(点偏了/没点中/界面没变化),
|
|
1888
|
+
// 给出的截图里包含最新界面状态,引导模型重新定位
|
|
1889
|
+
if (errorCode === 'verification_failed' && response.state) {
|
|
1890
|
+
const salvagedVf = await buildObservationImages(
|
|
1891
|
+
response.state,
|
|
1892
|
+
response.actionMarker,
|
|
1893
|
+
);
|
|
1894
|
+
const guidance =
|
|
1895
|
+
resolvedInput.x !== undefined && resolvedInput.y !== undefined
|
|
1896
|
+
? ` 提示:上一次点击坐标(${Math.round(resolvedInput.x)}, ${Math.round(resolvedInput.y)})可能偏了,请根据截图仔细观察界面实际状态,重新定位目标后再操作,不要重复上一次的坐标。`
|
|
1897
|
+
: '';
|
|
1898
|
+
return {
|
|
1899
|
+
ok: false,
|
|
1900
|
+
error: `❌ ${failure}${hint}${guidance}\n\n下面是动作后的最新截图,请仔细观察界面实际状态,重新定位目标后再操作,不要重复上一次的坐标。`,
|
|
1901
|
+
...(salvagedVf?.images.length
|
|
1902
|
+
? { visionImages: salvagedVf.images, visionNote: salvagedVf.note }
|
|
1903
|
+
: {}),
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
const salvaged = response.state
|
|
1907
|
+
? await buildObservationImages(response.state, response.actionMarker)
|
|
1908
|
+
: null;
|
|
1909
|
+
return {
|
|
1910
|
+
ok: false,
|
|
1911
|
+
error: `❌ ${failure}${hint}`,
|
|
1912
|
+
...(salvaged?.images.length
|
|
1913
|
+
? { visionImages: salvaged.images, visionNote: salvaged.note }
|
|
1914
|
+
: {}),
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
if (response.state) {
|
|
1919
|
+
// 跨进程打断:前台被别的进程(或用户自己)抢走了。
|
|
1920
|
+
//
|
|
1921
|
+
// 这条判据的价值在于**它不依赖目标应用配合** —— 前台窗口归 OS 管,
|
|
1922
|
+
// 微信可以拒绝暴露控件树,它拒绝不了「我这个窗口还是不是前台」。
|
|
1923
|
+
// 于是自绘/Electron 应用上其它判据全瞎时,这一条依然成立(focus.ts 头注 ①)。
|
|
1924
|
+
// 继续按旧观测的坐标点下去,就是在别人刚切过来的窗口上闭眼操作。
|
|
1925
|
+
const boundary =
|
|
1926
|
+
response.focusChange === FOCUS_CHANGE.OTHER_PROCESS
|
|
1927
|
+
? {
|
|
1928
|
+
reason: 'blocked' as const,
|
|
1929
|
+
summary: `前台应用已变成「${response.state.activeApp?.name ?? '未知应用'}」(可能是你自己切了窗口,或别的程序抢了前台):${recommendedAction(response.focusChange).advice}`,
|
|
1930
|
+
confidence: 'ax' as const,
|
|
1931
|
+
}
|
|
1932
|
+
: detectComputerHumanBoundary(
|
|
1933
|
+
response.state,
|
|
1934
|
+
await manualForState(response.state),
|
|
1935
|
+
);
|
|
1936
|
+
const handled = await handleHumanBoundary(
|
|
1937
|
+
boundary,
|
|
1938
|
+
response.state,
|
|
1939
|
+
context,
|
|
1940
|
+
signal,
|
|
1941
|
+
);
|
|
1942
|
+
if (handled.refusal) return handled.refusal;
|
|
1943
|
+
if (handled.state) response = { ...response, state: handled.state };
|
|
1944
|
+
if (response.focus) extra.focusLevel = response.focus.level;
|
|
1945
|
+
if (response.focusChange) extra.focusChange = response.focusChange;
|
|
1946
|
+
} else {
|
|
1947
|
+
// 动作返回的是 ActionResult 而不是 Observation,所以上面那条基于 AX 的
|
|
1948
|
+
// 判据在动作之后根本跑不到 —— 而「输入到一半界面突然变了」恰好只发生
|
|
1949
|
+
// 在动作之后。这条像素兜底就是为这个缺口存在的。
|
|
1950
|
+
//
|
|
1951
|
+
// 变化率来自守护进程(`probeChange`,仅在「输入动作 + 上次观测 AX 树
|
|
1952
|
+
// 不可用」时才请求,见 computerUseService::shouldProbeChange)。判定用
|
|
1953
|
+
// **上一次观测**的状态:那正是决定要不要探测的那次观测,天然配对。
|
|
1954
|
+
const changeRatio = actionChangeRatio(response.data, 'changeRatio');
|
|
1955
|
+
if (changeRatio !== undefined) {
|
|
1956
|
+
extra.changeRatio = changeRatio;
|
|
1957
|
+
const observed = computerUseService.observationAtAction(run);
|
|
1958
|
+
if (observed) {
|
|
1959
|
+
const regionChangeRatio = actionChangeRatio(
|
|
1960
|
+
response.data,
|
|
1961
|
+
'regionChangeRatio',
|
|
1962
|
+
);
|
|
1963
|
+
if (regionChangeRatio !== undefined)
|
|
1964
|
+
extra.regionChangeRatio = regionChangeRatio;
|
|
1965
|
+
const evidence = {
|
|
1966
|
+
// `typing` 如实反映这次是不是文本输入 —— 曾经无条件传 true,
|
|
1967
|
+
// 于是所有被探测的动作都按「正在打字」判,把判据的前提架空了。
|
|
1968
|
+
typing: input.action === 'type',
|
|
1969
|
+
changeRatio,
|
|
1970
|
+
...(regionChangeRatio !== undefined ? { regionChangeRatio } : {}),
|
|
1971
|
+
...(focusAreaRatio(observed) !== undefined
|
|
1972
|
+
? { focusAreaRatio: focusAreaRatio(observed)! }
|
|
1973
|
+
: {}),
|
|
1974
|
+
};
|
|
1975
|
+
const verdict = classifyPixelChange(observed, evidence);
|
|
1976
|
+
if (verdict === 'suspicious') {
|
|
1977
|
+
const boundary = detectPixelHumanBoundary(observed, evidence);
|
|
1978
|
+
if (boundary) {
|
|
1979
|
+
const handled = await handleHumanBoundary(
|
|
1980
|
+
boundary,
|
|
1981
|
+
observed,
|
|
1982
|
+
context,
|
|
1983
|
+
signal,
|
|
1984
|
+
);
|
|
1985
|
+
if (handled.refusal) return handled.refusal;
|
|
1986
|
+
}
|
|
1987
|
+
} else if (verdict === 'no-effect') {
|
|
1988
|
+
// 不是安全问题,但把它当「一切正常」会让模型接着往下打,越走越偏。
|
|
1989
|
+
extra.pixelVerdict = 'no-effect';
|
|
1990
|
+
return {
|
|
1991
|
+
ok: true,
|
|
1992
|
+
content:
|
|
1993
|
+
'动作已发出,但屏幕上(含焦点控件内)没有任何像素变化 ——' +
|
|
1994
|
+
'这次输入很可能没有落到目标控件上(焦点丢了、或应用没响应)。' +
|
|
1995
|
+
'先 observe 确认焦点在哪,再决定要不要重来,别接着往下输入。',
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
// replay 返回的是裸灰度帧,必须在这里解码成少量图。
|
|
2002
|
+
//
|
|
2003
|
+
// 不接这条路会把守护进程的 base64 原样塞进 content —— 30 帧 480×270 灰度
|
|
2004
|
+
// 约 520 万字符(≈130 万 token),一次调用就把上下文炸穿。
|
|
2005
|
+
if (input.action === 'replay') {
|
|
2006
|
+
const replay = await buildReplayImages(extractReplayFrames(response.data));
|
|
2007
|
+
if (!replay) {
|
|
2008
|
+
return {
|
|
2009
|
+
ok: true,
|
|
2010
|
+
content: '回放缓冲里还没有可用的帧(采样器可能刚启动)。',
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
return {
|
|
2014
|
+
ok: true,
|
|
2015
|
+
content: replay.summary.join('\n'),
|
|
2016
|
+
visionImages: replay.images,
|
|
2017
|
+
visionNote: replay.note,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// settle 的像素时间线由原生侧算(0.24ms/帧,跨 IPC 传像素反而更贵),
|
|
2022
|
+
// 但**分级留在这里** —— confidence、焦点三分类、给模型看的话术都是策略,
|
|
2023
|
+
// 不是算术。两边各管一半,谁都不重复实现。
|
|
2024
|
+
if (input.action === 'settle') {
|
|
2025
|
+
return { ok: true, content: describeSettle(response.data) };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
if (!response.state) {
|
|
2029
|
+
return {
|
|
2030
|
+
ok: true,
|
|
2031
|
+
content:
|
|
2032
|
+
response.content ?? JSON.stringify(response.data ?? { ok: true }),
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// A mutating response reaches this branch only after the native transaction
|
|
2037
|
+
// returned a fresh observation. A busy remote desktop can leave settle
|
|
2038
|
+
// inconclusive even when the atomic action was applied, so never call that
|
|
2039
|
+
// state "verified" in the model-visible feedback.
|
|
2040
|
+
const actionFeedback: string[] = [];
|
|
2041
|
+
// 焦点被抢并自动恢复过 —— 排在最前面说,因为它解释了后面所有数字为什么可能异常
|
|
2042
|
+
// (比如 repeat 的实际次数少于请求次数)。
|
|
2043
|
+
if (focusRecoveryNote) actionFeedback.push(focusRecoveryNote);
|
|
2044
|
+
// ⚠️ **有确定性证据时就说确认,别让屏幕判据把它拉下来。**
|
|
2045
|
+
//
|
|
2046
|
+
// `activate_app` 的目标是「让这个 app 到前台」,而观测里的 `activeApp.id`
|
|
2047
|
+
// 就是这件事的**直接证据** —— 与画面动没动无关。可窗口切换本来就带动画,
|
|
2048
|
+
// settle 判据几乎必然是 inconclusive,于是回执长期是
|
|
2049
|
+
// 「已执行(稳定性尚未确认)· 画面稳定度:仍在变化中」。
|
|
2050
|
+
//
|
|
2051
|
+
// 实测代价(真机 trace):模型据此认为没成功,**紧接着又调了一次
|
|
2052
|
+
// activate_app** —— 一整轮 LLM 往返 30.2s 全部作废,占那次任务总耗时的 22%。
|
|
2053
|
+
// 与 type 走剪贴板那次是同一类错误:一个不适用的判据盖过了确定性事实。
|
|
2054
|
+
const activationConfirmed =
|
|
2055
|
+
resolvedInput.action === 'activate_app'
|
|
2056
|
+
&& resolvedInput.appId !== undefined
|
|
2057
|
+
&& response.state.activeApp?.id === resolvedInput.appId;
|
|
2058
|
+
const actionOutcome =
|
|
2059
|
+
response.verification?.status === 'passed' || activationConfirmed
|
|
2060
|
+
? '已核验'
|
|
2061
|
+
: '已执行(稳定性尚未确认)';
|
|
2062
|
+
// ⚠️ **顺带挪了窗口就必须说。**
|
|
2063
|
+
//
|
|
2064
|
+
// `activate_app` 会在窗口越出可用区(被 Dock/菜单栏压住、或伸到屏外)时自动摆正。
|
|
2065
|
+
// 那是对用户环境的真实副作用,而且**模型手里上一轮观测得到的坐标会全部失效** ——
|
|
2066
|
+
// 不说等于让它闭眼点旧坐标。读结构化字段,不嗅探 summary 文案(见 TextInputPath)。
|
|
2067
|
+
const repositioned = (
|
|
2068
|
+
response.data as { action?: { windowRepositioned?: unknown } } | undefined
|
|
2069
|
+
)?.action?.windowRepositioned;
|
|
2070
|
+
if (isRecord(repositioned)) {
|
|
2071
|
+
const { x, y, width, height } = repositioned as Record<string, number>;
|
|
2072
|
+
actionFeedback.push(
|
|
2073
|
+
`🪟 该窗口原先超出可用区(被 Dock/菜单栏压住或伸到屏外),已自动摆正到 `
|
|
2074
|
+
+ `(${Math.round(x)}, ${Math.round(y)}) ${Math.round(width)}×${Math.round(height)} 逻辑像素。`
|
|
2075
|
+
+ `**之前观测得到的坐标已经全部失效**,请按下面这张最新截图重新定位。`,
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
const activeAppName =
|
|
2080
|
+
response.state.activeApp?.name ??
|
|
2081
|
+
response.state.activeApp?.id ??
|
|
2082
|
+
'未知应用';
|
|
2083
|
+
|
|
2084
|
+
// 点击类动作反馈
|
|
2085
|
+
if (
|
|
2086
|
+
['click', 'double_click', 'right_click', 'middle_click', 'hover'].includes(
|
|
2087
|
+
resolvedInput.action,
|
|
2088
|
+
)
|
|
2089
|
+
) {
|
|
2090
|
+
if (resolvedInput.x !== undefined && resolvedInput.y !== undefined) {
|
|
2091
|
+
const snapResult = computerUseService.consumeLastSnapResult(run);
|
|
2092
|
+
if (resolvedInput.action === 'hover') {
|
|
2093
|
+
// hover 动作:突出显示悬停位置,供模型确认坐标后再点击
|
|
2094
|
+
if (snapResult?.snapped && snapResult.element) {
|
|
2095
|
+
const el = snapResult.element;
|
|
2096
|
+
const elName = el.label ? `「${el.label}」` : '';
|
|
2097
|
+
actionFeedback.push(
|
|
2098
|
+
`🖱️ 鼠标已悬停在 ${el.role} ${elName}(实际坐标 (${Math.round(snapResult.x)}, ${Math.round(snapResult.y)}))`,
|
|
2099
|
+
);
|
|
2100
|
+
actionFeedback.push(
|
|
2101
|
+
`📍 原始目标坐标 (${Math.round(resolvedInput.x)}, ${Math.round(resolvedInput.y)}),AX吸附偏移约 ${Math.round(snapResult.distancePx ?? 0)} 像素`,
|
|
2102
|
+
);
|
|
2103
|
+
actionFeedback.push(
|
|
2104
|
+
'👉 如果这是你想点的位置,下一步直接 click 相同坐标即可(AX吸附会保证精准)。',
|
|
2105
|
+
);
|
|
2106
|
+
} else {
|
|
2107
|
+
actionFeedback.push(
|
|
2108
|
+
`🖱️ 鼠标已悬停在坐标 (${Math.round(resolvedInput.x)}, ${Math.round(resolvedInput.y)})`,
|
|
2109
|
+
);
|
|
2110
|
+
actionFeedback.push(
|
|
2111
|
+
'⚠️ 附近 28 像素内未检测到可交互控件,请确认这是正确位置。如果是输入框/文本区域等不可AX识别的控件也可以直接点击。',
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
} else {
|
|
2115
|
+
if (snapResult?.snapped && snapResult.element) {
|
|
2116
|
+
const el = snapResult.element;
|
|
2117
|
+
const elName = el.label ? `「${el.label}」` : '';
|
|
2118
|
+
actionFeedback.push(
|
|
2119
|
+
`${resolvedInput.action} ${actionOutcome}:在「${activeAppName}」中操作坐标 (${Math.round(resolvedInput.x)}, ${Math.round(resolvedInput.y)})`,
|
|
2120
|
+
);
|
|
2121
|
+
actionFeedback.push(
|
|
2122
|
+
`🎯 AX吸附:已自动吸附到 ${el.role} ${elName}(偏移约${Math.round(snapResult.distancePx ?? 0)}像素,实际点击坐标 (${Math.round(snapResult.x)}, ${Math.round(snapResult.y)}))`,
|
|
2123
|
+
);
|
|
2124
|
+
} else {
|
|
2125
|
+
actionFeedback.push(
|
|
2126
|
+
`${resolvedInput.action} ${actionOutcome}:在「${activeAppName}」中操作坐标 (${Math.round(resolvedInput.x)}, ${Math.round(resolvedInput.y)})`,
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
// 拖拽动作反馈
|
|
2133
|
+
else if (resolvedInput.action === 'drag') {
|
|
2134
|
+
if (
|
|
2135
|
+
resolvedInput.x !== undefined &&
|
|
2136
|
+
resolvedInput.y !== undefined &&
|
|
2137
|
+
resolvedInput.toX !== undefined &&
|
|
2138
|
+
resolvedInput.toY !== undefined
|
|
2139
|
+
) {
|
|
2140
|
+
actionFeedback.push(
|
|
2141
|
+
`drag ${actionOutcome}:从 (${Math.round(resolvedInput.x)}, ${Math.round(resolvedInput.y)}) 拖到 (${Math.round(resolvedInput.toX)}, ${Math.round(resolvedInput.toY)})`,
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
// 滚动动作反馈
|
|
2146
|
+
else if (resolvedInput.action === 'scroll') {
|
|
2147
|
+
const direction = resolvedInput.direction ?? 'unknown';
|
|
2148
|
+
if (resolvedInput.x !== undefined && resolvedInput.y !== undefined) {
|
|
2149
|
+
actionFeedback.push(
|
|
2150
|
+
`scroll ${actionOutcome}:在 (${Math.round(resolvedInput.x)}, ${Math.round(resolvedInput.y)}) 位置向「${direction}」滚动`,
|
|
2151
|
+
);
|
|
2152
|
+
} else {
|
|
2153
|
+
actionFeedback.push(`scroll ${actionOutcome}:向「${direction}」滚动`);
|
|
2154
|
+
actionFeedback.push(
|
|
2155
|
+
'⚠️ scroll 未指定 x/y 坐标,滚轮作用在当前鼠标位置(可能不在目标窗口内)。下次请提供要滚动的区域坐标。',
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
// 输入动作反馈
|
|
2160
|
+
else if (resolvedInput.action === 'type' && resolvedInput.text) {
|
|
2161
|
+
actionFeedback.push(
|
|
2162
|
+
`type ${actionOutcome}:向「${activeAppName}」输入了 ${resolvedInput.text.length} 个字符`,
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
// 按键动作反馈
|
|
2166
|
+
else if (resolvedInput.action === 'key' && resolvedInput.key) {
|
|
2167
|
+
actionFeedback.push(
|
|
2168
|
+
`key ${actionOutcome}:按下按键「${resolvedInput.key}」`,
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
// 应用激活反馈
|
|
2172
|
+
else if (resolvedInput.action === 'activate_app' && resolvedInput.appId) {
|
|
2173
|
+
actionFeedback.push(
|
|
2174
|
+
`activate_app ${actionOutcome}:前台应用为 ${resolvedInput.appId}`,
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
// 剪贴板写入反馈
|
|
2178
|
+
else if (resolvedInput.action === 'clipboard_write' && resolvedInput.text) {
|
|
2179
|
+
actionFeedback.push(
|
|
2180
|
+
`clipboard_write ${actionOutcome}:写入了 ${resolvedInput.text.length} 个字符到剪贴板`,
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// ★ 窗口边界坐标转换 + 越界警告(参考 UI-TARS 闭环设计):
|
|
2185
|
+
// 把逻辑像素窗口边界转换成 0-1000 相对坐标,模型可直接使用无需心算;
|
|
2186
|
+
// 同时检测点击坐标是否落在前台窗口范围内,不在则发出警告。
|
|
2187
|
+
const stateForRect = response.state;
|
|
2188
|
+
const primaryDisplay =
|
|
2189
|
+
stateForRect?.displays?.find((d) => d.primary) ??
|
|
2190
|
+
stateForRect?.displays?.[0];
|
|
2191
|
+
const screenW = primaryDisplay?.logicalBounds?.width ?? 0;
|
|
2192
|
+
const screenH = primaryDisplay?.logicalBounds?.height ?? 0;
|
|
2193
|
+
const activeAppId = stateForRect?.activeApp?.id;
|
|
2194
|
+
|
|
2195
|
+
// 检测点击坐标是否落在前台窗口外(仅对有坐标的点击/滚动类动作)
|
|
2196
|
+
const isClickWithPoint =
|
|
2197
|
+
['click', 'double_click', 'right_click', 'middle_click', 'scroll'].includes(
|
|
2198
|
+
resolvedInput.action,
|
|
2199
|
+
) &&
|
|
2200
|
+
resolvedInput.x !== undefined &&
|
|
2201
|
+
resolvedInput.y !== undefined;
|
|
2202
|
+
const frontWindow = pickFrontWindow(stateForRect?.windows, activeAppId);
|
|
2203
|
+
if (
|
|
2204
|
+
isClickWithPoint &&
|
|
2205
|
+
frontWindow &&
|
|
2206
|
+
primaryDisplay &&
|
|
2207
|
+
screenW > 0 &&
|
|
2208
|
+
screenH > 0
|
|
2209
|
+
) {
|
|
2210
|
+
const clickRx = resolvedInput.x as number; // 0-1000 相对坐标
|
|
2211
|
+
const clickRy = resolvedInput.y as number;
|
|
2212
|
+
// 前台窗口边界(逻辑像素 → 0-1000 相对坐标)
|
|
2213
|
+
const winRatio = windowBoundsToRatio(frontWindow.bounds, primaryDisplay);
|
|
2214
|
+
const winRxLeft = Math.round(winRatio.left);
|
|
2215
|
+
const winRyTop = Math.round(winRatio.top);
|
|
2216
|
+
const winRxRight = Math.round(winRatio.right);
|
|
2217
|
+
const winRyBottom = Math.round(winRatio.bottom);
|
|
2218
|
+
// 判断是否在窗口内(留一点容差)
|
|
2219
|
+
const margin = 5; // 0-1000 相对坐标容差
|
|
2220
|
+
const inWindow =
|
|
2221
|
+
clickRx >= winRxLeft - margin &&
|
|
2222
|
+
clickRx <= winRxRight + margin &&
|
|
2223
|
+
clickRy >= winRyTop - margin &&
|
|
2224
|
+
clickRy <= winRyBottom + margin;
|
|
2225
|
+
if (!inWindow) {
|
|
2226
|
+
const actionName =
|
|
2227
|
+
resolvedInput.action === 'scroll' ? '滚动坐标' : '点击坐标';
|
|
2228
|
+
actionFeedback.push(
|
|
2229
|
+
`⚠️ ${actionName} (${Math.round(clickRx)}, ${Math.round(clickRy)}) 不在当前前台应用「${activeAppId}」的窗口范围内(窗口相对边界 x:${winRxLeft}-${winRxRight}, y:${winRyTop}-${winRyBottom}),可能作用到了别的应用或桌面。请仔细核对截图中的位置。`,
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// 组装图与文本摘要。
|
|
2235
|
+
//
|
|
2236
|
+
// `detail` 不用 'low':那是 WebBrowser 的设定,浏览器有 DOM 兜底、截图只是
|
|
2237
|
+
// 核验;桌面这边截图是**唯一**信息通道,而 OpenAI 系的 'low' 固定
|
|
2238
|
+
// 512×512 / 85tok,屏幕上的字根本读不出来。
|
|
2239
|
+
const hasBeforeAfter =
|
|
2240
|
+
isMutatingComputerUseAction(resolvedInput.action) &&
|
|
2241
|
+
response.beforeState?.screenshot &&
|
|
2242
|
+
response.state?.screenshot;
|
|
2243
|
+
|
|
2244
|
+
const beforeAssembled = hasBeforeAfter
|
|
2245
|
+
? await buildObservationImages(response.beforeState!)
|
|
2246
|
+
: null;
|
|
2247
|
+
|
|
2248
|
+
// 放大镜只在 observe 上供货:它的成本是**额外一张图**,而写类动作本来就已经
|
|
2249
|
+
// 附了「操作前+操作后」两张。三张图一起发既贵又容易让模型看串。
|
|
2250
|
+
// 想读小字就单独 observe 一次带 magnify —— 那一步的语义本来就是「我要看清楚」。
|
|
2251
|
+
const magnifyRect =
|
|
2252
|
+
resolvedInput.action === 'observe'
|
|
2253
|
+
? _resolveMagnifyRect(
|
|
2254
|
+
resolvedInput.magnify,
|
|
2255
|
+
resolvedInput.magnifyAnchor,
|
|
2256
|
+
stateForRect ?? computerUseService.lastObservation(run),
|
|
2257
|
+
)
|
|
2258
|
+
: undefined;
|
|
2259
|
+
const assembled = stateForRect
|
|
2260
|
+
? await buildObservationImages(stateForRect, response.actionMarker, magnifyRect)
|
|
2261
|
+
: null;
|
|
2262
|
+
if (assembled?.axIdMap) {
|
|
2263
|
+
computerUseService.updateAxIdMap(run, assembled.axIdMap);
|
|
2264
|
+
}
|
|
2265
|
+
const guidance = stateForRect
|
|
2266
|
+
? await describeAppGuidance(stateForRect, run)
|
|
2267
|
+
: undefined;
|
|
2268
|
+
|
|
2269
|
+
const beforeFocus = response.beforeState
|
|
2270
|
+
? deriveFocusSnapshot(response.beforeState)
|
|
2271
|
+
: null;
|
|
2272
|
+
const afterFocus = response.focus ?? null;
|
|
2273
|
+
const focusChangeText =
|
|
2274
|
+
hasBeforeAfter && beforeFocus && afterFocus
|
|
2275
|
+
? describeFocusChange(beforeFocus, afterFocus)
|
|
2276
|
+
: '';
|
|
2277
|
+
|
|
2278
|
+
const allImages = [
|
|
2279
|
+
...(beforeAssembled?.images ?? []),
|
|
2280
|
+
// repeat 的中间帧按发生顺序插在中间:遍历类任务要读的**正是中间过程**,
|
|
2281
|
+
// 只给首尾等于把翻过的内容全丢了。最后一张仍是 assembled(当前状态)。
|
|
2282
|
+
...repeatFrames,
|
|
2283
|
+
...(assembled?.images ?? []),
|
|
2284
|
+
];
|
|
2285
|
+
const combinedNote = hasBeforeAfter
|
|
2286
|
+
? [
|
|
2287
|
+
`以上 ${allImages.length} 张图按时间顺序排列:`,
|
|
2288
|
+
' 第 1 张 = 操作前截图(动作发生前的界面)',
|
|
2289
|
+
' 第 2 张 = 操作后截图(动作发生后的界面,红色十字是点击位置)',
|
|
2290
|
+
'',
|
|
2291
|
+
'判断操作效果时,直接对比两张图的差异,不要只看一张图凭记忆猜。',
|
|
2292
|
+
'',
|
|
2293
|
+
...(assembled?.note ? [assembled.note] : []),
|
|
2294
|
+
].join('\n')
|
|
2295
|
+
: (assembled?.note ?? '');
|
|
2296
|
+
|
|
2297
|
+
// 屏幕变化检测(比"稳态"更能反映操作是否真的生效)
|
|
2298
|
+
//
|
|
2299
|
+
// ⚠️ **优先看目标区域(ROI),全屏只作回落。** 这里以前只读全屏 `changeRatio`,
|
|
2300
|
+
// 于是在微信这类界面上永远走「✅ 界面有变化」那一支 —— 它的会话列表、未读角标、
|
|
2301
|
+
// 时间戳几乎一直在动,全屏变化率恒超 1%。后果是下面那条「滚动没生效,改用 PageUp」
|
|
2302
|
+
// 的建议**从来没有触发过**:实测一次读聊天记录的任务里连滚 8 次、聊天区一行没动,
|
|
2303
|
+
// 模型每次都收到「操作似乎生效了」,最后只好自己去拖那条隐藏的细滚动条 ——
|
|
2304
|
+
// 拖偏就是在聊天区框选文本,属于危险误操作。
|
|
2305
|
+
//
|
|
2306
|
+
// ⚠️⚠️ **`movedRatio` 必须排在两个 settle 通道之前** —— 它们回答的不是同一个问题:
|
|
2307
|
+
// · movedRatio = 动作前抓的基线 vs 动作后的画面 → 「内容变了没有」★这才是问题本身
|
|
2308
|
+
// · regionChange/change = settle 期间的帧间抖动 → 「画面还在不在动」
|
|
2309
|
+
// 翻页/切页这类**瞬时**变化,动画在测量时早已停住 → settle 恒为 0.0。
|
|
2310
|
+
// 之前这里只读 settle,于是每按一次方向键,模型都收到
|
|
2311
|
+
// 「⚠️ 操作后界面变化很小(变化率 0.0%),请仔细核对是否达到预期效果」。
|
|
2312
|
+
// 那**不是缺证据,是一条错误的否定证据**:实测同一时刻书从 15/106 翻到了 21/106,
|
|
2313
|
+
// 按键一直是好使的。模型据此反复怀疑、重试、最后跑去乱点碰运气 —— 全是这句话害的。
|
|
2314
|
+
// (同一个坑在 isAtScrollEdge 上修过一次,漏了模型真正读的这条消息。)
|
|
2315
|
+
const movedRatio = actionChangeRatio(response.data, 'movedRatio');
|
|
2316
|
+
const roiRatio = actionChangeRatio(response.data, 'regionChangeRatio');
|
|
2317
|
+
const fullRatio = actionChangeRatio(response.data, 'changeRatio');
|
|
2318
|
+
const changeRatio = movedRatio ?? roiRatio ?? fullRatio;
|
|
2319
|
+
const ratioScope =
|
|
2320
|
+
movedRatio !== undefined
|
|
2321
|
+
? '画面前后对比'
|
|
2322
|
+
: roiRatio !== undefined
|
|
2323
|
+
? '目标区域'
|
|
2324
|
+
: '全屏';
|
|
2325
|
+
let changeWarning: string | null = null;
|
|
2326
|
+
if (changeRatio !== undefined) {
|
|
2327
|
+
const changePercent = (changeRatio * 100).toFixed(1);
|
|
2328
|
+
if (changeRatio > 0.01) {
|
|
2329
|
+
changeWarning = `✅ ${ratioScope}有变化(变化率约 ${changePercent}%),操作似乎生效了`;
|
|
2330
|
+
} else {
|
|
2331
|
+
if (resolvedInput.action === 'scroll') {
|
|
2332
|
+
changeWarning = `⚠️ 滚动后${ratioScope}几乎没变化(变化率 ${changePercent}%)——这一路没生效。\n`
|
|
2333
|
+
+ `请改用「点一下内容区中心 → key: pageup / pagedown」翻页,键盘事件走焦点控件,不依赖鼠标位置。\n`
|
|
2334
|
+
+ `⛔ 不要加大滚动距离重试,也**绝对不要用 drag 去拖滚动条**:滚动条通常是隐藏的细条,`
|
|
2335
|
+
+ `拖偏就落到内容区上,在聊天/文档里那是框选文本,不是滚动。`;
|
|
2336
|
+
} else if (
|
|
2337
|
+
['click', 'double_click', 'right_click', 'middle_click'].includes(
|
|
2338
|
+
resolvedInput.action,
|
|
2339
|
+
)
|
|
2340
|
+
) {
|
|
2341
|
+
changeWarning = `⚠️ 点击后界面无变化(变化率 ${changePercent}%),可能点在了空白区域或未点中目标。请根据截图仔细观察界面实际状态,重新定位后再操作。`;
|
|
2342
|
+
} else {
|
|
2343
|
+
changeWarning = `⚠️ 操作后界面变化很小(变化率 ${changePercent}%),请仔细核对是否达到预期效果。`;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
const lines = [
|
|
2349
|
+
...(response.focus ? [`★ ${describeFocus(response.focus)}`] : []),
|
|
2350
|
+
...(focusChangeText ? [`📊 ${focusChangeText}`] : []),
|
|
2351
|
+
...(textTargetNotice ? [textTargetNotice] : []),
|
|
2352
|
+
...actionFeedback,
|
|
2353
|
+
...(changeWarning ? [changeWarning] : []),
|
|
2354
|
+
...(response.settle
|
|
2355
|
+
? [
|
|
2356
|
+
`画面稳定度:${response.settle.settled ? '已稳定' : '仍在变化中'}${response.settle.reason ? ` · ${response.settle.reason}` : ''}`,
|
|
2357
|
+
]
|
|
2358
|
+
: []),
|
|
2359
|
+
...(hasBeforeAfter
|
|
2360
|
+
? ['🖼️ 已附上操作前+操作后两张截图,直接对比判断效果。']
|
|
2361
|
+
: []),
|
|
2362
|
+
...(assembled?.summary ?? []),
|
|
2363
|
+
...(stateForRect?.windows?.length &&
|
|
2364
|
+
primaryDisplay &&
|
|
2365
|
+
screenW > 0 &&
|
|
2366
|
+
screenH > 0
|
|
2367
|
+
? [
|
|
2368
|
+
'窗口列表(所有可见窗口,坐标为「(逻辑像素) [0-1000相对坐标]」,★=当前前台窗口):' +
|
|
2369
|
+
stateForRect.windows
|
|
2370
|
+
.filter((w) => !w.minimized)
|
|
2371
|
+
.slice(0, 10)
|
|
2372
|
+
.map((w) => {
|
|
2373
|
+
const b = w.bounds;
|
|
2374
|
+
const label = w.title
|
|
2375
|
+
? `「${w.title}」`
|
|
2376
|
+
: `[${w.id.split(':').pop() ?? '?'}]`;
|
|
2377
|
+
const r = windowBoundsToRatio(b, primaryDisplay);
|
|
2378
|
+
const rx1 = Math.round(r.left);
|
|
2379
|
+
const ry1 = Math.round(r.top);
|
|
2380
|
+
const rx2 = Math.round(r.right);
|
|
2381
|
+
const ry2 = Math.round(r.bottom);
|
|
2382
|
+
const isFront = w.appId === activeAppId;
|
|
2383
|
+
return `${isFront ? '★' : ''}${label}(${b.x},${b.y})-(${b.x + b.width},${b.y + b.height}) [${rx1},${ry1}]-[${rx2},${ry2}] ${b.width}×${b.height}`;
|
|
2384
|
+
})
|
|
2385
|
+
.join(';'),
|
|
2386
|
+
]
|
|
2387
|
+
: stateForRect?.windows?.length
|
|
2388
|
+
? [
|
|
2389
|
+
'窗口列表(所有可见窗口):' +
|
|
2390
|
+
stateForRect.windows
|
|
2391
|
+
.filter((w) => !w.minimized)
|
|
2392
|
+
.slice(0, 10)
|
|
2393
|
+
.map((w) => {
|
|
2394
|
+
const b = w.bounds;
|
|
2395
|
+
const label = w.title
|
|
2396
|
+
? `「${w.title}」`
|
|
2397
|
+
: `[${w.id.split(':').pop() ?? '?'}]`;
|
|
2398
|
+
return `${label}(${b.x},${b.y})-(${b.x + b.width},${b.y + b.height}) ${b.width}×${b.height}`;
|
|
2399
|
+
})
|
|
2400
|
+
.join(';'),
|
|
2401
|
+
]
|
|
2402
|
+
: []),
|
|
2403
|
+
...(stateForRect?.accessibility?.truncated
|
|
2404
|
+
? [
|
|
2405
|
+
'⚠️ 无障碍树不完整(权限不足或元素过多),控件信息可能缺失,别把「没看到」当成「不存在」。',
|
|
2406
|
+
]
|
|
2407
|
+
: []),
|
|
2408
|
+
guidance,
|
|
2409
|
+
response.content ?? '',
|
|
2410
|
+
].filter(Boolean);
|
|
2411
|
+
|
|
2412
|
+
// repeat 的实际次数必须如实汇报:模型要用它判断「还要不要再翻」。
|
|
2413
|
+
// 报「按了 N 次」而实际只按了 3 次就到底了,会让它以为内容真的只有这么多。
|
|
2414
|
+
if (repeatTimes > 1) {
|
|
2415
|
+
// 诊断字段:这次排查 repeat 是否生效只能靠「一个 key 动作带回 7 张图」反推 ——
|
|
2416
|
+
// 真实执行次数与边界判定结果必须落进 trace,否则下次还得猜。
|
|
2417
|
+
// ⚠️ 恢复路径以前从这里之前就 return 掉了,trace 里连 repeat 字段都没有 ——
|
|
2418
|
+
// 我正是靠「这一步的诊断字段比别的少 10 个」才反推出批量被腰斩。
|
|
2419
|
+
// 现在恢复路径汇回主流程,这段对它同样生效。
|
|
2420
|
+
extra.repeat = repeatDone;
|
|
2421
|
+
extra.repeatRequested = repeatTimes;
|
|
2422
|
+
extra.repeatEdge = repeatStoppedAtEdge
|
|
2423
|
+
? 'at-edge'
|
|
2424
|
+
: repeatEdgeUnknown
|
|
2425
|
+
? 'unknown'
|
|
2426
|
+
: 'moving';
|
|
2427
|
+
// 三态都要如实说。把「没测出来」讲成「还能翻」,模型会拿一个我们没有的
|
|
2428
|
+
// 确认当依据;讲成「到底了」则会让它提前收工 —— 实测出现过后者:
|
|
2429
|
+
// 模型靠肉眼判定「只有 6 条」,而截图证明视图还停在底部。
|
|
2430
|
+
actionFeedback.push(
|
|
2431
|
+
repeatStoppedAtEdge
|
|
2432
|
+
? `repeat:请求 ${repeatTimes} 次,实际执行 ${repeatDone} 次后**目标区域连续 `
|
|
2433
|
+
+ `${EDGE_CONFIRM_ROUNDS} 次没有变化**,判定已到边界,剩余次数未执行。`
|
|
2434
|
+
+ `已经翻到头了,不要再往同方向重复。`
|
|
2435
|
+
: repeatEdgeUnknown
|
|
2436
|
+
? `repeat:已连续执行 ${repeatDone}/${repeatTimes} 次,随附每次之后的画面(按时间顺序)。`
|
|
2437
|
+
+ `⚠️ 本次**未能测得目标区域的变化率**,因此**无法判断是否已到边界** —— `
|
|
2438
|
+
+ `这是工具的限制,不代表已经到底。要下「没有更多内容」的结论,`
|
|
2439
|
+
+ `必须自己拿出证据(比如再翻一次、对比画面确认内容不再变化)。`
|
|
2440
|
+
: `repeat:已连续执行 ${repeatDone}/${repeatTimes} 次,随附每次之后的画面(按时间顺序)。`
|
|
2441
|
+
+ `目标区域仍在变化,说明还没到边界。`,
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// movedRatio 无条件记进诊断(不只在 repeat 时):它是「动作有没有真的推动内容」
|
|
2446
|
+
// 的唯一确定性证据,单发动作同样需要它才排查得动。这次修 bug 就吃过亏 ——
|
|
2447
|
+
// repeat 是否生效只能靠「一个动作带回 7 张图」反推。
|
|
2448
|
+
const movedForTrace = actionChangeRatio(response.data, 'movedRatio');
|
|
2449
|
+
if (movedForTrace !== undefined) extra.movedRatio = movedForTrace;
|
|
2450
|
+
|
|
2451
|
+
extra.axNodes = stateForRect ? countAxNodes(stateForRect) : 0;
|
|
2452
|
+
extra.axTruncated = stateForRect?.accessibility?.truncated === true;
|
|
2453
|
+
extra.frames = stateForRect?.sequence?.length ?? 0;
|
|
2454
|
+
if (assembled) extra.estTokens = assembled.estimatedTokens;
|
|
2455
|
+
if (stateForRect?.backend) extra.backend = stateForRect.backend;
|
|
2456
|
+
|
|
2457
|
+
return {
|
|
2458
|
+
ok: true,
|
|
2459
|
+
content: lines.join('\n'),
|
|
2460
|
+
...(allImages.length
|
|
2461
|
+
? { visionImages: allImages, visionNote: combinedNote }
|
|
2462
|
+
: {}),
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/** Exported for the input-path regression matrix. */
|
|
2467
|
+
export function shouldUseClipboardPaste(text: string): boolean {
|
|
2468
|
+
return text.length > 50 || /[^\x00-\x7F]/.test(text);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** 无障碍树节点数。遥测用 —— 只数个数,不看内容。 */
|
|
2472
|
+
function countAxNodes(state: ComputerState): number {
|
|
2473
|
+
const walk = (node: AccessibilityNode | undefined): number => {
|
|
2474
|
+
if (!node) return 0;
|
|
2475
|
+
let total = 1;
|
|
2476
|
+
for (const child of node.children ?? []) total += walk(child);
|
|
2477
|
+
return total;
|
|
2478
|
+
};
|
|
2479
|
+
return walk(state.accessibility?.root);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* 把原生 settle 的时间线翻译成模型看得懂的一句话。
|
|
2484
|
+
*
|
|
2485
|
+
* 判据分级走 `vision/settle.ts::evaluateSettle` —— 它是 R1 重构的产物,
|
|
2486
|
+
* 核心是「ROI 像素为主判据、焦点只作增强」,且 L2 拿不到时**必须如实报
|
|
2487
|
+
* `pixel-only`**,不能让模型把「没检测到焦点变化」当成「焦点没变」。
|
|
2488
|
+
*
|
|
2489
|
+
* 当前协议不携带焦点快照,所以这里恒为 pixel-only —— 这不是缺陷而是事实,
|
|
2490
|
+
* 而 evaluateSettle 的设计正是为了让这个事实能被说出来。
|
|
2491
|
+
*/
|
|
2492
|
+
function describeSettle(data: unknown): string {
|
|
2493
|
+
const payload = (data ?? {}) as {
|
|
2494
|
+
timeline?: { t: number; ratio: number }[];
|
|
2495
|
+
changedRegions?: { x: number; y: number; width: number; height: number }[];
|
|
2496
|
+
settled?: boolean;
|
|
2497
|
+
stableMs?: number;
|
|
2498
|
+
reason?: string;
|
|
2499
|
+
};
|
|
2500
|
+
const timeline = Array.isArray(payload.timeline) ? payload.timeline : [];
|
|
2501
|
+
if (timeline.length === 0) {
|
|
2502
|
+
return '未采到任何帧,无法判断界面是否稳定。别把这当成「已稳定」。';
|
|
2503
|
+
}
|
|
2504
|
+
const now = timeline[timeline.length - 1]!.t;
|
|
2505
|
+
const verdict = evaluateSettle({ timeline, now });
|
|
2506
|
+
const regions = payload.changedRegions ?? [];
|
|
2507
|
+
return [
|
|
2508
|
+
verdict.reason,
|
|
2509
|
+
`采样 ${timeline.length} 次 · 判据强度 ${verdict.confidence}`,
|
|
2510
|
+
regions.length > 0
|
|
2511
|
+
? `变化区(相对坐标):${regions.map((r) => `(${Math.round(r.x)},${Math.round(r.y)})-(${Math.round(r.x + r.width)},${Math.round(r.y + r.height)})`).join('、')}`
|
|
2512
|
+
: '未检出变化区。',
|
|
2513
|
+
].join('\n');
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
/** 从守护进程的 replay 响应里取出帧数组,形状不对就当没有帧。 */
|
|
2517
|
+
function extractReplayFrames(data: unknown): ReplayFrame[] {
|
|
2518
|
+
if (!data || typeof data !== 'object') return [];
|
|
2519
|
+
const frames = (data as { frames?: unknown }).frames;
|
|
2520
|
+
if (!Array.isArray(frames)) return [];
|
|
2521
|
+
return frames.flatMap((frame) => {
|
|
2522
|
+
if (!frame || typeof frame !== 'object') return [];
|
|
2523
|
+
const f = frame as Record<string, unknown>;
|
|
2524
|
+
return typeof f.width === 'number' && typeof f.height === 'number'
|
|
2525
|
+
? [
|
|
2526
|
+
{
|
|
2527
|
+
capturedAtMs:
|
|
2528
|
+
typeof f.capturedAtMs === 'number' ? f.capturedAtMs : 0,
|
|
2529
|
+
width: f.width,
|
|
2530
|
+
height: f.height,
|
|
2531
|
+
...(typeof f.grayBase64 === 'string'
|
|
2532
|
+
? { grayBase64: f.grayBase64 }
|
|
2533
|
+
: {}),
|
|
2534
|
+
},
|
|
2535
|
+
]
|
|
2536
|
+
: [];
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
/**
|
|
2541
|
+
* ★ 对齐 UI-TARS:永远返回全屏截图,不再自动裁剪。
|
|
2542
|
+
*
|
|
2543
|
+
* 裁剪图会造成"坐标错觉"——模型在裁剪图内看到目标,
|
|
2544
|
+
* 却需要计算整屏坐标,容易算错偏移。UI-TARS 只给全屏,
|
|
2545
|
+
* 截图自动缩放到 MAX_PIXELS(~100万像素),模型直接看图定位,坐标一一对应。
|
|
2546
|
+
*
|
|
2547
|
+
* 保留此函数导出是为了显式表达"永远全屏"的语义,供测试和调用方确认这一行为。
|
|
2548
|
+
*/
|
|
2549
|
+
export function imageRect(_input: ComputerUseInput) {
|
|
2550
|
+
return undefined;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* 不可逆动作的准入:命中 → 定类别 → 问人 → 批准后本回合有效。
|
|
2555
|
+
*
|
|
2556
|
+
* ★★ **动作类别的三级来源,优先级从高到低**(这是本次修复的核心):
|
|
2557
|
+
*
|
|
2558
|
+
* 1. **引擎自主推导** —— `matchIrreversible` 的 `key:` / `region:` 模式命中。
|
|
2559
|
+
* 按了哪个键、点在哪个矩形里都是引擎手里的事实,不需要模型配合,因此最可信,
|
|
2560
|
+
* 必须排在最前面。(旧实现先试硬编码的 `intent:submit` / `window:close`,
|
|
2561
|
+
* 于是模型只要报了 submit,说明书里可验证的那一条就永远轮不到 —— 拿可验证
|
|
2562
|
+
* 的判据去让位给自报,方向是反的。)
|
|
2563
|
+
* 2. **模型自报的 `actionClass`** —— 且**必须出现在当前应用说明书的
|
|
2564
|
+
* `irreversible:` 清单里**。不在清单里就忽略这条声明、降级到第 3 级,
|
|
2565
|
+
* 并把「你报的类别不在该应用说明书中」写回给模型。
|
|
2566
|
+
* 3. **回落** —— `intent:submit`(模型自报提交)或 `window:close`(引擎判定)。
|
|
2567
|
+
*
|
|
2568
|
+
* ⚠️ 安全不变量:伪造的、拼错的、跨应用的 `actionClass` **拿不到更窄的授权,也
|
|
2569
|
+
* 绕不过通用确认** —— 它们一律落到第 3 级的 `intent:submit`,走完整审批。所需
|
|
2570
|
+
* 能力(`requiredCapability`)完全不看 `actionClass`,能力提升仍只由
|
|
2571
|
+
* `intent:'submit'` 驱动。换句话说,自报类别只能让审批**更精确**,不能让它更松。
|
|
2572
|
+
*
|
|
2573
|
+
* 有效类别决定两件事:写进 grant 的 `actionClass`(授权范围),以及去说明书里
|
|
2574
|
+
* 挑哪些步骤的前置条件来验(`unmetPreconditionsFor`)。
|
|
2575
|
+
*
|
|
2576
|
+
* 命中后按顺序走三层,**顺序本身就是安全设计**:
|
|
2577
|
+
* 1. `supervised !== true`(无人可确认)→ **无条件拒绝**。没人能批准的时候
|
|
2578
|
+
* 不做不可撤销的事,比「先做了再说」安全。这是最硬的一层,不经过任何询问。
|
|
2579
|
+
* 2. 本回合已批准过**同一 (应用, 动作类别)** → 放行,不重复打扰。
|
|
2580
|
+
* 3. 问真人(`askBridge`)。批准 → 写一条限定范围的 grant 后放行。
|
|
2581
|
+
*
|
|
2582
|
+
* ★ 授权范围是 **(应用, 动作类别)**,不是「本回合所有不可逆动作」。
|
|
2583
|
+
* 早先的实现批准后往 `capabilities` 里 push `'submit'`,于是「批准给张三
|
|
2584
|
+
* 发这条消息」顺带放开了同一回合里的关窗口、删除、其它应用的提交——
|
|
2585
|
+
* 用户点头的是一件事,拿到的是一类权力。收窄之后两件事可以同时成立:
|
|
2586
|
+
* 同一 (应用, 类别) 的后续动作不再逐个弹窗(一个回合里连发几条消息只问
|
|
2587
|
+
* 一次),而换了类别或换了应用仍然要单独点头。
|
|
2588
|
+
*
|
|
2589
|
+
* ★ 生命周期仍是回合级:`computerRun` 由宿主每回合新建(TUI `useChat.ts`、
|
|
2590
|
+
* Desktop `localServer.ts`),所以授权不会渗到下一回合。
|
|
2591
|
+
*
|
|
2592
|
+
* ★ 为什么敢在这里问:`requestUserChoice` 在没有交互 UI 的宿主会**自动选首项**。
|
|
2593
|
+
* 三重保险保证那不会变成静默批准:(a) 第 1 层已经拦掉所有非交互宿主
|
|
2594
|
+
* (`print.ts`/`streamJson.ts` 都传 `supervised:false`);(b) 进 ask 前查
|
|
2595
|
+
* `hasAskHandler()`;(c) 首项固定是「取消」。任一层单独成立即 fail-closed。
|
|
2596
|
+
*/
|
|
2597
|
+
async function authorizeIrreversibleAction(
|
|
2598
|
+
input: ComputerUseInput,
|
|
2599
|
+
run: NonNullable<import('../../types.js').ToolCallContext['computerRun']>,
|
|
2600
|
+
signal?: AbortSignal,
|
|
2601
|
+
): Promise<{ error?: string; authorized: boolean; notice?: string }> {
|
|
2602
|
+
if (!isMutatingComputerUseAction(input.action)) return { authorized: false };
|
|
2603
|
+
|
|
2604
|
+
const state = computerUseService.lastObservation(run);
|
|
2605
|
+
const guides = await loadAllGuides();
|
|
2606
|
+
const matched = matchGuide(guides, {
|
|
2607
|
+
id: state?.activeApp?.id,
|
|
2608
|
+
name: state?.activeApp?.name,
|
|
2609
|
+
});
|
|
2610
|
+
const declared = matched?.guide.irreversible ?? [];
|
|
2611
|
+
|
|
2612
|
+
// 第 1 级:引擎自主推导。**刻意不传 `intent`** —— 传了会让说明书里的
|
|
2613
|
+
// `intent:` 条目在这一级命中,而这一级的全部意义正是「不依赖模型说什么」。
|
|
2614
|
+
// 自报的那条路在第 2 / 3 级,位置比它低。
|
|
2615
|
+
let hit: { entry: string; why: string } | null = matchIrreversible(
|
|
2616
|
+
matched?.guide ?? null,
|
|
2617
|
+
{
|
|
2618
|
+
command: input.action,
|
|
2619
|
+
combo: input.key,
|
|
2620
|
+
...(input.x !== undefined && input.y !== undefined
|
|
2621
|
+
? { point: { x: input.x, y: input.y } }
|
|
2622
|
+
: {}),
|
|
2623
|
+
},
|
|
2624
|
+
);
|
|
2625
|
+
|
|
2626
|
+
// 第 2 级:模型自报的类别,且必须被当前说明书认领。
|
|
2627
|
+
//
|
|
2628
|
+
// 认领这一关不能省:不查清单的话,模型报什么类别就得到什么类别的 grant,
|
|
2629
|
+
// 于是它可以自造一个只有自己知道的类别名,让「同一 (应用, 类别) 不重复询问」
|
|
2630
|
+
// 变成一次点头换一整类自定义权力;跨应用的类别名也会污染别的应用的授权面。
|
|
2631
|
+
let unclaimed: string | undefined;
|
|
2632
|
+
if (!hit && input.actionClass) {
|
|
2633
|
+
if (declared.includes(input.actionClass)) {
|
|
2634
|
+
hit = {
|
|
2635
|
+
entry: input.actionClass,
|
|
2636
|
+
why: `你自报的动作类别「${input.actionClass}」在${matched!.guide.name} 说明书中被声明为不可逆`,
|
|
2637
|
+
};
|
|
2638
|
+
} else {
|
|
2639
|
+
unclaimed = input.actionClass;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// 第 3 级:回落。走到这里的自报类别一律不生效,拿到的是最宽的通用审批。
|
|
2644
|
+
if (!hit) {
|
|
2645
|
+
// ★ 全局高危黑名单必须**够得着**。
|
|
2646
|
+
//
|
|
2647
|
+
// 修的是一个真缺口:`intent:payment` / `intent:format-disk` 这类全局高危条目,
|
|
2648
|
+
// 此前**只有**在某份应用说明书恰好声明过它时才可能成为 `hit.entry`;否则模型
|
|
2649
|
+
// 自报的类别落进 `unclaimed`,`hit` 为空 → 函数返回 `{authorized:false}`
|
|
2650
|
+
// 且不带 error → 调用方视为「无需审批」**照常执行**。
|
|
2651
|
+
// 于是黑名单只在「恰好有说明书」时生效,而最危险的场景(陌生应用、
|
|
2652
|
+
// 没有说明书)恰恰是它最该生效的时候。全局黑名单本来的语义是
|
|
2653
|
+
// 「不可被应用级降级」,不该连「够不够得着」都取决于说明书。
|
|
2654
|
+
if (input.actionClass && isGlobalCriticalIntent(input.actionClass)) {
|
|
2655
|
+
hit = {
|
|
2656
|
+
entry: input.actionClass,
|
|
2657
|
+
why: '你自报的动作属于全局高危类别:后果不可撤销,且涉及财产或数据安全',
|
|
2658
|
+
};
|
|
2659
|
+
// 它不是「没被认领」——是被全局黑名单直接认领了,不该再提示类别名写错。
|
|
2660
|
+
unclaimed = undefined;
|
|
2661
|
+
} else if (input.intent === 'submit') {
|
|
2662
|
+
hit = { entry: 'intent:submit', why: '你把这一步自报为最终提交' };
|
|
2663
|
+
} else if (input.action === 'window' && input.windowOperation === 'close') {
|
|
2664
|
+
hit = {
|
|
2665
|
+
entry: 'window:close',
|
|
2666
|
+
why: '关闭窗口会丢失未保存的内容,且无法撤销',
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
if (!hit) return { authorized: false };
|
|
2671
|
+
|
|
2672
|
+
const notice = unclaimed
|
|
2673
|
+
? `⚠️ 你报的 actionClass「${unclaimed}」不在${
|
|
2674
|
+
matched
|
|
2675
|
+
? `${matched.guide.name} 说明书的 irreversible: 清单里(清单:${declared.join('、') || '空'})`
|
|
2676
|
+
: '任何已匹配的应用说明书中(当前前台应用没有匹配到说明书)'
|
|
2677
|
+
},已忽略该声明,按通用类别「${hit.entry}」处理。要拿到精确到这一类的授权与前置条件检查,请从回执里注入的清单中原样抄一个类别名。`
|
|
2678
|
+
: undefined;
|
|
2679
|
+
// 下面每条出口都要带上 notice —— 这是「模型报错了类别」这件事的唯一回传通道,
|
|
2680
|
+
// 漏在任何一条分支上,模型就会在那条路上一直用错名字而永远收不到反馈。
|
|
2681
|
+
const deny = (error: string) => ({
|
|
2682
|
+
authorized: false as const,
|
|
2683
|
+
error,
|
|
2684
|
+
...(notice ? { notice } : {}),
|
|
2685
|
+
});
|
|
2686
|
+
const allow = () => ({
|
|
2687
|
+
authorized: true as const,
|
|
2688
|
+
...(notice ? { notice } : {}),
|
|
2689
|
+
});
|
|
2690
|
+
|
|
2691
|
+
// 说明书声明的前置条件:**在问人之前**检查。
|
|
2692
|
+
//
|
|
2693
|
+
// 不该拿一个「作者已经写明前提不成立」的动作去请用户点头 —— 人在弹窗上
|
|
2694
|
+
// 看不到「聊天标题跟目标对象对不上」这种事,点了同意就真发出去了。
|
|
2695
|
+
// 这是本批把知识从 skill 搬进插件后真正多出来的**强制力**:说明书里
|
|
2696
|
+
// 写下的约束不再只是给模型看的建议。
|
|
2697
|
+
//
|
|
2698
|
+
// 查的是**有效类别**那一类的前提,不是整份说明书的所有不可逆步骤 ——
|
|
2699
|
+
// 后者是旧实现的错,理由见 `manualSchema.ts::unmetPreconditionsFor` 头注。
|
|
2700
|
+
const unmet = unmetPreconditionsFor(
|
|
2701
|
+
matched?.guide.manual ?? EMPTY_MANUAL,
|
|
2702
|
+
hit.entry,
|
|
2703
|
+
state,
|
|
2704
|
+
);
|
|
2705
|
+
if (unmet.length > 0) {
|
|
2706
|
+
const detail = unmet
|
|
2707
|
+
.map(
|
|
2708
|
+
(item) =>
|
|
2709
|
+
`「${item.flowId}」第 ${item.stepIndex} 步(${item.step})要求:${item.precondition}`,
|
|
2710
|
+
)
|
|
2711
|
+
.join(';');
|
|
2712
|
+
return deny(
|
|
2713
|
+
`这是不可逆动作(${hit.entry}:${hit.why}),但${matched!.guide.name} 说明书声明的前置条件当前**不成立**:${detail}。先把这些条件弄成立(通常是重新 observe 确认目标对象/焦点),再执行这一步。条件没满足就不会向用户发起确认 —— 用户在弹窗上看不出这类问题。`,
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// ★ 风险分级判定。
|
|
2718
|
+
//
|
|
2719
|
+
// 设计原则:黑名单制。
|
|
2720
|
+
// - critical(高危红线):全局黑名单 + 应用级 critical 列表 → 必须人工确认
|
|
2721
|
+
// - normal(一般不可逆):其余全部 → 自动放行,保留审计记录
|
|
2722
|
+
//
|
|
2723
|
+
// 这是把发消息这种操作和删库格式化区分开的关键。
|
|
2724
|
+
// 判定住在内核的 `src/irreversible/rules.ts`,与 Bash 共用同一套词汇表。
|
|
2725
|
+
// (原先传的 `guideAll` 是**死参数**——函数体从来没读过它,一并去掉。)
|
|
2726
|
+
const riskHit: RiskHit = classifyIntent({
|
|
2727
|
+
entry: hit.entry,
|
|
2728
|
+
guideCritical: matched?.guide.irreversibleCritical ?? [],
|
|
2729
|
+
why: hit.why,
|
|
2730
|
+
});
|
|
2731
|
+
|
|
2732
|
+
// normal 级:直接放行。
|
|
2733
|
+
//
|
|
2734
|
+
// 仍然写一条 grant(审计用),这样后续同一 (应用, 类别) 的动作也能命中 grant,
|
|
2735
|
+
// 保持行为一致性。
|
|
2736
|
+
if (riskHit.riskLevel === 'normal') {
|
|
2737
|
+
const appId =
|
|
2738
|
+
input.appId ?? state?.activeApp?.id ?? state?.activeApp?.name ?? '*';
|
|
2739
|
+
(run.irreversibleGrants ??= []).push({
|
|
2740
|
+
appId,
|
|
2741
|
+
actionClass: hit.entry,
|
|
2742
|
+
grantedAtMs: Date.now(),
|
|
2743
|
+
});
|
|
2744
|
+
return allow();
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// 授权范围的两个维度。appId 取当时的前台应用;拿不到就用 `'*'` ——
|
|
2748
|
+
// 那种情况下这条 grant 覆盖面更宽,但第 1 层仍要求有人在场。
|
|
2749
|
+
const appId =
|
|
2750
|
+
input.appId ?? state?.activeApp?.id ?? state?.activeApp?.name ?? '*';
|
|
2751
|
+
const actionClass = hit.entry;
|
|
2752
|
+
|
|
2753
|
+
// 第 1 层:无人可确认 → 无条件拒绝,连问都不问。
|
|
2754
|
+
if (run.supervised !== true) {
|
|
2755
|
+
return deny(
|
|
2756
|
+
`这是不可逆动作(${hit.entry}:${hit.why}),而当前运行没有可确认的人。不可撤销的操作不能在无人监督时执行 —— 请把结果告诉用户,由用户自己完成这一步。`,
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// 第 2 层:本回合已经批准过同一 (应用, 类别),不重复打扰。
|
|
2761
|
+
if (hasGrant(run, appId, actionClass)) return allow();
|
|
2762
|
+
|
|
2763
|
+
// 第 3 层:问真人。没有交互 UI 就当没批准。
|
|
2764
|
+
if (!hasAskHandler()) {
|
|
2765
|
+
return deny(
|
|
2766
|
+
`这是不可逆动作(${hit.entry}:${hit.why}),但当前宿主没有可用的确认界面,无法取得用户同意。请把你要做的事告诉用户,由用户自己完成这一步。`,
|
|
2767
|
+
);
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
const appLabel =
|
|
2771
|
+
state?.activeApp?.name ?? state?.activeApp?.id ?? '当前前台应用';
|
|
2772
|
+
const target = describeActionTarget(input);
|
|
2773
|
+
// 用户本回合的原话里根本没有不可逆意向,模型却要提交 —— 这本身就值得多看一眼。
|
|
2774
|
+
// 这也是 `looksLikeIrreversibleRequest` 的正确用法:提示,不是授权判据。
|
|
2775
|
+
const unprompted =
|
|
2776
|
+
run.userInput !== undefined && !looksLikeIrreversibleRequest(run.userInput);
|
|
2777
|
+
|
|
2778
|
+
let answers: Awaited<ReturnType<typeof requestUserChoice>>;
|
|
2779
|
+
try {
|
|
2780
|
+
answers = await requestUserChoice(
|
|
2781
|
+
[
|
|
2782
|
+
{
|
|
2783
|
+
header: '不可逆操作',
|
|
2784
|
+
question: `Agent 要在「${appLabel}」执行一个**收不回来**的操作:${target}\n判定依据:${hit.entry} —— ${hit.why}\n${
|
|
2785
|
+
unprompted
|
|
2786
|
+
? '⚠️ 你这一轮的原话里并没有要求做不可逆的事,请确认这是你想要的。\n'
|
|
2787
|
+
: ''
|
|
2788
|
+
}批准后,本回合内「${appLabel}」的同类操作(${actionClass})不再逐个询问;换一类操作或换一个应用仍会再问你一次。`,
|
|
2789
|
+
options: [
|
|
2790
|
+
{
|
|
2791
|
+
label: '取消,我自己来 (推荐)',
|
|
2792
|
+
description:
|
|
2793
|
+
'拒绝这次操作。Agent 会把该做什么告诉你,由你手动完成。',
|
|
2794
|
+
},
|
|
2795
|
+
{
|
|
2796
|
+
label: '批准,本回合内执行',
|
|
2797
|
+
description: `允许本回合内「${appLabel}」的 ${actionClass} 类操作。下一轮对话会重新询问。`,
|
|
2798
|
+
},
|
|
2799
|
+
],
|
|
2800
|
+
},
|
|
2801
|
+
],
|
|
2802
|
+
signal,
|
|
2803
|
+
);
|
|
2804
|
+
} catch {
|
|
2805
|
+
// 询问本身失败(中断、通道断了)一律按未批准处理。
|
|
2806
|
+
return deny(
|
|
2807
|
+
`这是不可逆动作(${hit.entry}:${hit.why}),向你确认的过程被中断,未获批准。`,
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
const approved =
|
|
2812
|
+
answers[0]?.selected.some((label) => label.startsWith('批准')) === true;
|
|
2813
|
+
void recordIrreversibleDecision({
|
|
2814
|
+
approved,
|
|
2815
|
+
appId,
|
|
2816
|
+
actionClass,
|
|
2817
|
+
surface: run.surface,
|
|
2818
|
+
});
|
|
2819
|
+
if (!approved) {
|
|
2820
|
+
return deny(
|
|
2821
|
+
`这是不可逆动作(${hit.entry}:${hit.why}),用户没有批准。请不要再尝试这一步,把需要用户自己完成的部分说清楚。`,
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// 批准写回本回合授权。`run` 与两处 host gate 闭包持的是同一个对象引用,
|
|
2826
|
+
// 所以这一次赋值同时对工具和 gate 生效,不必两边各记一份状态。
|
|
2827
|
+
(run.irreversibleGrants ??= []).push({
|
|
2828
|
+
appId,
|
|
2829
|
+
actionClass,
|
|
2830
|
+
grantedAtMs: Date.now(),
|
|
2831
|
+
});
|
|
2832
|
+
return allow();
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
/** 本回合是否已批准过这个 (应用, 类别)。`'*'` 的 grant 覆盖任意应用。 */
|
|
2836
|
+
function hasGrant(
|
|
2837
|
+
run: NonNullable<import('../../types.js').ToolCallContext['computerRun']>,
|
|
2838
|
+
appId: string,
|
|
2839
|
+
actionClass: string,
|
|
2840
|
+
): boolean {
|
|
2841
|
+
return (run.irreversibleGrants ?? []).some(
|
|
2842
|
+
(grant) =>
|
|
2843
|
+
grant.actionClass === actionClass &&
|
|
2844
|
+
(grant.appId === appId || grant.appId === '*'),
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/**
|
|
2849
|
+
* 把一次不可逆审批的结局落审计(fire-and-forget,不阻塞审批)。
|
|
2850
|
+
*
|
|
2851
|
+
* 落**独立的** `authorizations.jsonl`,不混进 `telemetry.jsonl`:
|
|
2852
|
+
* - 遥测有隐私白名单,`appId` 这类字段本来就会被丢掉,而审计恰恰需要它
|
|
2853
|
+
* ——「批准了哪个应用的什么」是审计的全部意义
|
|
2854
|
+
* - 遥测会按大小轮转(旧的直接丢),审计不该跟着一起被冲掉
|
|
2855
|
+
*
|
|
2856
|
+
* ⚠️ **它不是守护进程的 `audit.jsonl`。** 那个文件由 daemon 拥有并写入,
|
|
2857
|
+
* TS 侧往里追加会破坏它的所有权与格式假设。`/computeruse audit` 把两份
|
|
2858
|
+
* 并排显示,不假装它们是同一份。
|
|
2859
|
+
*/
|
|
2860
|
+
async function recordIrreversibleDecision(input: {
|
|
2861
|
+
approved: boolean;
|
|
2862
|
+
appId: string;
|
|
2863
|
+
actionClass: string;
|
|
2864
|
+
surface: string;
|
|
2865
|
+
}): Promise<void> {
|
|
2866
|
+
try {
|
|
2867
|
+
const line = `${JSON.stringify({
|
|
2868
|
+
t: Date.now(),
|
|
2869
|
+
decision: input.approved ? 'approved' : 'denied',
|
|
2870
|
+
appId: input.appId,
|
|
2871
|
+
actionClass: input.actionClass,
|
|
2872
|
+
surface: input.surface,
|
|
2873
|
+
})}\n`;
|
|
2874
|
+
const path = join(computerUseStateDir(), 'authorizations.jsonl');
|
|
2875
|
+
await mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
2876
|
+
await appendFile(path, line, { mode: 0o600 });
|
|
2877
|
+
} catch {
|
|
2878
|
+
/* 审计写不进去不能阻断已经取得的人工批准 —— 那会把「用户点了同意」
|
|
2879
|
+
变成「因为磁盘满了所以不算」。写失败是运维问题,不是授权问题。 */
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
/** 给人看的一句话动作描述。只用结构化字段,不回显任何屏幕内容。 */
|
|
2884
|
+
function describeActionTarget(input: ComputerUseInput): string {
|
|
2885
|
+
const parts = [`动作 ${input.action}`];
|
|
2886
|
+
if (input.windowOperation) parts.push(`窗口操作 ${input.windowOperation}`);
|
|
2887
|
+
if (input.key) parts.push(`按键 ${input.key}`);
|
|
2888
|
+
if (input.elementId) parts.push(`目标元素 ${input.elementId}`);
|
|
2889
|
+
else if (input.x !== undefined && input.y !== undefined) {
|
|
2890
|
+
parts.push(`坐标 (${Math.round(input.x)}, ${Math.round(input.y)})`);
|
|
2891
|
+
}
|
|
2892
|
+
// 输入的文本可能是隐私内容,只报长度不报内容。
|
|
2893
|
+
if (input.text) parts.push(`输入 ${input.text.length} 个字符`);
|
|
2894
|
+
return parts.join(' · ');
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
/**
|
|
2898
|
+
* 加载全部应用说明书。来源与优先级见 `appGuide.ts::appManualProviders`
|
|
2899
|
+
* (工作区 `app-manuals/` > cwd 插件 > 包内插件,逐文件覆盖)。
|
|
2900
|
+
*
|
|
2901
|
+
* 合并逻辑住在 appGuide 而不是这里:插件命令面要用同一份结果,两处各写一遍
|
|
2902
|
+
* 会让「哪份说明书生效」在工具与诊断命令之间给出不同答案。
|
|
2903
|
+
*/
|
|
2904
|
+
async function loadAllGuides(): Promise<AppGuide[]> {
|
|
2905
|
+
return (await loadAllAppManuals()).guides;
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/**
|
|
2909
|
+
* 当前观测对应的那份说明书(没有就 undefined)。
|
|
2910
|
+
*
|
|
2911
|
+
* 存在的唯一理由是把**应用专属知识**喂给通用判定器,而不是让通用判定器自己去
|
|
2912
|
+
* 认识某个应用 —— 内核里曾经写死过一段微信 bundleId 的判断,见
|
|
2913
|
+
* `humanBoundary.ts` 里那段注释。
|
|
2914
|
+
*/
|
|
2915
|
+
async function manualForState(
|
|
2916
|
+
state: ComputerState,
|
|
2917
|
+
): Promise<AppManual | undefined> {
|
|
2918
|
+
const matched = matchGuide(await loadAllGuides(), {
|
|
2919
|
+
id: state.activeApp?.id,
|
|
2920
|
+
name: state.activeApp?.name,
|
|
2921
|
+
});
|
|
2922
|
+
return matched?.guide.manual;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
/** 首次注入的完整摘要预算(字符)。中文约 2 字/token,故上限约 2k token。 */
|
|
2926
|
+
/**
|
|
2927
|
+
* 真正**消费坐标**的动作。只有它们才谈得上「像素坐标还是比例坐标」。
|
|
2928
|
+
*
|
|
2929
|
+
* ⚠️ 为什么需要这个集合:不少模型(GPT 系尤其明显)会把 schema 里每个字段都填上
|
|
2930
|
+
* 默认值再发过来 —— `x:0, y:0, pixelX:0, pixelY:0` 同时出现。对 `observe` 这种
|
|
2931
|
+
* 压根不看坐标的动作,拿互斥约束去拦它毫无意义,却会让整次调用失败。
|
|
2932
|
+
* 实测:Desktop 侧连 observe 都调不动就卡在这里(前一道是 `actionClass: ""`)。
|
|
2933
|
+
*/
|
|
2934
|
+
const POINTING_ACTIONS = new Set([
|
|
2935
|
+
'click', 'double_click', 'right_click', 'middle_click', 'hover',
|
|
2936
|
+
'pointer_down', 'pointer_move', 'pointer_up', 'drag', 'scroll',
|
|
2937
|
+
]);
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* 说明书摘要的注入预算。
|
|
2941
|
+
*
|
|
2942
|
+
* ⚠️ 这个数字**只在每次运行的第一次 ComputerUse 调用**花一次(之后只注入一行提醒 + 路径),
|
|
2943
|
+
* 而上下文窗口是几十万 token 级 —— 省这一点点换来的是实打实的成功率损失。
|
|
2944
|
+
*
|
|
2945
|
+
* 实测:4000 时 `wechat.md` 需要 5283 字符,降级阶梯把**全部「流程中的坑」砍掉**,
|
|
2946
|
+
* 包括「发错对象无法挽回」「输入框右下角是麦克风不是发送按钮」这些最贵的经验。
|
|
2947
|
+
* 而那些坑正是说明书存在的理由 —— 步骤列表模型自己也能猜个八九不离十,
|
|
2948
|
+
* 坑不告诉它就一定会踩。
|
|
2949
|
+
*
|
|
2950
|
+
* 所以放宽到 12000:给现有说明书留足余量,也给「实测记录不断长出来」留空间。
|
|
2951
|
+
* 降级阶梯仍在(真写爆了还是会如实告诉你丢了什么),只是不再在正常尺寸上误伤。
|
|
2952
|
+
*/
|
|
2953
|
+
const MANUAL_DIGEST_BUDGET_CHARS = 12_000;
|
|
2954
|
+
|
|
2955
|
+
/** 可以 `repeat` 的动作:效果可累加、且中间态对调用方有意义的那些。 */
|
|
2956
|
+
const REPEATABLE_ACTIONS = new Set(['key', 'scroll']);
|
|
2957
|
+
|
|
2958
|
+
/** `repeat` 的硬上限。见 inputSchema.repeat 注释。 */
|
|
2959
|
+
const MAX_REPEAT = 8;
|
|
2960
|
+
|
|
2961
|
+
/** 一次遍历后「到边界了没有」的三态。`unknown` 是有意保留的。 */
|
|
2962
|
+
type ScrollEdge = 'at-edge' | 'moving' | 'unknown';
|
|
2963
|
+
|
|
2964
|
+
/**
|
|
2965
|
+
* 遍历有没有到边界。
|
|
2966
|
+
*
|
|
2967
|
+
* ⚠️ **只认 `movedRatio`(动作前基线 vs 动作后画面),不认 `changeRatio` /
|
|
2968
|
+
* `regionChangeRatio`。** 这三个数长得像,回答的却是两个不同的问题:
|
|
2969
|
+
*
|
|
2970
|
+
* · `change/regionChangeRatio` —— 来自 `settle_timeline`,在动作后的窗口里逐帧
|
|
2971
|
+
* 对比**相邻采样**,答的是「画面**还在不在动**」;
|
|
2972
|
+
* · `movedRatio` —— 动作前抓的基线对动作后的画面,答的是「内容相对之前
|
|
2973
|
+
* **有没有移动**」。
|
|
2974
|
+
*
|
|
2975
|
+
* 曾经用前者判边界,实测是**每一次翻页都报「已到边界」**而内容还在往前翻:
|
|
2976
|
+
* `pageup` / 滚轮是瞬时跳转、没有动画,采样启动时画面早已静止,测出 0 属于正常。
|
|
2977
|
+
* 假的边界信号比没有更糟 —— 它会告诉模型「已经翻到头了」,直接诱导提前收工,
|
|
2978
|
+
* 那正是这轮要修的原始症状(「只有约 6 条」,而截图证明视图还停在底部)。
|
|
2979
|
+
*
|
|
2980
|
+
* 拿不到 `movedRatio` 时返回 `unknown` 并如实说明,不用别的数顶替。
|
|
2981
|
+
*/
|
|
2982
|
+
function isAtScrollEdge(response: { data?: unknown }): ScrollEdge {
|
|
2983
|
+
const moved = actionChangeRatio(response.data, 'movedRatio');
|
|
2984
|
+
if (moved === undefined) return 'unknown';
|
|
2985
|
+
return moved < EDGE_CHANGE_RATIO ? 'at-edge' : 'moving';
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
/** 低于这个变化率就认为「这一下什么都没翻动」。与验证器同一量级。 */
|
|
2989
|
+
const EDGE_CHANGE_RATIO = 0.001;
|
|
2990
|
+
|
|
2991
|
+
/**
|
|
2992
|
+
* 每个 run 最多自动抢回几次前台。
|
|
2993
|
+
*
|
|
2994
|
+
* 有上限是必须的:如果前台是被**用户自己**占着(他真的在用电脑),一直抢回去就是
|
|
2995
|
+
* 和用户抢鼠标。次数用完就老实停下,把情况如实报出来。
|
|
2996
|
+
*/
|
|
2997
|
+
const MAX_FOCUS_RECOVERIES = 3;
|
|
2998
|
+
const focusRecoveryCounts = new Map<string, number>();
|
|
2999
|
+
|
|
3000
|
+
/**
|
|
3001
|
+
* 焦点被抢走后就地抢回来,然后重试一次原动作。
|
|
3002
|
+
*
|
|
3003
|
+
* 只在**没有人工接管通道**(`-p` 之类的无人宿主)时走这条路:有人值守时应该让人处理,
|
|
3004
|
+
* 那才是这条闸门的本意。
|
|
3005
|
+
*
|
|
3006
|
+
* 返回 null = 没能恢复(不知道该激活哪个应用 / 激活失败 / 重试仍失败 / 次数用尽),
|
|
3007
|
+
* 调用方据此走原来的终止路径,并在错误里说明已经尝试过。
|
|
3008
|
+
*/
|
|
3009
|
+
async function recoverLostFocus(
|
|
3010
|
+
run: NonNullable<import('../../types.js').ToolCallContext['computerRun']>,
|
|
3011
|
+
requestInput: Omit<ComputerUseRequest, 'protocolVersion' | 'requestId' | 'context'>,
|
|
3012
|
+
signal?: AbortSignal,
|
|
3013
|
+
): Promise<{ response: ComputerUseResponse; note: string } | null> {
|
|
3014
|
+
const key = `${run.conversationKey}:${run.runId}`;
|
|
3015
|
+
const used = focusRecoveryCounts.get(key) ?? 0;
|
|
3016
|
+
if (used >= MAX_FOCUS_RECOVERIES) return null;
|
|
3017
|
+
|
|
3018
|
+
// 目标应用:优先用本次动作显式声明的,否则用上一次观测里的前台应用 ——
|
|
3019
|
+
// 那正是焦点被抢走**之前**我们在操作的那个。
|
|
3020
|
+
const targetApp =
|
|
3021
|
+
requestInput.appId ?? computerUseService.lastObservation(run)?.activeApp?.id;
|
|
3022
|
+
if (!targetApp) return null;
|
|
3023
|
+
focusRecoveryCounts.set(key, used + 1);
|
|
3024
|
+
|
|
3025
|
+
const activated = await computerUseService.executeVerifiedAction(
|
|
3026
|
+
run,
|
|
3027
|
+
{ action: 'activate_app', appId: targetApp } as Omit<
|
|
3028
|
+
ComputerUseRequest,
|
|
3029
|
+
'protocolVersion' | 'requestId' | 'context'
|
|
3030
|
+
>,
|
|
3031
|
+
signal,
|
|
3032
|
+
);
|
|
3033
|
+
if (!activated.ok) return null;
|
|
3034
|
+
|
|
3035
|
+
// 重新激活会让 epoch 前进,原来那份 expectedEpoch 已经过期 —— 必须换成最新的,
|
|
3036
|
+
// 否则重试必然撞 stale_state(换个说法失败,等于没恢复)。
|
|
3037
|
+
const freshEpoch = computerUseService.lastObservation(run)?.epoch;
|
|
3038
|
+
const retried = await computerUseService.executeVerifiedAction(
|
|
3039
|
+
run,
|
|
3040
|
+
{
|
|
3041
|
+
...requestInput,
|
|
3042
|
+
...(freshEpoch !== undefined ? { expectedEpoch: freshEpoch } : {}),
|
|
3043
|
+
},
|
|
3044
|
+
signal,
|
|
3045
|
+
);
|
|
3046
|
+
if (!retried.ok || !retried.state) return null;
|
|
3047
|
+
|
|
3048
|
+
// ⚠️ **返回「恢复后的响应」,不要在这里拼成品 ToolResult。**
|
|
3049
|
+
// 此前这里直接返回 ToolResult 并被调用方 `return` 掉,于是恢复路径成了一条
|
|
3050
|
+
// 并行的早返回 —— 拿不到批量剩余次数的补跑、拿不到「repeat:M/N 次」文本、
|
|
3051
|
+
// 拿不到 extra 诊断与 movedRatio。实测代价:repeat:8 静默塌缩成 1 次。
|
|
3052
|
+
// 现在交回响应,由调用方汇进主流程,上面那些一次都不落。
|
|
3053
|
+
return {
|
|
3054
|
+
response: retried,
|
|
3055
|
+
note:
|
|
3056
|
+
`⚠️ 前台曾被别的窗口抢走,已自动重新激活「${targetApp}」并重试了这个动作`
|
|
3057
|
+
+ `(本次任务第 ${used + 1}/${MAX_FOCUS_RECOVERIES} 次自动恢复)。`
|
|
3058
|
+
+ '请对照画面确认动作确实落在了目标应用上;'
|
|
3059
|
+
+ '如果反复出现,说明有别的程序在抢前台,应如实告诉用户而不是继续硬抢。',
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
/**
|
|
3064
|
+
* 连续这么多次「目标区域没动」才判定到边界。
|
|
3065
|
+
*
|
|
3066
|
+
* 取 2 而不是 1:单次没动完全可能是动画还没渲染完(尤其带惯性滚动的列表),
|
|
3067
|
+
* 按 1 次就收手会在内容还没翻完时提前停下 —— 而提前停下的表现是「内容只有这么多」,
|
|
3068
|
+
* 一个看起来像事实的错误结论。
|
|
3069
|
+
*/
|
|
3070
|
+
const EDGE_CONFIRM_ROUNDS = 2;
|
|
3071
|
+
|
|
3072
|
+
/**
|
|
3073
|
+
* 跨应用通用的交互模式,随应用说明书走**同一条注入通道**。
|
|
3074
|
+
*
|
|
3075
|
+
* ⚠️ 为什么必须走这条通道,而不是写进工具描述:**同样的内容,通道不同,结果相反。**
|
|
3076
|
+
* 实测过一次 —— 把「翻页阶梯」写进工具描述,模型跑一整轮**一次都没照做**,反而去
|
|
3077
|
+
* 拖那条隐藏的滚动条(拖偏就是在聊天区框选文本);把同样几句写进应用说明书的 flow,
|
|
3078
|
+
* 模型立刻照做,14 次 pageup 翻到了三天前的记录。差别只在于说明书**每次观测都注入**,
|
|
3079
|
+
* 而工具描述只在开头出现一次 —— 跑到第 10 步时早被淹没了。
|
|
3080
|
+
*
|
|
3081
|
+
* ⚠️ 为什么放在引擎里,而不是放进 `plugins/computer_use/app/` 当数据:
|
|
3082
|
+
* 这里写的是**本工具自身的行为**(滚轮事件投递给鼠标所在视图、变化率报的是目标区域
|
|
3083
|
+
* 还是全屏、哪些动作会被验证)。它必须和实现绑在一起,作为插件数据会各自漂移 ——
|
|
3084
|
+
* 而一份和实现对不上的说明书比没有更糟。应用特有的事实仍然留在各自的 app 手册里。
|
|
3085
|
+
*
|
|
3086
|
+
* ⚠️ 只写**有实测支撑**的条目。宁可少,也不写一大本没验证过的通则 —— 那正是
|
|
3087
|
+
* 「说明书写得像真的、模型照做却撞墙」的来源。目前只有滚动区这一节够格。
|
|
3088
|
+
*/
|
|
3089
|
+
const GENERIC_INTERACTION_GUIDE = [
|
|
3090
|
+
'【通用交互模式】(跨应用成立,与具体应用无关)',
|
|
3091
|
+
'',
|
|
3092
|
+
'▸ 翻阅长列表 / 聊天记录 / 文档等可滚动区域,按这个顺序:',
|
|
3093
|
+
...TRAVERSAL_GUIDE_LINES,
|
|
3094
|
+
'',
|
|
3095
|
+
'▸ 判断「翻动了没有」只能靠**对比截图内容**,不能靠动作返回成功:',
|
|
3096
|
+
' 自绘界面(Electron / Qt / 跨端客户端)无障碍树为空,动作层面永远是"成功"的。',
|
|
3097
|
+
'',
|
|
3098
|
+
'▸ **「到顶了 / 没有更多了」是一个需要证据的结论,不能靠一眼看。**',
|
|
3099
|
+
' 可接受的证据只有两种:① 回执明确报告「已到边界」;② 你连续两次翻动后对比画面,',
|
|
3100
|
+
' 内容确实没有变化。除此之外一律按「还没到底」处理。',
|
|
3101
|
+
' 回执说「未能判断是否已到边界」时,那是工具的限制 —— **不要替它下结论**。',
|
|
3102
|
+
'',
|
|
3103
|
+
'▸ 目标数量达不到时(比如要 20 条只看到 6 条),如实报告「只找到 N 条」并说明依据:',
|
|
3104
|
+
' 是已经翻到边界,还是没能继续翻。**不要把「我没找到更多」讲成「只有这么多」** ——',
|
|
3105
|
+
' 这两句话对用户的意义完全不同,而后者是一个看起来像事实的错误结论。',
|
|
3106
|
+
].join('\n');
|
|
3107
|
+
|
|
3108
|
+
/**
|
|
3109
|
+
* 把对应应用的说明书**注入**观测回执。
|
|
3110
|
+
*
|
|
3111
|
+
* ★ 这里曾经只是「指路」:回执写一句「已找到应用指南:<path>,先 Read 它」,
|
|
3112
|
+
* 然后指望模型照做。那正是把 skill 变成插件要解决的问题 —— 模型可以不读,
|
|
3113
|
+
* 引擎也不知道它读没读,于是所有沉淀下来的事实都是「可参考可不参考」。
|
|
3114
|
+
*
|
|
3115
|
+
* 现在改成引擎直接把结构化内容写进回执:模型没有「不看」这个选项。
|
|
3116
|
+
*
|
|
3117
|
+
* 成本由两件事控住:
|
|
3118
|
+
* ① **本运行内首次命中才发完整摘要**,之后只剩一行提醒 + 路径
|
|
3119
|
+
* (`shouldInjectFullManual`,状态住在服务里)
|
|
3120
|
+
* ② 完整摘要有字符预算与确定性降级阶梯,且丢了什么会如实说
|
|
3121
|
+
* (`renderManualDigest`,语义抄 skillList 的那套)
|
|
3122
|
+
*
|
|
3123
|
+
* 匹配不上时**如实说「没找到」**并列出已有的 —— 静默降级会让模型以为自己
|
|
3124
|
+
* 有该应用的知识、实际在裸奔,而没人知道。
|
|
3125
|
+
*/
|
|
3126
|
+
async function describeAppGuidance(
|
|
3127
|
+
state: ComputerState,
|
|
3128
|
+
run: NonNullable<import('../../types.js').ToolCallContext['computerRun']>,
|
|
3129
|
+
): Promise<string> {
|
|
3130
|
+
const { guides, rejected } = await loadAllAppManuals();
|
|
3131
|
+
const identity = { id: state.activeApp?.id, name: state.activeApp?.name };
|
|
3132
|
+
const matched = matchGuide(guides, identity);
|
|
3133
|
+
|
|
3134
|
+
// 本应命中却被拒的那份,必须**如实说出来**。
|
|
3135
|
+
//
|
|
3136
|
+
// 说明书写错时它不进匹配池(见 appGuide.ts::rejectionOf),此时若只报
|
|
3137
|
+
// 「没找到说明书」,用户会以为自己压根没写过,而实际上写了、只是坏了 ——
|
|
3138
|
+
// 一个能修的问题被表现成一个不存在的问题。
|
|
3139
|
+
const blocked = matched
|
|
3140
|
+
? null
|
|
3141
|
+
: matchGuide(
|
|
3142
|
+
rejected.map((item) => item.guide),
|
|
3143
|
+
identity,
|
|
3144
|
+
);
|
|
3145
|
+
if (blocked) {
|
|
3146
|
+
const entry = rejected.find(
|
|
3147
|
+
(item) => item.guide.name === blocked.guide.name,
|
|
3148
|
+
)!;
|
|
3149
|
+
return [
|
|
3150
|
+
`⚠️ 「${identity.name ?? identity.id ?? '当前应用'}」**有**说明书,但它写错了,已被隔离,不会进入你的上下文:`,
|
|
3151
|
+
` ${entry.guide.path}`,
|
|
3152
|
+
...entry.errors.map((message) => ` - ${message}`),
|
|
3153
|
+
'在它修好之前,你对这个应用没有可用知识 —— 每个动作后都要观测核实,不要连续盲操作。',
|
|
3154
|
+
].join('\n');
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
if (guides.length === 0) return '';
|
|
3158
|
+
return buildGuidanceText({
|
|
3159
|
+
guides,
|
|
3160
|
+
identity,
|
|
3161
|
+
// 只有真命中了才登记「已注入」—— 否则没找到说明书的那几次会白白吃掉配额,
|
|
3162
|
+
// 等真找到时反而只给一行提醒。
|
|
3163
|
+
firstTime: matched
|
|
3164
|
+
? computerUseService.shouldInjectFullManual(run, matched.guide.name)
|
|
3165
|
+
: false,
|
|
3166
|
+
// 通用交互模式不依赖匹配结果:没有应用说明书时它反而最该出现。
|
|
3167
|
+
// 用一个不可能与真实说明书重名的键占配额(说明书名来自文件名,不含 `__`)。
|
|
3168
|
+
genericFirstTime: computerUseService.shouldInjectFullManual(
|
|
3169
|
+
run,
|
|
3170
|
+
'__generic_interaction__',
|
|
3171
|
+
),
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
/**
|
|
3176
|
+
* 纯函数版本,供单测直接断言(注入策略是本批的核心行为,必须可测)。
|
|
3177
|
+
* @internal
|
|
3178
|
+
*/
|
|
3179
|
+
export function buildGuidanceText(input: {
|
|
3180
|
+
guides: AppGuide[];
|
|
3181
|
+
identity: { id?: string; name?: string };
|
|
3182
|
+
firstTime: boolean;
|
|
3183
|
+
budgetChars?: number;
|
|
3184
|
+
/**
|
|
3185
|
+
* 本回合是否还没注入过通用交互模式。
|
|
3186
|
+
*
|
|
3187
|
+
* 与应用说明书共用同一套「每回合一次」的配额纪律:通用手册每次观测都灌一遍会
|
|
3188
|
+
* 白烧上下文,而它的内容在一个回合内不会变。
|
|
3189
|
+
*/
|
|
3190
|
+
genericFirstTime?: boolean;
|
|
3191
|
+
}): string {
|
|
3192
|
+
// 通用交互模式与应用说明书是**互补**的,不是二选一:
|
|
3193
|
+
// 前者管「这类控件怎么操作」,后者管「这个应用的东西在哪、有什么坑」。
|
|
3194
|
+
// 没有应用说明书时它尤其重要 —— 那正是模型最容易自己瞎试的时候。
|
|
3195
|
+
const generic = input.genericFirstTime ? GENERIC_INTERACTION_GUIDE : '';
|
|
3196
|
+
const withGeneric = (body: string): string =>
|
|
3197
|
+
generic ? `${generic}\n\n${body}` : body;
|
|
3198
|
+
|
|
3199
|
+
const matched = matchGuide(input.guides, input.identity);
|
|
3200
|
+
if (!matched) {
|
|
3201
|
+
return withGeneric(describeMatch(null, input.guides, input.identity));
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
if (!input.firstTime) {
|
|
3205
|
+
return withGeneric(
|
|
3206
|
+
`应用说明书 **${matched.guide.name}** 本回合已注入过(完整内容:${matched.guide.path})。`,
|
|
3207
|
+
);
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
const confidence =
|
|
3211
|
+
matched.matchedBy === 'displayName'
|
|
3212
|
+
? '(靠显示名命中,置信度较低:显示名会随系统语言/版本变化)'
|
|
3213
|
+
: '';
|
|
3214
|
+
return withGeneric(
|
|
3215
|
+
renderManualDigest({
|
|
3216
|
+
name: matched.guide.name,
|
|
3217
|
+
path: matched.guide.path,
|
|
3218
|
+
manual: matched.guide.manual ?? EMPTY_MANUAL,
|
|
3219
|
+
irreversible: matched.guide.irreversible,
|
|
3220
|
+
matchedBy: `${matched.matchedBy}${confidence}`,
|
|
3221
|
+
budgetChars: input.budgetChars ?? MANUAL_DIGEST_BUDGET_CHARS,
|
|
3222
|
+
}).text,
|
|
3223
|
+
);
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
export async function authorizeComputerUseFileDropPaths(
|
|
3227
|
+
paths: string[],
|
|
3228
|
+
allowedRoots: string[],
|
|
3229
|
+
): Promise<{ ok: true; paths: string[] } | { ok: false; error: string }> {
|
|
3230
|
+
let canonicalRoots: string[];
|
|
3231
|
+
try {
|
|
3232
|
+
canonicalRoots = await Promise.all(
|
|
3233
|
+
allowedRoots.map((root) => realpath(resolve(root))),
|
|
3234
|
+
);
|
|
3235
|
+
} catch {
|
|
3236
|
+
return { ok: false, error: 'Computer Use 授权目录不存在或无法解析' };
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
const canonicalPaths: string[] = [];
|
|
3240
|
+
for (const path of paths) {
|
|
3241
|
+
if (!isAbsolute(path))
|
|
3242
|
+
return { ok: false, error: '拖入文件必须使用绝对路径' };
|
|
3243
|
+
let canonicalPath: string;
|
|
3244
|
+
try {
|
|
3245
|
+
canonicalPath = await realpath(path);
|
|
3246
|
+
if (!(await stat(canonicalPath)).isFile()) {
|
|
3247
|
+
return { ok: false, error: `拖入目标不是普通文件:${path}` };
|
|
3248
|
+
}
|
|
3249
|
+
} catch {
|
|
3250
|
+
return { ok: false, error: `拖入文件不存在或无法读取:${path}` };
|
|
3251
|
+
}
|
|
3252
|
+
if (!pathContainedByAny(canonicalRoots, canonicalPath))
|
|
3253
|
+
return { ok: false, error: `拖入文件超出当前运行授权范围:${path}` };
|
|
3254
|
+
canonicalPaths.push(canonicalPath);
|
|
3255
|
+
}
|
|
3256
|
+
return { ok: true, paths: canonicalPaths };
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
export async function validateComputerUseFileDropPaths(
|
|
3260
|
+
paths: string[],
|
|
3261
|
+
allowedRoots: string[],
|
|
3262
|
+
): Promise<string | null> {
|
|
3263
|
+
const result = await authorizeComputerUseFileDropPaths(paths, allowedRoots);
|
|
3264
|
+
return result.ok ? null : result.error;
|
|
3265
|
+
}
|