@vellumai/assistant 0.12.2 → 0.12.3-staging.1
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/.env.example +2 -0
- package/AGENTS.md +1 -1
- package/docs/architecture/integrations.md +7 -0
- package/docs/architecture/memory.md +17 -2
- package/docs/credential-execution-service.md +1 -1
- package/docs/desktop-browser-cli.md +7 -3
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
- package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
- package/node_modules/@vellumai/environments/src/shell.ts +24 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
- package/openapi.yaml +54 -5
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- package/scripts/sync-llm-catalog.ts +3 -0
- package/scripts/voice-ttft-spike.ts +2 -2
- package/src/__tests__/agent-loop.test.ts +259 -0
- package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
- package/src/__tests__/anthropic-provider.test.ts +55 -0
- package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
- package/src/__tests__/attachments-store.test.ts +22 -3
- package/src/__tests__/channel-approval.test.ts +9 -14
- package/src/__tests__/channel-reply-delivery.test.ts +56 -0
- package/src/__tests__/chat-credential-redaction.test.ts +23 -0
- package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
- package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
- package/src/__tests__/conversation-agent-loop.test.ts +257 -6
- package/src/__tests__/conversation-attachments.test.ts +106 -0
- package/src/__tests__/conversation-fork-crud.test.ts +191 -0
- package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
- package/src/__tests__/credential-execution-client.test.ts +70 -53
- package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
- package/src/__tests__/events-tail-route.test.ts +33 -0
- package/src/__tests__/history-repair.test.ts +53 -0
- package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
- package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
- package/src/__tests__/llm-catalog-parity.test.ts +27 -4
- package/src/__tests__/oauth-apps-routes.test.ts +1 -0
- package/src/__tests__/oauth-commands-routes.test.ts +114 -101
- package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
- package/src/__tests__/oauth-providers-routes.test.ts +2 -0
- package/src/__tests__/persist-media-references.test.ts +50 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
- package/src/__tests__/schedule-routes.test.ts +20 -0
- package/src/__tests__/scheduler-result-notification.test.ts +23 -4
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
- package/src/__tests__/secret-routes-scrub.test.ts +22 -27
- package/src/__tests__/secret-scanner.test.ts +20 -0
- package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
- package/src/__tests__/secure-keys.test.ts +7 -3
- package/src/__tests__/server-tool-pairing.test.ts +107 -0
- package/src/__tests__/skills.test.ts +5 -4
- package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
- package/src/__tests__/terminal-tools.test.ts +8 -0
- package/src/__tests__/tool-result-follow-up.test.ts +99 -0
- package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
- package/src/__tests__/unicode.test.ts +36 -0
- package/src/agent/loop.ts +37 -12
- package/src/agent/tool-result-follow-up.ts +70 -0
- package/src/api/attachment-provenance.test.ts +68 -0
- package/src/api/computer-use-tool.test.ts +55 -0
- package/src/api/computer-use-tool.ts +27 -0
- package/src/api/events/assistant-outbound-attachment.ts +3 -0
- package/src/api/events/desktop-activity-changed.ts +10 -0
- package/src/api/events/question-request.ts +1 -0
- package/src/api/index.ts +11 -0
- package/src/api/responses/conversation-message.ts +3 -0
- package/src/approvals/approval-primitive.ts +5 -2
- package/src/approvals/scoped-approval-grants.ts +6 -2
- package/src/browser/virtual-desktop-target.ts +1 -2
- package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
- package/src/cli/commands/__tests__/plugins.test.ts +20 -1
- package/src/cli/commands/__tests__/schedules.test.ts +14 -0
- package/src/cli/commands/bash.help.ts +4 -3
- package/src/cli/commands/browser.help.ts +5 -1
- package/src/cli/commands/credentials.help.ts +3 -3
- package/src/cli/commands/oauth/index.help.ts +10 -0
- package/src/cli/commands/oauth/providers.ts +12 -0
- package/src/cli/commands/oauth/request.test.ts +179 -2
- package/src/cli/commands/oauth/request.ts +41 -10
- package/src/cli/commands/plugins.ts +12 -5
- package/src/cli/commands/schedules.ts +2 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
- package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
- package/src/cli/lib/bundled-marketplace.json +855 -0
- package/src/cli/lib/bundled-plugin-packages.json +783 -1
- package/src/cli/lib/inspect-plugin.ts +11 -4
- package/src/cli/lib/upgrade-plugin.ts +19 -11
- package/src/cli/output.ts +18 -4
- package/src/config/__tests__/default-provider.test.ts +4 -0
- package/src/config/bundled-skills/schedule/SKILL.md +6 -6
- package/src/config/loader.ts +9 -17
- package/src/config/platform-identity.ts +8 -8
- package/src/config/profile-text-generation.test.ts +51 -0
- package/src/config/profile-text-generation.ts +51 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
- package/src/config/schemas/llm.ts +7 -2
- package/src/config/schemas/mcp.ts +5 -1
- package/src/config/schemas/memory-v3.ts +12 -0
- package/src/credential-execution/ces-runtime.ts +2 -2
- package/src/credential-execution/executable-discovery.ts +36 -134
- package/src/credential-execution/process-manager.test.ts +20 -24
- package/src/credential-execution/process-manager.ts +24 -19
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
- package/src/daemon/assistant-attachments.ts +20 -12
- package/src/daemon/chat-credential-redaction.ts +16 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
- package/src/daemon/conversation-agent-loop.ts +12 -0
- package/src/daemon/conversation-attachments.ts +93 -12
- package/src/daemon/conversation-tool-setup.ts +71 -3
- package/src/daemon/conversation-turn-finalize.ts +26 -13
- package/src/daemon/conversation.ts +72 -17
- package/src/daemon/daemon-control.ts +3 -7
- package/src/daemon/lifecycle.ts +4 -4
- package/src/daemon/mcp-reload-service.ts +1 -1
- package/src/daemon/message-types/shared.ts +1 -0
- package/src/daemon/orphan-reaper.ts +4 -3
- package/src/daemon/persist-media-references.ts +20 -3
- package/src/daemon/tool-setup-types.ts +6 -0
- package/src/daemon/wake-conversation-ops.ts +50 -15
- package/src/desktop/desktop-automation-lease.test.ts +230 -0
- package/src/desktop/desktop-automation-lease.ts +90 -6
- package/src/desktop/desktop-help.ts +15 -0
- package/src/desktop/desktop-stream-bridge.test.ts +65 -10
- package/src/desktop/desktop-stream-bridge.ts +3 -3
- package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
- package/src/desktop/desktop-wallpaper-worker.ts +17 -0
- package/src/desktop/desktop-wallpaper.test.ts +48 -7
- package/src/desktop/desktop-wallpaper.ts +34 -102
- package/src/desktop/virtual-desktop-feature.ts +1 -1
- package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
- package/src/mcp/__tests__/startup.test.ts +30 -7
- package/src/mcp/__tests__/tool-caps.test.ts +107 -0
- package/src/mcp/manager.ts +168 -101
- package/src/mcp/startup.ts +33 -13
- package/src/mcp/tool-caps.ts +176 -0
- package/src/messaging/provider-message-metadata.ts +3 -3
- package/src/notifications/__tests__/copy-composer.test.ts +70 -0
- package/src/notifications/copy-composer.ts +11 -3
- package/src/notifications/schedule-result-producer.ts +4 -6
- package/src/oauth/AGENTS.md +2 -0
- package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
- package/src/oauth/identity-verifier.ts +24 -0
- package/src/oauth/oauth-store.ts +11 -1
- package/src/oauth/provider-serializer.ts +1 -0
- package/src/oauth/seed-providers.ts +12 -0
- package/src/permissions/confirmation-guardian-request.test.ts +16 -0
- package/src/permissions/confirmation-guardian-request.ts +2 -3
- package/src/permissions/question-prompter.test.ts +31 -0
- package/src/permissions/question-prompter.ts +2 -0
- package/src/persistence/attachments-store.ts +84 -44
- package/src/persistence/conversation-crud.ts +111 -20
- package/src/persistence/conversation-plugin-facade.ts +30 -0
- package/src/persistence/conversation-tool-surface.ts +135 -0
- package/src/persistence/conversation-types.test.ts +32 -0
- package/src/persistence/conversation-types.ts +26 -4
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
- package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
- package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
- package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
- package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
- package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/oauth.ts +1 -0
- package/src/persistence/steps.ts +24 -3
- package/src/plugin-api/conversation-turn.ts +31 -7
- package/src/plugin-api/index.ts +11 -1
- package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
- package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
- package/src/plugins/defaults/memory/AGENTS.md +39 -5
- package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
- package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
- package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
- package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
- package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
- package/src/plugins/defaults/memory/buffer-file.ts +354 -0
- package/src/plugins/defaults/memory/buffer-format.ts +40 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
- package/src/plugins/defaults/memory/context-search/format.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
- package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
- package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
- package/src/plugins/defaults/memory/host-utils.ts +0 -10
- package/src/plugins/defaults/memory/injectors.ts +4 -3
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
- package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
- package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
- package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
- package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
- package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
- package/src/plugins/defaults/memory/tools.ts +1 -1
- package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
- package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
- package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
- package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
- package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
- package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
- package/src/plugins/defaults/memory/v3/card.ts +2 -1
- package/src/plugins/defaults/memory/v3/injector.ts +212 -178
- package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
- package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
- package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
- package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
- package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
- package/src/plugins/defaults/memory/v3/sections.ts +2 -1
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
- package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
- package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
- package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
- package/src/prompts/delegation-gate.ts +57 -0
- package/src/prompts/system-prompt.ts +14 -32
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/jev/client.test.ts +260 -0
- package/src/providers/jev/client.ts +518 -0
- package/src/providers/model-catalog.ts +58 -3
- package/src/providers/server-tool-pairing.ts +16 -7
- package/src/runtime/AGENTS.md +2 -2
- package/src/runtime/__tests__/agent-wake.test.ts +94 -1
- package/src/runtime/agent-wake.ts +31 -4
- package/src/runtime/guardian-action-service.ts +2 -17
- package/src/runtime/guardian-reply-router.ts +1 -8
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
- package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
- package/src/runtime/routes/channel-route-shared.ts +1 -9
- package/src/runtime/routes/conversation-routes.ts +29 -4
- package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
- package/src/runtime/routes/desktop-setup-routes.ts +8 -5
- package/src/runtime/routes/guardian-approval-interception.ts +24 -0
- package/src/runtime/routes/inbound-message-handler.ts +2 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
- package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
- package/src/runtime/routes/inference-profiles-routes.ts +9 -0
- package/src/runtime/routes/oauth-commands-routes.ts +57 -54
- package/src/runtime/routes/oauth-providers.ts +4 -0
- package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
- package/src/runtime/routes/oauth-request-hints.ts +149 -0
- package/src/runtime/routes/schedule-routes.ts +2 -0
- package/src/runtime/routes/secret-routes.ts +99 -119
- package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
- package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
- package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
- package/src/schedule/run-script.ts +2 -2
- package/src/schedule/scheduler.ts +47 -11
- package/src/schedule/tool-surface-readiness.ts +65 -0
- package/src/schedule/worker-mcp.ts +85 -0
- package/src/schedule/worker.ts +9 -31
- package/src/security/secure-keys.ts +60 -48
- package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
- package/src/tools/ask-question/ask-question-tool.ts +119 -23
- package/src/tools/browser/browser-execution.ts +7 -1
- package/src/tools/host-terminal/host-shell.ts +12 -6
- package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
- package/src/tools/shared/shell-output.test.ts +10 -0
- package/src/tools/shared/shell-output.ts +14 -2
- package/src/tools/skills/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +2 -1
- package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
- package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
- package/src/tools/terminal/safe-env.ts +35 -24
- package/src/tools/terminal/sanitized-bash.ts +15 -2
- package/src/tools/terminal/shell-launch.test.ts +162 -0
- package/src/tools/terminal/shell.test.ts +29 -0
- package/src/tools/terminal/shell.ts +13 -7
- package/src/util/browser-human-verification.ts +14 -0
- package/src/util/host-process.test.ts +17 -1
- package/src/util/host-process.ts +24 -0
- package/src/util/unicode.ts +29 -0
|
@@ -43,7 +43,7 @@ function isPositiveInteger(v: unknown): v is number {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Read daemon timeout values directly from the config JSON file, bypassing
|
|
46
|
-
* loadConfig() and its
|
|
46
|
+
* loadConfig() and its ensureDataDir() side effects.
|
|
47
47
|
* Falls back to hardcoded defaults on any error (missing file, malformed JSON,
|
|
48
48
|
* unexpected shape) so daemon stop/start never fails due to config issues.
|
|
49
49
|
*/
|
|
@@ -258,15 +258,11 @@ async function getDaemonStatus(): Promise<{
|
|
|
258
258
|
return { running: true, pid };
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
function getStartupLockPath(): string {
|
|
262
|
-
return getDaemonStartupLockPath();
|
|
263
|
-
}
|
|
264
|
-
|
|
265
261
|
/** Attempt to acquire a startup lock. Returns true on success. Stale locks
|
|
266
262
|
* (older than STARTUP_LOCK_STALE_MS) are forcibly removed to prevent
|
|
267
263
|
* permanent deadlocks from a crashed caller. */
|
|
268
264
|
function acquireStartupLock(): boolean {
|
|
269
|
-
const lockPath =
|
|
265
|
+
const lockPath = getDaemonStartupLockPath();
|
|
270
266
|
try {
|
|
271
267
|
// Ensure the root directory exists before attempting the lock file write.
|
|
272
268
|
// On a first-time run, getRootDir() may not exist yet, and writeFileSync
|
|
@@ -293,7 +289,7 @@ function acquireStartupLock(): boolean {
|
|
|
293
289
|
|
|
294
290
|
function releaseStartupLock(): void {
|
|
295
291
|
try {
|
|
296
|
-
unlinkSync(
|
|
292
|
+
unlinkSync(getDaemonStartupLockPath());
|
|
297
293
|
} catch {
|
|
298
294
|
/* already removed */
|
|
299
295
|
}
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -673,11 +673,11 @@ export async function runDaemon(): Promise<void> {
|
|
|
673
673
|
// blocked.
|
|
674
674
|
startConsentRefresh();
|
|
675
675
|
|
|
676
|
-
// Bring up the
|
|
676
|
+
// Bring up the assistant's CES connection (process + handshake + reconnect
|
|
677
677
|
// wiring). Blocks up to a 20s timeout so credential reads route through CES
|
|
678
|
-
// before provider init; non-fatal
|
|
679
|
-
// on failure.
|
|
680
|
-
// happens at the process level.
|
|
678
|
+
// before provider init; non-fatal, falls back to the direct credential store
|
|
679
|
+
// on failure. CES serves a multi-connection bootstrap socket, so this
|
|
680
|
+
// happens at the process level and child processes can connect independently.
|
|
681
681
|
await startCes(config);
|
|
682
682
|
|
|
683
683
|
// Bring up the plugin layer: install the runtime bridge, register the
|
|
@@ -116,7 +116,7 @@ async function doReload(): Promise<McpReloadResult> {
|
|
|
116
116
|
const servers: McpReloadServerResult[] = [];
|
|
117
117
|
|
|
118
118
|
if (serverIds.length > 0) {
|
|
119
|
-
const serverToolInfos = await manager.start(mcpConfig);
|
|
119
|
+
const { servers: serverToolInfos } = await manager.start(mcpConfig);
|
|
120
120
|
for (const { serverId, serverConfig, tools } of serverToolInfos) {
|
|
121
121
|
const mcpTools = createMcpToolsFromServer(
|
|
122
122
|
tools,
|
|
@@ -39,6 +39,7 @@ export interface UserMessageAttachment {
|
|
|
39
39
|
data: string;
|
|
40
40
|
/** Origin of the attachment on the daemon side, when known. */
|
|
41
41
|
sourceType?: "sandbox_file" | "host_file" | "tool_block";
|
|
42
|
+
computerUseScreenshot?: boolean;
|
|
42
43
|
extractedText?: string;
|
|
43
44
|
/** Original file size in bytes. Present when data was omitted from history_response to reduce payload size. */
|
|
44
45
|
sizeBytes?: number;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Periodic reaper for orphaned subprocesses that reparent to the daemon.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* timeout/abort, group-kill with `process.kill(-pgid, SIGKILL)` (the bash
|
|
6
|
-
* host_bash tools, and the skill sandbox runner).
|
|
4
|
+
* POSIX tools run commands in their own process group (`detached: true`) and,
|
|
5
|
+
* on timeout/abort, group-kill with `process.kill(-pgid, SIGKILL)` (the bash
|
|
6
|
+
* and host_bash tools, and the skill sandbox runner). Windows piped shell
|
|
7
|
+
* children stay attached and are torn down with `taskkill /T`. The
|
|
7
8
|
* immediate child is reaped by Bun/libuv, but its descendants — e.g. git's
|
|
8
9
|
* transport helpers or a skill runner's `bun` process — were never spawned by
|
|
9
10
|
* the daemon, so when the group dies they reparent to PID 1. When the daemon
|
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
WorkspaceRefMediaSource,
|
|
30
30
|
} from "../providers/types.js";
|
|
31
31
|
import { getLogger } from "../util/logger.js";
|
|
32
|
+
import { toolImageFilename } from "./assistant-attachments.js";
|
|
32
33
|
|
|
33
34
|
const log = getLogger("persist-media-references");
|
|
34
35
|
|
|
@@ -62,6 +63,7 @@ async function referenceMediaBlock(
|
|
|
62
63
|
messageId: string,
|
|
63
64
|
block: ImageContent | FileContent,
|
|
64
65
|
position: number,
|
|
66
|
+
imageFilenameOverride?: string,
|
|
65
67
|
): Promise<ContentBlock | null> {
|
|
66
68
|
if (block.source.type !== "base64") {
|
|
67
69
|
return block;
|
|
@@ -69,7 +71,9 @@ async function referenceMediaBlock(
|
|
|
69
71
|
const { data, media_type } = block.source;
|
|
70
72
|
const filename =
|
|
71
73
|
block.source.filename ??
|
|
72
|
-
(block.type === "image"
|
|
74
|
+
(block.type === "image"
|
|
75
|
+
? (imageFilenameOverride ?? imageFilename(media_type))
|
|
76
|
+
: "attachment");
|
|
73
77
|
|
|
74
78
|
let stored: { id: string; sizeBytes: number };
|
|
75
79
|
try {
|
|
@@ -127,11 +131,15 @@ export async function referenceMediaBlocksForPersist(
|
|
|
127
131
|
conversationCreatedAt: number,
|
|
128
132
|
messageId: string,
|
|
129
133
|
blocks: ContentBlock[],
|
|
134
|
+
computerUseToolNames?: ReadonlyMap<string, string>,
|
|
130
135
|
): Promise<ContentBlock[]> {
|
|
131
136
|
// A single link position counter across all attachments in the message so
|
|
132
137
|
// their `message_attachments` rows keep content order.
|
|
133
138
|
let position = 0;
|
|
134
|
-
const convert = async (
|
|
139
|
+
const convert = async (
|
|
140
|
+
block: ContentBlock,
|
|
141
|
+
imageFilenameOverride?: string,
|
|
142
|
+
): Promise<ContentBlock> => {
|
|
135
143
|
if (block.type === "image" || block.type === "file") {
|
|
136
144
|
if (block.source.type !== "base64") {
|
|
137
145
|
return block;
|
|
@@ -143,13 +151,22 @@ export async function referenceMediaBlocksForPersist(
|
|
|
143
151
|
messageId,
|
|
144
152
|
block,
|
|
145
153
|
position++,
|
|
154
|
+
imageFilenameOverride,
|
|
146
155
|
)) ?? block
|
|
147
156
|
);
|
|
148
157
|
}
|
|
149
158
|
if (block.type === "tool_result" && block.contentBlocks?.length) {
|
|
150
159
|
const contentBlocks: ContentBlock[] = [];
|
|
160
|
+
const computerUseToolName = computerUseToolNames?.get(block.tool_use_id);
|
|
151
161
|
for (const cb of block.contentBlocks) {
|
|
152
|
-
contentBlocks.push(
|
|
162
|
+
contentBlocks.push(
|
|
163
|
+
await convert(
|
|
164
|
+
cb,
|
|
165
|
+
cb.type === "image" && computerUseToolName
|
|
166
|
+
? toolImageFilename(cb.source.media_type, computerUseToolName)
|
|
167
|
+
: undefined,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
153
170
|
}
|
|
154
171
|
return { ...block, contentBlocks };
|
|
155
172
|
}
|
|
@@ -59,6 +59,12 @@ export interface SubagentToolStats {
|
|
|
59
59
|
* parity for desktop/web sources; channel-routed sources resolve every tool
|
|
60
60
|
* gate identically under `hasNoClient: true` with or without them.)
|
|
61
61
|
*
|
|
62
|
+
* A wake that also carries its source's recorded wire array
|
|
63
|
+
* (`Conversation.wireToolReplay`, set by `scopeWakeAllowedTools`) sends that
|
|
64
|
+
* array verbatim, so the pin then shapes only the execution-side inventory
|
|
65
|
+
* (`allowedToolNames`); the pin alone carries wire parity for a source with
|
|
66
|
+
* no recorded surface.
|
|
67
|
+
*
|
|
62
68
|
* Tool-definition resolution ONLY. The executor callback and host-proxy
|
|
63
69
|
* attachment paths never read the pin, so it cannot make a host tool
|
|
64
70
|
* runnable: in execution gate mode every non-allowlisted call is rejected
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { syncMessageToDisk } from "../persistence/conversation-disk-view.js";
|
|
23
23
|
import { backfillMessageIdOnLogs } from "../persistence/llm-request-log-store.js";
|
|
24
24
|
import { resolveMediaSourceData } from "../providers/media-resolve.js";
|
|
25
|
-
import type { Message } from "../providers/types.js";
|
|
25
|
+
import type { Message, ToolDefinition } from "../providers/types.js";
|
|
26
26
|
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
27
27
|
import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
|
|
28
28
|
import type { CompletedBackgroundTool } from "../tools/background-tool-registry.js";
|
|
@@ -350,38 +350,70 @@ export async function persistWakeTriggerMessage(
|
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* How a wake's tool allowlist is applied beyond the allowlist itself. Every
|
|
355
|
+
* field is set on the conversation together with the allowlist and restored
|
|
356
|
+
* with it.
|
|
357
|
+
*/
|
|
358
|
+
export interface WakeToolScopeOptions {
|
|
359
|
+
/** How the allowlist is enforced; see {@link SubagentToolGateMode}. */
|
|
360
|
+
gateMode?: SubagentToolGateMode;
|
|
361
|
+
/**
|
|
362
|
+
* Freezes the client-context inputs for tool-definition resolution
|
|
363
|
+
* (execution-gate-mode cache-parity wakes); see {@link WakeToolContextPin}.
|
|
364
|
+
* Its `requestOrigin`, when set, is stamped onto the conversation's per-turn
|
|
365
|
+
* origin so the permission checker's origin-scoped auto-grants fire.
|
|
366
|
+
*/
|
|
367
|
+
toolContextPin?: WakeToolContextPin;
|
|
368
|
+
/**
|
|
369
|
+
* Skills whose bundled tools join the turn's active set
|
|
370
|
+
* (`allowedToolNames`) so they are callable without a prior `skill_load`.
|
|
371
|
+
*/
|
|
372
|
+
preactivateSkillIds?: readonly string[];
|
|
373
|
+
/**
|
|
374
|
+
* Sent verbatim as the wake's wire tool array in place of the resolved one
|
|
375
|
+
* (the source conversation's recorded surface, replayed for provider
|
|
376
|
+
* prompt-cache parity). Changes nothing about what may execute.
|
|
377
|
+
*/
|
|
378
|
+
wireToolDefinitions?: readonly ToolDefinition[];
|
|
379
|
+
/**
|
|
380
|
+
* The delegation section's rendered state recorded with the source's
|
|
381
|
+
* surface, replayed into the wake's system prompt alongside
|
|
382
|
+
* `wireToolDefinitions`. Changes nothing about what may execute.
|
|
383
|
+
*/
|
|
384
|
+
delegateIndependentTasks?: boolean;
|
|
385
|
+
}
|
|
386
|
+
|
|
353
387
|
/**
|
|
354
388
|
* Temporarily restrict the tools visible/executable during a wake by
|
|
355
389
|
* reusing the conversation's subagent allowlist slot. Returns a restore
|
|
356
390
|
* callback that reinstates the previous allowlist so the wake can release
|
|
357
391
|
* the scope before any queued user turn is drained.
|
|
358
|
-
*
|
|
359
|
-
* `gateMode` controls how the allowlist is enforced — see
|
|
360
|
-
* {@link SubagentToolGateMode}. `toolContextPin`, when provided
|
|
361
|
-
* (execution-gate-mode cache-parity wakes), freezes the client-context
|
|
362
|
-
* inputs for tool-definition resolution — see {@link WakeToolContextPin}.
|
|
363
|
-
* Its `requestOrigin`, when set, is stamped onto the conversation's per-turn
|
|
364
|
-
* origin so the permission checker's origin-scoped auto-grants fire for the
|
|
365
|
-
* wake. `preactivateSkillIds`, when non-empty, activates those skills' bundled
|
|
366
|
-
* tools in the turn's active set (`allowedToolNames`) for the wake so they are
|
|
367
|
-
* callable without a prior `skill_load`. All are set and restored alongside the
|
|
368
|
-
* allowlist.
|
|
369
392
|
*/
|
|
370
393
|
export function scopeWakeAllowedTools(
|
|
371
394
|
conversation: Conversation,
|
|
372
395
|
tools: ReadonlySet<string>,
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
396
|
+
{
|
|
397
|
+
gateMode = "wire",
|
|
398
|
+
toolContextPin,
|
|
399
|
+
preactivateSkillIds,
|
|
400
|
+
wireToolDefinitions,
|
|
401
|
+
delegateIndependentTasks,
|
|
402
|
+
}: WakeToolScopeOptions = {},
|
|
376
403
|
): () => void {
|
|
377
404
|
const previous = conversation.subagentAllowedTools;
|
|
378
405
|
const previousGateMode = conversation.subagentToolGateMode;
|
|
379
406
|
const previousToolContextPin = conversation.toolContextPin;
|
|
380
407
|
const previousRequestOrigin = conversation.currentTurnRequestOrigin;
|
|
381
408
|
const previousPreactivated = conversation.preactivatedSkillIds;
|
|
409
|
+
const previousWireToolReplay = conversation.wireToolReplay;
|
|
410
|
+
const previousDelegateIndependentTasksReplay =
|
|
411
|
+
conversation.delegateIndependentTasksReplay;
|
|
382
412
|
conversation.setSubagentAllowedTools(new Set(tools));
|
|
383
413
|
conversation.subagentToolGateMode = gateMode;
|
|
384
414
|
conversation.toolContextPin = toolContextPin;
|
|
415
|
+
conversation.wireToolReplay = wireToolDefinitions;
|
|
416
|
+
conversation.delegateIndependentTasksReplay = delegateIndependentTasks;
|
|
385
417
|
if (toolContextPin?.requestOrigin !== undefined) {
|
|
386
418
|
conversation.currentTurnRequestOrigin = toolContextPin.requestOrigin;
|
|
387
419
|
}
|
|
@@ -394,5 +426,8 @@ export function scopeWakeAllowedTools(
|
|
|
394
426
|
conversation.toolContextPin = previousToolContextPin;
|
|
395
427
|
conversation.currentTurnRequestOrigin = previousRequestOrigin;
|
|
396
428
|
conversation.setPreactivatedSkillIds(previousPreactivated);
|
|
429
|
+
conversation.wireToolReplay = previousWireToolReplay;
|
|
430
|
+
conversation.delegateIndependentTasksReplay =
|
|
431
|
+
previousDelegateIndependentTasksReplay;
|
|
397
432
|
};
|
|
398
433
|
}
|
|
@@ -35,7 +35,9 @@ function fixture() {
|
|
|
35
35
|
const started = mock(async () => {});
|
|
36
36
|
const released = mock(() => {});
|
|
37
37
|
const releaseBrowser = mock(async () => {});
|
|
38
|
+
const notify = mock(async () => {});
|
|
38
39
|
const lease = new DesktopAutomationLease({
|
|
40
|
+
notify,
|
|
39
41
|
enabled: () => enabled,
|
|
40
42
|
ready: () => ready,
|
|
41
43
|
ensureReady,
|
|
@@ -50,6 +52,7 @@ function fixture() {
|
|
|
50
52
|
cleanups.push(lease);
|
|
51
53
|
return {
|
|
52
54
|
lease,
|
|
55
|
+
notify,
|
|
53
56
|
ensureReady,
|
|
54
57
|
install,
|
|
55
58
|
failSetup: () => setup.reject(new Error("download failed")),
|
|
@@ -128,6 +131,7 @@ test("cancellation stops running and queued browser commands without blocking a
|
|
|
128
131
|
const callback = mock(operation);
|
|
129
132
|
const queued = f.lease.runBrowser(context, callback);
|
|
130
133
|
abort.abort();
|
|
134
|
+
expect(f.lease.isActive).toBe(false);
|
|
131
135
|
expect(await running).toBeInstanceOf(Error);
|
|
132
136
|
expect((await queued).isError).toBe(true);
|
|
133
137
|
expect(callback).not.toHaveBeenCalled();
|
|
@@ -206,3 +210,229 @@ test("disabled, unidentified, cancelled and released browser calls cannot instal
|
|
|
206
210
|
await expect(f.lease.runBrowser(context, operation)).rejects.toThrow();
|
|
207
211
|
expect(f.ensureReady).not.toHaveBeenCalled();
|
|
208
212
|
});
|
|
213
|
+
|
|
214
|
+
test("activity follows the browser lease and clears before failed cleanup", async () => {
|
|
215
|
+
const f = fixture();
|
|
216
|
+
expect(f.lease.isActive).toBe(false);
|
|
217
|
+
await f.lease.runBrowser(context, operation);
|
|
218
|
+
expect(f.lease.isActive).toBe(true);
|
|
219
|
+
expect(f.notify).toHaveBeenCalledTimes(1);
|
|
220
|
+
await f.lease.runBrowser(context, operation);
|
|
221
|
+
expect(f.notify).toHaveBeenCalledTimes(1);
|
|
222
|
+
f.releaseBrowser.mockRejectedValueOnce(new Error("cleanup failed"));
|
|
223
|
+
await expect(f.lease.runBrowser(context, operation, true)).rejects.toThrow(
|
|
224
|
+
"cleanup failed",
|
|
225
|
+
);
|
|
226
|
+
expect(f.lease.isActive).toBe(false);
|
|
227
|
+
expect(f.notify).toHaveBeenCalledTimes(2);
|
|
228
|
+
await f.lease.runBrowser(context, operation, true);
|
|
229
|
+
expect(f.notify).toHaveBeenCalledTimes(2);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("turn completion releases only its own desktop before cleanup finishes", async () => {
|
|
233
|
+
const f = fixture();
|
|
234
|
+
await f.lease.runBrowser(context, operation);
|
|
235
|
+
f.lease.releaseForConversation("conv-456");
|
|
236
|
+
expect(f.lease.isActive).toBe(true);
|
|
237
|
+
expect(f.notify).toHaveBeenCalledTimes(1);
|
|
238
|
+
|
|
239
|
+
const cleanup = Promise.withResolvers<void>();
|
|
240
|
+
f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
|
|
241
|
+
f.lease.releaseForConversation(context.conversationId);
|
|
242
|
+
expect(f.lease.isActive).toBe(false);
|
|
243
|
+
expect(f.notify).toHaveBeenCalledTimes(2);
|
|
244
|
+
await Bun.sleep(0);
|
|
245
|
+
expect(f.released).not.toHaveBeenCalled();
|
|
246
|
+
|
|
247
|
+
const callback = mock(operation);
|
|
248
|
+
const next = f.lease.runBrowser(context, callback);
|
|
249
|
+
await Bun.sleep(0);
|
|
250
|
+
expect(callback).not.toHaveBeenCalled();
|
|
251
|
+
cleanup.resolve();
|
|
252
|
+
expect((await next).isError).toBe(false);
|
|
253
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(f.started).toHaveBeenCalledTimes(2);
|
|
255
|
+
expect(f.lease.isActive).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("turn completion cancels running and queued desktop work", async () => {
|
|
259
|
+
const f = fixture();
|
|
260
|
+
const started = Promise.withResolvers<void>();
|
|
261
|
+
const running = f.lease
|
|
262
|
+
.runBrowser(context, async (signal) => {
|
|
263
|
+
started.resolve();
|
|
264
|
+
await new Promise<void>((_resolve, reject) => {
|
|
265
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), {
|
|
266
|
+
once: true,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
return operation();
|
|
270
|
+
})
|
|
271
|
+
.catch((error: unknown) => error);
|
|
272
|
+
await started.promise;
|
|
273
|
+
const callback = mock(operation);
|
|
274
|
+
const queued = f.lease.runBrowser(context, callback);
|
|
275
|
+
f.lease.releaseForConversation(context.conversationId);
|
|
276
|
+
expect(f.lease.isActive).toBe(false);
|
|
277
|
+
expect(await running).toBeInstanceOf(Error);
|
|
278
|
+
expect((await queued).isError).toBe(true);
|
|
279
|
+
expect(callback).not.toHaveBeenCalled();
|
|
280
|
+
await f.lease.runBrowser(context, callback);
|
|
281
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("turn handoff retries failed cleanup without another turn or restart", async () => {
|
|
285
|
+
const f = fixture();
|
|
286
|
+
await f.lease.runBrowser(context, operation);
|
|
287
|
+
f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
|
|
288
|
+
f.lease.releaseForConversation(context.conversationId);
|
|
289
|
+
expect(f.lease.isActive).toBe(false);
|
|
290
|
+
await Bun.sleep(0);
|
|
291
|
+
expect(f.released).not.toHaveBeenCalled();
|
|
292
|
+
await Bun.sleep(1_100);
|
|
293
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
294
|
+
const nextContext = { ...context, conversationId: "conv-456" };
|
|
295
|
+
expect((await f.lease.runBrowser(nextContext, operation)).isError).toBe(
|
|
296
|
+
false,
|
|
297
|
+
);
|
|
298
|
+
await f.lease.runBrowser(nextContext, operation, true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("a delayed cleanup retry cannot release a replacement owner", async () => {
|
|
302
|
+
const f = fixture();
|
|
303
|
+
await f.lease.runBrowser(context, operation);
|
|
304
|
+
f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
|
|
305
|
+
f.lease.releaseForConversation(context.conversationId);
|
|
306
|
+
await f.lease.runBrowser(context, operation, true);
|
|
307
|
+
const nextContext = { ...context, conversationId: "conv-456" };
|
|
308
|
+
await f.lease.runBrowser(nextContext, operation);
|
|
309
|
+
await Bun.sleep(1_100);
|
|
310
|
+
expect(f.lease.isActive).toBe(true);
|
|
311
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
312
|
+
await f.lease.runBrowser(nextContext, operation, true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("cleanup retries preserve commands queued behind an explicit handoff", async () => {
|
|
316
|
+
const f = fixture();
|
|
317
|
+
await f.lease.runBrowser(context, operation);
|
|
318
|
+
f.releaseBrowser.mockRejectedValueOnce(new Error("Chrome cleanup timed out"));
|
|
319
|
+
f.lease.releaseForConversation(context.conversationId);
|
|
320
|
+
await Bun.sleep(0);
|
|
321
|
+
const cleanup = Promise.withResolvers<void>();
|
|
322
|
+
f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
|
|
323
|
+
const detached = f.lease.runBrowser(context, operation, true);
|
|
324
|
+
await Bun.sleep(0);
|
|
325
|
+
const callback = mock(operation);
|
|
326
|
+
const next = f.lease.runBrowser(context, callback);
|
|
327
|
+
await Bun.sleep(1_100);
|
|
328
|
+
cleanup.resolve();
|
|
329
|
+
await detached;
|
|
330
|
+
expect((await next).isError).toBe(false);
|
|
331
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
332
|
+
expect(f.lease.isActive).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("turn completion cancels commands queued behind an in-progress detach", async () => {
|
|
336
|
+
const f = fixture();
|
|
337
|
+
await f.lease.runBrowser(context, operation);
|
|
338
|
+
const cleanup = Promise.withResolvers<void>();
|
|
339
|
+
f.releaseBrowser.mockImplementationOnce(() => cleanup.promise);
|
|
340
|
+
const detached = f.lease.runBrowser(context, operation, true);
|
|
341
|
+
await Bun.sleep(0);
|
|
342
|
+
const callback = mock(operation);
|
|
343
|
+
const queued = f.lease.runBrowser(context, callback);
|
|
344
|
+
f.lease.releaseForConversation(context.conversationId);
|
|
345
|
+
cleanup.resolve();
|
|
346
|
+
await detached;
|
|
347
|
+
expect((await queued).isError).toBe(true);
|
|
348
|
+
expect(callback).not.toHaveBeenCalled();
|
|
349
|
+
await f.lease.runBrowser(context, callback);
|
|
350
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("human help releases held input while reserving the desktop, then resumes its owner", async () => {
|
|
354
|
+
const f = fixture();
|
|
355
|
+
await f.lease.runBrowser(context, operation);
|
|
356
|
+
const finish = await f.lease.reserveForHuman(context);
|
|
357
|
+
expect(f.releaseBrowser).toHaveBeenCalledTimes(2);
|
|
358
|
+
expect(f.released).not.toHaveBeenCalled();
|
|
359
|
+
expect(f.lease.isActive).toBe(false);
|
|
360
|
+
const callback = mock(operation);
|
|
361
|
+
await expect(f.lease.runBrowser(context, callback)).rejects.toThrow(
|
|
362
|
+
"reserved",
|
|
363
|
+
);
|
|
364
|
+
await expect(f.lease.runBrowser(context, callback, true)).rejects.toThrow(
|
|
365
|
+
"reserved",
|
|
366
|
+
);
|
|
367
|
+
const other = { ...context, conversationId: "conv-456" };
|
|
368
|
+
await expect(f.lease.runBrowser(other, callback)).rejects.toThrow(
|
|
369
|
+
"Another conversation",
|
|
370
|
+
);
|
|
371
|
+
expect(callback).not.toHaveBeenCalled();
|
|
372
|
+
await finish(true);
|
|
373
|
+
expect(f.lease.isActive).toBe(true);
|
|
374
|
+
await expect(f.lease.runBrowser(other, callback)).rejects.toThrow(
|
|
375
|
+
"Another conversation",
|
|
376
|
+
);
|
|
377
|
+
await f.lease.runBrowser(context, callback);
|
|
378
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
379
|
+
expect(f.started).toHaveBeenCalledTimes(1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("closing human help releases its reservation and stale cleanup preserves the next owner", async () => {
|
|
383
|
+
const f = fixture();
|
|
384
|
+
const finish = await f.lease.reserveForHuman(context);
|
|
385
|
+
await finish(false);
|
|
386
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
387
|
+
const other = { ...context, conversationId: "conv-456" };
|
|
388
|
+
await f.lease.runBrowser(other, operation);
|
|
389
|
+
await finish(false);
|
|
390
|
+
expect(f.lease.isActive).toBe(true);
|
|
391
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
392
|
+
await f.lease.runBrowser(other, operation, true);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("aborting human help releases its reservation", async () => {
|
|
396
|
+
const f = fixture();
|
|
397
|
+
const abort = new AbortController();
|
|
398
|
+
const finish = await f.lease.reserveForHuman({
|
|
399
|
+
...context,
|
|
400
|
+
signal: abort.signal,
|
|
401
|
+
});
|
|
402
|
+
abort.abort();
|
|
403
|
+
await finish(true);
|
|
404
|
+
expect(f.lease.isActive).toBe(false);
|
|
405
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
406
|
+
await f.lease.runBrowser(context, operation);
|
|
407
|
+
expect(f.started).toHaveBeenCalledTimes(2);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("the browser idle timeout cannot steal a pending human-help reservation", async () => {
|
|
411
|
+
const f = fixture();
|
|
412
|
+
const finish = await f.lease.reserveForHuman(context);
|
|
413
|
+
const now = Date.now;
|
|
414
|
+
const future = now() + 10 * 60_000;
|
|
415
|
+
Date.now = () => future;
|
|
416
|
+
try {
|
|
417
|
+
await Bun.sleep(1_100);
|
|
418
|
+
expect(f.released).not.toHaveBeenCalled();
|
|
419
|
+
await expect(
|
|
420
|
+
f.lease.runBrowser({ ...context, conversationId: "conv-456" }, operation),
|
|
421
|
+
).rejects.toThrow("Another conversation");
|
|
422
|
+
} finally {
|
|
423
|
+
Date.now = now;
|
|
424
|
+
await finish(false);
|
|
425
|
+
}
|
|
426
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("ending human help retries failed input cleanup without another turn", async () => {
|
|
430
|
+
const f = fixture();
|
|
431
|
+
const finish = await f.lease.reserveForHuman(context);
|
|
432
|
+
f.releaseBrowser.mockRejectedValueOnce(new Error("cleanup failed"));
|
|
433
|
+
await expect(finish(false)).rejects.toThrow("cleanup failed");
|
|
434
|
+
await Bun.sleep(0);
|
|
435
|
+
expect(f.released).toHaveBeenCalledTimes(1);
|
|
436
|
+
await f.lease.runBrowser(context, operation);
|
|
437
|
+
expect(f.lease.isActive).toBe(true);
|
|
438
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
2
2
|
import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
|
|
3
3
|
import { getLogger } from "../util/logger.js";
|
|
4
4
|
import { desktopDependencyInstaller } from "./desktop-dependencies.js";
|
|
@@ -21,6 +21,7 @@ type Owner = {
|
|
|
21
21
|
actions: number;
|
|
22
22
|
lastActivity: number;
|
|
23
23
|
desktopLost: boolean;
|
|
24
|
+
humanHelp?: symbol;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export class DesktopAutomationLease {
|
|
@@ -35,14 +36,33 @@ export class DesktopAutomationLease {
|
|
|
35
36
|
ready: () => boolean;
|
|
36
37
|
ensureReady: (signal: AbortSignal) => Promise<void>;
|
|
37
38
|
manager: () => DesktopSessionManager;
|
|
39
|
+
notify: () => Promise<unknown>;
|
|
38
40
|
} = {
|
|
39
|
-
enabled:
|
|
41
|
+
enabled: isVirtualDesktopEnabled,
|
|
40
42
|
ready: () => desktopDependencyInstaller.getStatus().state === "ready",
|
|
41
43
|
ensureReady: (signal) => desktopDependencyInstaller.ensureReady(signal),
|
|
42
44
|
manager: getDesktopSessionManager,
|
|
45
|
+
notify: async () =>
|
|
46
|
+
broadcastMessage({ type: "desktop_activity_changed" }),
|
|
43
47
|
},
|
|
44
48
|
) {}
|
|
45
49
|
|
|
50
|
+
get isActive(): boolean {
|
|
51
|
+
return (
|
|
52
|
+
this.owner !== null &&
|
|
53
|
+
!this.owner.humanHelp &&
|
|
54
|
+
!this.owner.abort.signal.aborted
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private notify(): void {
|
|
59
|
+
void this.deps
|
|
60
|
+
.notify()
|
|
61
|
+
.catch((err) =>
|
|
62
|
+
log.warn({ err }, "Desktop browser activity notification failed"),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
private exclusive<T>(run: () => Promise<T>): Promise<T> {
|
|
47
67
|
const next = this.tail.then(run, run);
|
|
48
68
|
this.tail = next.catch(() => {});
|
|
@@ -84,9 +104,25 @@ export class DesktopAutomationLease {
|
|
|
84
104
|
}
|
|
85
105
|
this.generation += 1;
|
|
86
106
|
owner.abort.abort();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
this.releaseCancelledOwner(owner);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private releaseCancelledOwner(owner: Owner): void {
|
|
111
|
+
void this.exclusive(async () => {
|
|
112
|
+
if (this.owner === owner) {
|
|
113
|
+
await this.release();
|
|
114
|
+
}
|
|
115
|
+
}).catch((err) => {
|
|
116
|
+
log.warn({ err }, "Desktop browser session cleanup failed");
|
|
117
|
+
const retry = setTimeout(() => this.releaseCancelledOwner(owner), 1_000);
|
|
118
|
+
retry.unref?.();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
releaseForConversation(conversationId: string): void {
|
|
123
|
+
if (this.owner?.conversationId === conversationId) {
|
|
124
|
+
this.cancel(this.owner);
|
|
125
|
+
}
|
|
90
126
|
}
|
|
91
127
|
|
|
92
128
|
private bindCancellation(owner: Owner, signal?: AbortSignal): void {
|
|
@@ -122,12 +158,17 @@ export class DesktopAutomationLease {
|
|
|
122
158
|
throw new Error("The desktop is busy or shutting down");
|
|
123
159
|
}
|
|
124
160
|
this.owner = owner;
|
|
161
|
+
owner.abort.signal.addEventListener("abort", () => this.notify(), {
|
|
162
|
+
once: true,
|
|
163
|
+
});
|
|
164
|
+
this.notify();
|
|
125
165
|
this.bindCancellation(owner, context.signal);
|
|
126
166
|
const cancel = () => this.cancel(owner);
|
|
127
167
|
this.watchdog = setInterval(() => {
|
|
128
168
|
try {
|
|
129
169
|
if (
|
|
130
|
-
|
|
170
|
+
(!owner.humanHelp &&
|
|
171
|
+
Date.now() - owner.lastActivity > IDLE_TIMEOUT_MS) ||
|
|
131
172
|
!this.deps.enabled() ||
|
|
132
173
|
!this.deps.ready()
|
|
133
174
|
) {
|
|
@@ -156,6 +197,44 @@ export class DesktopAutomationLease {
|
|
|
156
197
|
}
|
|
157
198
|
}
|
|
158
199
|
|
|
200
|
+
async reserveForHuman(
|
|
201
|
+
context: ToolContext,
|
|
202
|
+
): Promise<(resume: boolean) => Promise<void>> {
|
|
203
|
+
let reservedOwner: Owner | undefined;
|
|
204
|
+
const reservation = Symbol("human-help");
|
|
205
|
+
const result = await this.runBrowser(context, async () => {
|
|
206
|
+
await this.deps.manager().browser.release();
|
|
207
|
+
reservedOwner = this.owner!;
|
|
208
|
+
reservedOwner.humanHelp = reservation;
|
|
209
|
+
this.notify();
|
|
210
|
+
return { content: "", isError: false };
|
|
211
|
+
});
|
|
212
|
+
if (result.isError || !reservedOwner) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
"Desktop help was interrupted before control could be handed over.",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const owner = reservedOwner;
|
|
218
|
+
return (resume) =>
|
|
219
|
+
this.exclusive(async () => {
|
|
220
|
+
if (this.owner !== owner || owner.humanHelp !== reservation) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (resume && !owner.abort.signal.aborted) {
|
|
224
|
+
owner.humanHelp = undefined;
|
|
225
|
+
owner.lastActivity = Date.now();
|
|
226
|
+
this.notify();
|
|
227
|
+
} else {
|
|
228
|
+
try {
|
|
229
|
+
await this.release();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
this.releaseCancelledOwner(owner);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
159
238
|
runBrowser(
|
|
160
239
|
context: ToolContext,
|
|
161
240
|
operation: (signal: AbortSignal) => Promise<ToolExecutionResult>,
|
|
@@ -179,6 +258,11 @@ export class DesktopAutomationLease {
|
|
|
179
258
|
) {
|
|
180
259
|
throw new Error("Another conversation is controlling the desktop");
|
|
181
260
|
}
|
|
261
|
+
if (this.owner?.humanHelp) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
"The desktop is reserved while the user is helping. Wait for Done or Skip.",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
182
266
|
if (done) {
|
|
183
267
|
await this.release();
|
|
184
268
|
return { content: "Desktop browser session released.", isError: false };
|