managed-deepagents 0.9.6-dev.19 → 0.9.6-dev.20
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 +1075 -37
- package/dist/agent-auth.d.ts +1 -1
- package/dist/agent-auth.d.ts.map +1 -1
- package/dist/agent-auth.js +36 -12
- package/dist/agent-auth.js.map +1 -1
- package/dist/browser.d.ts +37 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +31 -0
- package/dist/browser.js.map +1 -0
- package/dist/channels/actor.d.ts +57 -0
- package/dist/channels/actor.d.ts.map +1 -0
- package/dist/channels/actor.js +24 -0
- package/dist/channels/actor.js.map +1 -0
- package/dist/channels/assistant-text.d.ts +29 -0
- package/dist/channels/assistant-text.d.ts.map +1 -1
- package/dist/channels/assistant-text.js +72 -1
- package/dist/channels/assistant-text.js.map +1 -1
- package/dist/channels/binding.d.ts +20 -1
- package/dist/channels/binding.d.ts.map +1 -1
- package/dist/channels/binding.js +44 -11
- package/dist/channels/binding.js.map +1 -1
- package/dist/channels/delivery.d.ts +51 -0
- package/dist/channels/delivery.d.ts.map +1 -0
- package/dist/channels/delivery.js +424 -0
- package/dist/channels/delivery.js.map +1 -0
- package/dist/channels/github/app-setup.d.ts +32 -0
- package/dist/channels/github/app-setup.d.ts.map +1 -0
- package/dist/channels/github/app-setup.js +146 -0
- package/dist/channels/github/app-setup.js.map +1 -0
- package/dist/channels/github/connections.d.ts +11 -0
- package/dist/channels/github/connections.d.ts.map +1 -0
- package/dist/channels/github/connections.js +14 -0
- package/dist/channels/github/connections.js.map +1 -0
- package/dist/channels/github/index.d.ts +12 -0
- package/dist/channels/github/index.d.ts.map +1 -0
- package/dist/channels/github/index.js +266 -0
- package/dist/channels/github/index.js.map +1 -0
- package/dist/channels/github/mentions.d.ts +12 -0
- package/dist/channels/github/mentions.d.ts.map +1 -0
- package/dist/channels/github/mentions.js +185 -0
- package/dist/channels/github/mentions.js.map +1 -0
- package/dist/channels/github/schema.d.ts +4165 -0
- package/dist/channels/github/schema.d.ts.map +1 -0
- package/dist/channels/github/schema.js +221 -0
- package/dist/channels/github/schema.js.map +1 -0
- package/dist/channels/github/types.d.ts +170 -0
- package/dist/channels/github/types.d.ts.map +1 -0
- package/dist/channels/github/types.js +2 -0
- package/dist/channels/github/types.js.map +1 -0
- package/dist/channels/http/endpoint.d.ts +137 -0
- package/dist/channels/http/endpoint.d.ts.map +1 -0
- package/dist/channels/http/endpoint.js +286 -0
- package/dist/channels/http/endpoint.js.map +1 -0
- package/dist/channels/http/http.d.ts.map +1 -1
- package/dist/channels/http/http.js +439 -65
- package/dist/channels/http/http.js.map +1 -1
- package/dist/channels/http/index.d.ts +8 -1
- package/dist/channels/http/index.d.ts.map +1 -1
- package/dist/channels/http/index.js +35 -16
- package/dist/channels/http/index.js.map +1 -1
- package/dist/channels/http/ingress.d.ts +67 -5
- package/dist/channels/http/ingress.d.ts.map +1 -1
- package/dist/channels/http/ingress.js +155 -43
- package/dist/channels/http/ingress.js.map +1 -1
- package/dist/channels/http/provider.d.ts +125 -0
- package/dist/channels/http/provider.d.ts.map +1 -0
- package/dist/channels/http/provider.js +227 -0
- package/dist/channels/http/provider.js.map +1 -0
- package/dist/channels/http/studio.d.ts +24 -0
- package/dist/channels/http/studio.d.ts.map +1 -0
- package/dist/channels/http/studio.js +95 -0
- package/dist/channels/http/studio.js.map +1 -0
- package/dist/channels/http/threads.d.ts +7 -0
- package/dist/channels/http/threads.d.ts.map +1 -0
- package/dist/channels/http/threads.js +10 -0
- package/dist/channels/http/threads.js.map +1 -0
- package/dist/channels/http/types.d.ts +166 -2
- package/dist/channels/http/types.d.ts.map +1 -1
- package/dist/channels/linear/app-setup.d.ts +29 -0
- package/dist/channels/linear/app-setup.d.ts.map +1 -0
- package/dist/channels/linear/app-setup.js +102 -0
- package/dist/channels/linear/app-setup.js.map +1 -0
- package/dist/channels/linear/connections.d.ts +11 -0
- package/dist/channels/linear/connections.d.ts.map +1 -0
- package/dist/channels/linear/connections.js +14 -0
- package/dist/channels/linear/connections.js.map +1 -0
- package/dist/channels/linear/graphql.d.ts +57 -0
- package/dist/channels/linear/graphql.d.ts.map +1 -0
- package/dist/channels/linear/graphql.js +167 -0
- package/dist/channels/linear/graphql.js.map +1 -0
- package/dist/channels/linear/index.d.ts +13 -0
- package/dist/channels/linear/index.d.ts.map +1 -0
- package/dist/channels/linear/index.js +457 -0
- package/dist/channels/linear/index.js.map +1 -0
- package/dist/channels/linear/schema.d.ts +1050 -0
- package/dist/channels/linear/schema.d.ts.map +1 -0
- package/dist/channels/linear/schema.js +255 -0
- package/dist/channels/linear/schema.js.map +1 -0
- package/dist/channels/linear/types.d.ts +169 -0
- package/dist/channels/linear/types.d.ts.map +1 -0
- package/dist/channels/linear/types.js +2 -0
- package/dist/channels/linear/types.js.map +1 -0
- package/dist/channels/message-schema.d.ts +2 -0
- package/dist/channels/message-schema.d.ts.map +1 -1
- package/dist/channels/message-schema.js +4 -0
- package/dist/channels/message-schema.js.map +1 -1
- package/dist/channels/messaging-registry.d.ts +16 -0
- package/dist/channels/messaging-registry.d.ts.map +1 -1
- package/dist/channels/messaging-registry.js +29 -0
- package/dist/channels/messaging-registry.js.map +1 -1
- package/dist/channels/reserved-connections.d.ts +12 -0
- package/dist/channels/reserved-connections.d.ts.map +1 -0
- package/dist/channels/reserved-connections.js +55 -0
- package/dist/channels/reserved-connections.js.map +1 -0
- package/dist/channels/resolve-user-endpoint.d.ts +34 -0
- package/dist/channels/resolve-user-endpoint.d.ts.map +1 -0
- package/dist/channels/resolve-user-endpoint.js +51 -0
- package/dist/channels/resolve-user-endpoint.js.map +1 -0
- package/dist/channels/resolve-user.d.ts +95 -0
- package/dist/channels/resolve-user.d.ts.map +1 -0
- package/dist/channels/resolve-user.js +143 -0
- package/dist/channels/resolve-user.js.map +1 -0
- package/dist/channels/run-input.d.ts +88 -0
- package/dist/channels/run-input.d.ts.map +1 -0
- package/dist/channels/run-input.js +177 -0
- package/dist/channels/run-input.js.map +1 -0
- package/dist/channels/runtime.d.ts +5 -2
- package/dist/channels/runtime.d.ts.map +1 -1
- package/dist/channels/runtime.js +19 -10
- package/dist/channels/runtime.js.map +1 -1
- package/dist/channels/schedule-delivery.d.ts +2 -13
- package/dist/channels/schedule-delivery.d.ts.map +1 -1
- package/dist/channels/schedule-delivery.js +6 -73
- package/dist/channels/schedule-delivery.js.map +1 -1
- package/dist/channels/slack/api.d.ts +2 -0
- package/dist/channels/slack/api.d.ts.map +1 -1
- package/dist/channels/slack/api.js +6 -0
- package/dist/channels/slack/api.js.map +1 -1
- package/dist/channels/slack/connections.d.ts +6 -0
- package/dist/channels/slack/connections.d.ts.map +1 -0
- package/dist/channels/slack/connections.js +6 -0
- package/dist/channels/slack/connections.js.map +1 -0
- package/dist/channels/slack/events.d.ts +278 -0
- package/dist/channels/slack/events.d.ts.map +1 -0
- package/dist/channels/slack/events.js +235 -0
- package/dist/channels/slack/events.js.map +1 -0
- package/dist/channels/slack/files.d.ts.map +1 -1
- package/dist/channels/slack/files.js +2 -3
- package/dist/channels/slack/files.js.map +1 -1
- package/dist/channels/slack/index.d.ts +90 -2
- package/dist/channels/slack/index.d.ts.map +1 -1
- package/dist/channels/slack/index.js +188 -6
- package/dist/channels/slack/index.js.map +1 -1
- package/dist/channels/slack/message-content.d.ts.map +1 -1
- package/dist/channels/slack/message-content.js +23 -28
- package/dist/channels/slack/message-content.js.map +1 -1
- package/dist/channels/slack/status.d.ts +76 -0
- package/dist/channels/slack/status.d.ts.map +1 -0
- package/dist/channels/slack/status.js +427 -0
- package/dist/channels/slack/status.js.map +1 -0
- package/dist/channels/source-providers.js +1 -1
- package/dist/channels/source-providers.js.map +1 -1
- package/dist/channels/trigger/client.d.ts.map +1 -1
- package/dist/channels/trigger/client.js +6 -9
- package/dist/channels/trigger/client.js.map +1 -1
- package/dist/channels/trigger/conversation.d.ts +11 -0
- package/dist/channels/trigger/conversation.d.ts.map +1 -0
- package/dist/channels/trigger/conversation.js +72 -0
- package/dist/channels/trigger/conversation.js.map +1 -0
- package/dist/channels/trigger/http.d.ts +130 -4
- package/dist/channels/trigger/http.d.ts.map +1 -1
- package/dist/channels/trigger/http.js +408 -78
- package/dist/channels/trigger/http.js.map +1 -1
- package/dist/channels/trigger/ingress.d.ts +70 -1
- package/dist/channels/trigger/ingress.d.ts.map +1 -1
- package/dist/channels/trigger/ingress.js +130 -19
- package/dist/channels/trigger/ingress.js.map +1 -1
- package/dist/channels/trigger/transport.d.ts +10 -0
- package/dist/channels/trigger/transport.d.ts.map +1 -1
- package/dist/channels/trigger/transport.js +25 -9
- package/dist/channels/trigger/transport.js.map +1 -1
- package/dist/channels/trigger/types.d.ts +92 -3
- package/dist/channels/trigger/types.d.ts.map +1 -1
- package/dist/channels/trigger/types.js +37 -0
- package/dist/channels/trigger/types.js.map +1 -1
- package/dist/channels/types.d.ts +53 -4
- package/dist/channels/types.d.ts.map +1 -1
- package/dist/channels/types.js +2 -0
- package/dist/channels/types.js.map +1 -1
- package/dist/connections.d.ts +35 -1
- package/dist/connections.d.ts.map +1 -1
- package/dist/connections.js +121 -10
- package/dist/connections.js.map +1 -1
- package/dist/credential-gate.d.ts +2 -0
- package/dist/credential-gate.d.ts.map +1 -1
- package/dist/credential-gate.js +37 -3
- package/dist/credential-gate.js.map +1 -1
- package/dist/define-deep-agent.d.ts +2 -2
- package/dist/define-deep-agent.d.ts.map +1 -1
- package/dist/define-deep-agent.js +53 -2
- package/dist/define-deep-agent.js.map +1 -1
- package/dist/identity/index.d.ts.map +1 -1
- package/dist/identity/index.js +3 -1
- package/dist/identity/index.js.map +1 -1
- package/dist/identity/options.d.ts +9 -0
- package/dist/identity/options.d.ts.map +1 -1
- package/dist/identity/options.js +1 -1
- package/dist/identity/options.js.map +1 -1
- package/dist/identity/types.d.ts +3 -0
- package/dist/identity/types.d.ts.map +1 -1
- package/dist/index.d.ts +19 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ingress/loopback.d.ts +1 -0
- package/dist/ingress/loopback.d.ts.map +1 -1
- package/dist/ingress/loopback.js +4 -1
- package/dist/ingress/loopback.js.map +1 -1
- package/dist/ingress/runs.d.ts +74 -1
- package/dist/ingress/runs.d.ts.map +1 -1
- package/dist/ingress/runs.js +184 -9
- package/dist/ingress/runs.js.map +1 -1
- package/dist/managed-composition.d.ts +3 -0
- package/dist/managed-composition.d.ts.map +1 -1
- package/dist/managed-composition.js +29 -13
- package/dist/managed-composition.js.map +1 -1
- package/dist/namespaces.d.ts +25 -21
- package/dist/namespaces.d.ts.map +1 -1
- package/dist/namespaces.js +4 -29
- package/dist/namespaces.js.map +1 -1
- package/dist/runtime/async-subagents.d.ts +27 -0
- package/dist/runtime/async-subagents.d.ts.map +1 -0
- package/dist/runtime/async-subagents.js +139 -0
- package/dist/runtime/async-subagents.js.map +1 -0
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +177 -8
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/backend.d.ts +7 -0
- package/dist/runtime/backend.d.ts.map +1 -1
- package/dist/runtime/backend.js +32 -7
- package/dist/runtime/backend.js.map +1 -1
- package/dist/runtime/browser-facade.d.ts +10 -0
- package/dist/runtime/browser-facade.d.ts.map +1 -0
- package/dist/runtime/browser-facade.js +23 -0
- package/dist/runtime/browser-facade.js.map +1 -0
- package/dist/runtime/browser-langsmith.d.ts +143 -0
- package/dist/runtime/browser-langsmith.d.ts.map +1 -0
- package/dist/runtime/browser-langsmith.js +508 -0
- package/dist/runtime/browser-langsmith.js.map +1 -0
- package/dist/runtime/browser-recorder.py +831 -0
- package/dist/runtime/browser-selection.d.ts +10 -0
- package/dist/runtime/browser-selection.d.ts.map +1 -0
- package/dist/runtime/browser-selection.js +21 -0
- package/dist/runtime/browser-selection.js.map +1 -0
- package/dist/runtime/browser.d.ts +128 -0
- package/dist/runtime/browser.d.ts.map +1 -0
- package/dist/runtime/browser.js +795 -0
- package/dist/runtime/browser.js.map +1 -0
- package/dist/runtime/channel-icon.d.ts +22 -0
- package/dist/runtime/channel-icon.d.ts.map +1 -0
- package/dist/runtime/channel-icon.js +81 -0
- package/dist/runtime/channel-icon.js.map +1 -0
- package/dist/runtime/channel.d.ts +11 -0
- package/dist/runtime/channel.d.ts.map +1 -1
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/identity-http.d.ts +2 -0
- package/dist/runtime/identity-http.d.ts.map +1 -1
- package/dist/runtime/identity-http.js +38 -2
- package/dist/runtime/identity-http.js.map +1 -1
- package/dist/runtime/identity-runtime.d.ts +10 -0
- package/dist/runtime/identity-runtime.d.ts.map +1 -1
- package/dist/runtime/identity-runtime.js +22 -0
- package/dist/runtime/identity-runtime.js.map +1 -1
- package/dist/runtime/index.d.ts +38 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +76 -34
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/managed-thread.d.ts +61 -0
- package/dist/runtime/managed-thread.d.ts.map +1 -0
- package/dist/runtime/managed-thread.js +76 -0
- package/dist/runtime/managed-thread.js.map +1 -0
- package/dist/runtime/managed-tools.d.ts +10 -2
- package/dist/runtime/managed-tools.d.ts.map +1 -1
- package/dist/runtime/managed-tools.js +10 -8
- package/dist/runtime/managed-tools.js.map +1 -1
- package/dist/runtime/memory/policy.d.ts.map +1 -1
- package/dist/runtime/memory/policy.js +1 -3
- package/dist/runtime/memory/policy.js.map +1 -1
- package/dist/runtime/parent-run.d.ts +33 -0
- package/dist/runtime/parent-run.d.ts.map +1 -0
- package/dist/runtime/parent-run.js +114 -0
- package/dist/runtime/parent-run.js.map +1 -0
- package/dist/runtime/run-input.d.ts +14 -1
- package/dist/runtime/run-input.d.ts.map +1 -1
- package/dist/runtime/run-input.js +100 -7
- package/dist/runtime/run-input.js.map +1 -1
- package/dist/runtime/sandbox-manager.d.ts +15 -2
- package/dist/runtime/sandbox-manager.d.ts.map +1 -1
- package/dist/runtime/sandbox-manager.js +37 -12
- package/dist/runtime/sandbox-manager.js.map +1 -1
- package/dist/runtime/schedule-auth.d.ts +79 -0
- package/dist/runtime/schedule-auth.d.ts.map +1 -1
- package/dist/runtime/schedule-auth.js +163 -6
- package/dist/runtime/schedule-auth.js.map +1 -1
- package/dist/runtime/static-entry.d.ts.map +1 -1
- package/dist/runtime/static-entry.js +1 -0
- package/dist/runtime/static-entry.js.map +1 -1
- package/dist/runtime/thread-record.d.ts +88 -0
- package/dist/runtime/thread-record.d.ts.map +1 -0
- package/dist/runtime/thread-record.js +213 -0
- package/dist/runtime/thread-record.js.map +1 -0
- package/dist/runtime/types.d.ts +12 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime-schedules.d.ts +15 -0
- package/dist/runtime-schedules.d.ts.map +1 -1
- package/dist/runtime-schedules.js +83 -20
- package/dist/runtime-schedules.js.map +1 -1
- package/dist/sandbox.d.ts +0 -6
- package/dist/sandbox.d.ts.map +1 -1
- package/dist/sandbox.js +3 -7
- package/dist/sandbox.js.map +1 -1
- package/dist/schedule-channel.d.ts +5 -2
- package/dist/schedule-channel.d.ts.map +1 -1
- package/dist/schedule-channel.js +13 -12
- package/dist/schedule-channel.js.map +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/values.d.ts +15 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +31 -0
- package/dist/values.js.map +1 -0
- package/package.json +23 -9
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser use: Stagehand's TypeScript facade tools on a LangSmith browser.
|
|
3
|
+
*
|
|
4
|
+
* A browser per key (`thread:<id>`, or `user:<id>` under user scope). A
|
|
5
|
+
* thread's own browser has one window, `mda`; a user's has a window per
|
|
6
|
+
* thread, so their threads share logins but not tabs. Each browser has one
|
|
7
|
+
* Stagehand client in this process (the extension serves one at a time),
|
|
8
|
+
* released between runs and after a minute idle so the browser can idle out.
|
|
9
|
+
*
|
|
10
|
+
* `browser_handoff` hands the window to the user and returns at once. A run of
|
|
11
|
+
* the watcher graph, which `mda` builds beside the agent, waits on its own
|
|
12
|
+
* thread for the user to hand it back, then enqueues a run on the agent's
|
|
13
|
+
* thread with a notification, as an async subagent tells the agent that
|
|
14
|
+
* started it that it finished.
|
|
15
|
+
*
|
|
16
|
+
* The middleware half of Python's `browser.py`; `browser-langsmith.ts` holds
|
|
17
|
+
* the LangSmith half. Only projects that declare `browser.ts` import this
|
|
18
|
+
* module or install Stagehand.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
22
|
+
import { join, posix, relative, sep } from "node:path";
|
|
23
|
+
import { SystemMessage, ToolMessage, } from "@langchain/core/messages";
|
|
24
|
+
import { tool } from "@langchain/core/tools";
|
|
25
|
+
import { Annotation, Command, END, START, StateGraph, } from "@langchain/langgraph";
|
|
26
|
+
import { Client } from "@langchain/langgraph-sdk";
|
|
27
|
+
import { isSandboxBackend, } from "deepagents";
|
|
28
|
+
import { createMiddleware } from "langchain";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { CodeModeRunInputSchema, FACADE_AGENT_INSTRUCTIONS, FACADE_TOOLS, sanitizeErrorMessage, StagehandFacadeExecutionError, StagehandFacadeInputError, } from "./browser-facade.js";
|
|
31
|
+
import { browserBox, closeSession, handOver, HANDOFF_LINK_TTL_S, handoffStatus, newestTab, openSession, RecorderError, shareLocalhost, PendingBrowserHandoffSchema, shellQuote, startTake, stopTake, } from "./browser-langsmith.js";
|
|
32
|
+
import { LazyManagedSandboxBackend } from "./backend.js";
|
|
33
|
+
import { logger } from "./log.js";
|
|
34
|
+
import { verifiedLeadThread } from "./parent-run.js";
|
|
35
|
+
import { MDA_NOTIFICATION_KEY } from "../channels/assistant-text.js";
|
|
36
|
+
const WORKSPACE = "/workspace"; // where run code's screenshots land in the sandbox
|
|
37
|
+
const INSTRUCTIONS =
|
|
38
|
+
// The facade's text and errors name its tools `run` and so on; ours gain `browser_`.
|
|
39
|
+
"These tools are named browser_run, browser_snapshot and browser_screenshot: " +
|
|
40
|
+
"call those wherever this text or an error names run, snapshot or screenshot.\n" +
|
|
41
|
+
"Screenshots that run code saves go to /workspace in your sandbox: " +
|
|
42
|
+
"`await page.screenshot({ path: 'shots/a.png' })` saves /workspace/shots/a.png.";
|
|
43
|
+
const SCREENSHOT_PATH = "Only if you have a sandbox: also save the image to this absolute path in it, " +
|
|
44
|
+
"creating its folder, e.g. to publish it.";
|
|
45
|
+
const LOCALHOST_NOTE = // a thread's browser shares its sandbox's localhost
|
|
46
|
+
"Servers running in your sandbox are reachable at http://localhost:<port> " +
|
|
47
|
+
"(any port but 80 and 443).";
|
|
48
|
+
const RECORDINGS = `${WORKSPACE}/recordings`;
|
|
49
|
+
// The recorder's own limits, which Python's `_browser_recording.py` sets: it runs in
|
|
50
|
+
// the browser's sandbox, so this process never imports it.
|
|
51
|
+
const RECORD_VIEWPORT = [1280, 800];
|
|
52
|
+
const RECORD_MAX_HOLD_S = 1.5;
|
|
53
|
+
const RECORD_MAX_S = 300;
|
|
54
|
+
const RECORD_DESCRIPTION = `Record the browser tab as a video, to show a person what you did.
|
|
55
|
+
|
|
56
|
+
\`start\`, act as usual, then \`stop\`: it saves an H.264 mp4 to the \`path\` you gave
|
|
57
|
+
either, in your sandbox (default ${RECORDINGS}/<time>.mp4), with what happened when
|
|
58
|
+
beside it as JSON (the same path, ending .json), and shows you its first and last
|
|
59
|
+
frames. The video shows a cursor
|
|
60
|
+
gliding to each click, the clicks, and what is typed (masked in password fields). While
|
|
61
|
+
recording, the viewport is ${RECORD_VIEWPORT[0]}x${RECORD_VIEWPORT[1]}, and each
|
|
62
|
+
pause between your calls plays for at most ${RECORD_MAX_HOLD_S}s.
|
|
63
|
+
A recording also ends, and is saved, when the run ends or you hand the browser to
|
|
64
|
+
the user: it never shows what they do. It stops at
|
|
65
|
+
${RECORD_MAX_S / 60} minutes.`;
|
|
66
|
+
const RECORD_SCHEMA = {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
action: { type: "string", enum: ["start", "stop"] },
|
|
70
|
+
path: { type: "string" },
|
|
71
|
+
},
|
|
72
|
+
required: ["action"],
|
|
73
|
+
};
|
|
74
|
+
const HANDOFF_DESCRIPTION = "Hand the browser to the user; you'll be notified when they hand it back.\n\n" +
|
|
75
|
+
"Use it for what only they should do: log in, solve a CAPTCHA, approve a " +
|
|
76
|
+
"2FA prompt. Never ask for passwords. `reason` is shown to them as is: a " +
|
|
77
|
+
'few words on what to do, such as "Log in to LinkedIn".';
|
|
78
|
+
const HAND_BACK_POLL_MS = 2_000;
|
|
79
|
+
const WITH_USER = "The browser is with the user until they hand it back; wait for the notification " +
|
|
80
|
+
"that they did before using it.";
|
|
81
|
+
const HANDED_OVER = // what the model reads from browser_handoff
|
|
82
|
+
"The browser is with the user. You'll get a notification when they hand it back; " +
|
|
83
|
+
"until then, don't use the browser. Tell the user what to do in it.";
|
|
84
|
+
/** Clients such as Studio show a notification's first sentence, so it reads for both. */
|
|
85
|
+
const WAKE_TEXT = {
|
|
86
|
+
handed_back: "The browser was handed back. Take a snapshot of the page; if the user hasn't " +
|
|
87
|
+
"finished, call browser_handoff again.",
|
|
88
|
+
ended: "The browser handoff ended. Take a snapshot to see whether the user finished.",
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The thread's handoff that waits on the user, as Python's `mda_browser_handoff`.
|
|
92
|
+
* A leading `_` keeps it out of run input, as Python's `OmitFromInput` does.
|
|
93
|
+
*/
|
|
94
|
+
const HANDOFF_KEY = "_mda_browser_handoff";
|
|
95
|
+
/** The graph `mda` builds beside the agent, whose run waits out a handoff. */
|
|
96
|
+
export const HANDOFF_WATCHER_GRAPH = "mda-browser-handoff";
|
|
97
|
+
/** Where deepagents puts the reference to the agent that started a run, which isn't forwarded. */
|
|
98
|
+
const PARENT_REFERENCE_KEY = "deepagents_parent";
|
|
99
|
+
const RELEASE_AFTER_MS = 60_000; // a run that throws or interrupts never reaches afterAgent
|
|
100
|
+
const BrowserStateSchema = z.object({
|
|
101
|
+
[HANDOFF_KEY]: PendingBrowserHandoffSchema.nullable().optional(),
|
|
102
|
+
});
|
|
103
|
+
// Process-wide: a user's concurrent threads share their browser's one client.
|
|
104
|
+
const sessions = new Map();
|
|
105
|
+
// By browser key, for its client; by `takeKey`, for the window's take, taken
|
|
106
|
+
// first. A take starts and ends in turn, and saving one holds up no other window.
|
|
107
|
+
const locks = new Map();
|
|
108
|
+
const releases = new Map();
|
|
109
|
+
// By browser key and window: a user's threads record their own windows.
|
|
110
|
+
const takes = new Map();
|
|
111
|
+
async function release(key) {
|
|
112
|
+
const session = sessions.get(key);
|
|
113
|
+
sessions.delete(key);
|
|
114
|
+
if (session)
|
|
115
|
+
await closeSession(session);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run code's `browser.close()`: release the client once the tool call is done.
|
|
119
|
+
*
|
|
120
|
+
* The facade asks before the run tool has moved the screenshots that code saved.
|
|
121
|
+
*/
|
|
122
|
+
async function closeAfterCall(key) {
|
|
123
|
+
const session = sessions.get(key);
|
|
124
|
+
if (session)
|
|
125
|
+
session.closing = true;
|
|
126
|
+
}
|
|
127
|
+
/** Release `key`'s client a minute after its last call; each call restarts the wait. */
|
|
128
|
+
function releaseWhenIdle(key) {
|
|
129
|
+
clearTimeout(releases.get(key));
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
withLock(key, async () => {
|
|
132
|
+
if (releases.get(key) !== timer)
|
|
133
|
+
return; // a later call restarted the wait
|
|
134
|
+
releases.delete(key);
|
|
135
|
+
await release(key);
|
|
136
|
+
});
|
|
137
|
+
}, RELEASE_AFTER_MS).unref(); // never keeps the process alive
|
|
138
|
+
releases.set(key, timer);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Give the agent Stagehand's tools, named `browser_run`, `browser_snapshot` and
|
|
142
|
+
* so on, plus `browser_record` for a video.
|
|
143
|
+
*/
|
|
144
|
+
export function createBrowserMiddleware(options) {
|
|
145
|
+
const { scope } = options;
|
|
146
|
+
const tools = browserTools(options);
|
|
147
|
+
const accepted = new Map(tools.map((t) => [t.name, Object.keys(t.schema.properties)]));
|
|
148
|
+
let instructions = `${FACADE_AGENT_INSTRUCTIONS}\n\n${INSTRUCTIONS}`;
|
|
149
|
+
if (scope === "thread")
|
|
150
|
+
instructions += ` ${LOCALHOST_NOTE}`;
|
|
151
|
+
return createMiddleware({
|
|
152
|
+
name: "mda-browser",
|
|
153
|
+
stateSchema: BrowserStateSchema,
|
|
154
|
+
tools,
|
|
155
|
+
// JSON-schema tools turn away input their schema rejects, with a stack trace,
|
|
156
|
+
// before their function runs. Answer it as Python's tools and facade do: names
|
|
157
|
+
// a tool lacks, such as snake_case ones, and bad run input. Like them, take a
|
|
158
|
+
// null argument as left out; some models send every argument.
|
|
159
|
+
async wrapToolCall(request, handler) {
|
|
160
|
+
const { name, args, id } = request.toolCall;
|
|
161
|
+
const names = accepted.get(name);
|
|
162
|
+
if (names === undefined)
|
|
163
|
+
return handler(request); // not a browser tool
|
|
164
|
+
const extra = Object.keys(args).filter((arg) => !names.includes(arg));
|
|
165
|
+
const given = Object.fromEntries(Object.entries(args).filter(([, value]) => value !== null));
|
|
166
|
+
let error = extra.length > 0 ? unexpected(extra) : undefined;
|
|
167
|
+
if (name === "browser_run")
|
|
168
|
+
error ??= invalidRun(given);
|
|
169
|
+
if (error === undefined) {
|
|
170
|
+
return handler({
|
|
171
|
+
...request,
|
|
172
|
+
toolCall: { ...request.toolCall, args: given },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return new ToolMessage({ content: error, tool_call_id: id ?? "", name });
|
|
176
|
+
},
|
|
177
|
+
// A run the handoff's notification starts ends the handoff.
|
|
178
|
+
async beforeAgent(state) {
|
|
179
|
+
const messages = state.messages ?? [];
|
|
180
|
+
const last = messages.at(-1);
|
|
181
|
+
if (pending(state) && last && isHandoffNotification(last)) {
|
|
182
|
+
return { [HANDOFF_KEY]: null };
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
},
|
|
186
|
+
// Save the run's recording and release the client; the browser and windows stay.
|
|
187
|
+
async afterAgent(_state, runtime) {
|
|
188
|
+
const found = locate(runtime, scope);
|
|
189
|
+
if (typeof found === "string")
|
|
190
|
+
return; // no thread or user, so no session
|
|
191
|
+
const { key, window } = found;
|
|
192
|
+
await withLock(takeKey(key, window), () => endTake(key, window));
|
|
193
|
+
await withLock(key, () => release(key));
|
|
194
|
+
},
|
|
195
|
+
// Append the facade's instructions to the system message, as their own block.
|
|
196
|
+
async wrapModelCall(request, handler) {
|
|
197
|
+
const content = request.systemMessage?.content ?? request.systemPrompt;
|
|
198
|
+
const blocks = typeof content === "string"
|
|
199
|
+
? content
|
|
200
|
+
? [{ type: "text", text: content }]
|
|
201
|
+
: []
|
|
202
|
+
: (content ?? []);
|
|
203
|
+
return handler({
|
|
204
|
+
...request,
|
|
205
|
+
systemMessage: new SystemMessage({
|
|
206
|
+
content: [...blocks, { type: "text", text: instructions }],
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Run `fn` on the run's session. Errors go to the model; nothing is retried.
|
|
214
|
+
*
|
|
215
|
+
* While the user has the window, the agent waits.
|
|
216
|
+
*/
|
|
217
|
+
async function call(options, runtime, fn) {
|
|
218
|
+
// The handoff is asked about here too: after a restart no watcher says it ended.
|
|
219
|
+
const handoff = pending(runtime.state);
|
|
220
|
+
if (handoff && (await handoffStatus(handoff)) === undefined) {
|
|
221
|
+
return `error: ${WITH_USER}`;
|
|
222
|
+
}
|
|
223
|
+
const found = locate(runtime, options.scope);
|
|
224
|
+
if (typeof found === "string")
|
|
225
|
+
return found;
|
|
226
|
+
const { key, window } = found;
|
|
227
|
+
return withLock(key, async () => {
|
|
228
|
+
try {
|
|
229
|
+
if (sessions.get(key)?.window !== window) {
|
|
230
|
+
await release(key); // another of the user's threads: move windows
|
|
231
|
+
const ttl = options.idleTtlSeconds;
|
|
232
|
+
const opened = openSession(key, window, ttl, () => closeAfterCall(key));
|
|
233
|
+
sessions.set(key, await opened);
|
|
234
|
+
}
|
|
235
|
+
const session = sessions.get(key);
|
|
236
|
+
await share(session, key, runtime);
|
|
237
|
+
return await fn(session);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return await failed(key, err);
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
if (sessions.get(key)?.closing)
|
|
244
|
+
await release(key);
|
|
245
|
+
releaseWhenIdle(key);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Give the user the window and return at once, keeping the handoff in state.
|
|
251
|
+
*
|
|
252
|
+
* It needs no client, only the browsers API: a window a person controls turns
|
|
253
|
+
* a client away, so a second handoff could not connect. The agent's client
|
|
254
|
+
* lets go, and the next browser call connects afresh and takes the window back.
|
|
255
|
+
*/
|
|
256
|
+
async function handToUser(options, runtime, reason) {
|
|
257
|
+
const found = locate(runtime, options.scope);
|
|
258
|
+
if (typeof found === "string")
|
|
259
|
+
return found;
|
|
260
|
+
const { thread, key, window } = found;
|
|
261
|
+
let ended;
|
|
262
|
+
let handed;
|
|
263
|
+
try {
|
|
264
|
+
// No take starts meanwhile.
|
|
265
|
+
handed = await withLock(takeKey(key, window), async () => {
|
|
266
|
+
ended = await endTake(key, window); // saved before the user has the window
|
|
267
|
+
return withLock(key, async () => {
|
|
268
|
+
await release(key);
|
|
269
|
+
return handOver(key, window, options.idleTtlSeconds);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
const failed = `error: ${sanitizeErrorMessage(errorText(err))}`;
|
|
275
|
+
return withRecording(failed, ended);
|
|
276
|
+
}
|
|
277
|
+
const handoff = {
|
|
278
|
+
browser: handed.browser,
|
|
279
|
+
window_id: handed.windowId,
|
|
280
|
+
until: Date.now() / 1000 + HANDOFF_LINK_TTL_S,
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
await startWatcher(loopback(), runtime, handoff);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
// The browser tools still work once it ended.
|
|
287
|
+
const label = logger.safeErrorLabel(err);
|
|
288
|
+
logger.warn(`could not watch the handoff on thread ${thread}: ${label}`);
|
|
289
|
+
}
|
|
290
|
+
// What clients such as Studio show the user: the ask, and a link that lets
|
|
291
|
+
// whoever holds it drive the window, for 15 minutes. The model never sees it.
|
|
292
|
+
const reply = new ToolMessage({
|
|
293
|
+
content: withRecording(HANDED_OVER, ended),
|
|
294
|
+
artifact: { type: "browser_handoff", message: reason, url: handed.url },
|
|
295
|
+
name: "browser_handoff",
|
|
296
|
+
tool_call_id: runtime.toolCallId ?? "",
|
|
297
|
+
});
|
|
298
|
+
return new Command({ update: { [HANDOFF_KEY]: handoff, messages: [reply] } });
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Start a run of the watcher graph on a thread of its own, as deepagents starts
|
|
302
|
+
* an async subagent's task: the thread's metadata names this agent, which the
|
|
303
|
+
* Agent Server copies into the run, and the run gets this run's context.
|
|
304
|
+
*
|
|
305
|
+
* The wait is a run rather than a promise in this process, so the wake-up comes
|
|
306
|
+
* from a live run, the only caller a deployed graph's Agent Server takes.
|
|
307
|
+
*/
|
|
308
|
+
export async function startWatcher(client, runtime, handoff) {
|
|
309
|
+
const thread = await client.threads.create({
|
|
310
|
+
metadata: {
|
|
311
|
+
parent_thread_id: runtime.configurable?.thread_id,
|
|
312
|
+
parent_assistant_id: runtime.serverInfo?.assistantId || runtime.serverInfo?.graphId,
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const context = forwardedContext(runtime.context);
|
|
316
|
+
await client.runs.create(thread.thread_id, HANDOFF_WATCHER_GRAPH, {
|
|
317
|
+
input: { handoff },
|
|
318
|
+
...(context && { context }),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const WatcherState = Annotation.Root({
|
|
322
|
+
handoff: Annotation(),
|
|
323
|
+
});
|
|
324
|
+
/**
|
|
325
|
+
* Once the handoff ends, notify the agent that started it, unless it has moved
|
|
326
|
+
* on: a newer handoff replaced this one, or the agent waits on a person, where
|
|
327
|
+
* a new run would drop what it waits for. Its browser tools work again either way.
|
|
328
|
+
*/
|
|
329
|
+
export async function watch(state, config) {
|
|
330
|
+
const { handoff } = state;
|
|
331
|
+
let status;
|
|
332
|
+
while (status === undefined) {
|
|
333
|
+
await new Promise((resolve) => {
|
|
334
|
+
setTimeout(resolve, HAND_BACK_POLL_MS);
|
|
335
|
+
});
|
|
336
|
+
status = await handoffStatus(handoff);
|
|
337
|
+
}
|
|
338
|
+
const { threadId, assistantId } = await launchingAgent(config);
|
|
339
|
+
if (!threadId || !assistantId)
|
|
340
|
+
return {};
|
|
341
|
+
const client = loopback();
|
|
342
|
+
const thread = await client.threads.get(threadId);
|
|
343
|
+
if (thread.status === "interrupted" ||
|
|
344
|
+
!sameHandoff(pending(thread.values), handoff)) {
|
|
345
|
+
return {};
|
|
346
|
+
}
|
|
347
|
+
const context = forwardedContext(config.context);
|
|
348
|
+
await client.runs.create(threadId, assistantId, {
|
|
349
|
+
input: { messages: [wakeMessage(status)] },
|
|
350
|
+
multitaskStrategy: "enqueue", // after a run that is going on
|
|
351
|
+
...(context && { context }),
|
|
352
|
+
});
|
|
353
|
+
return {};
|
|
354
|
+
}
|
|
355
|
+
/** The watcher graph: one run per handoff, from `browser_handoff` until it ends. */
|
|
356
|
+
export const handoffWatcher = new StateGraph(WatcherState)
|
|
357
|
+
.addNode("watch", watch)
|
|
358
|
+
.addEdge(START, "watch")
|
|
359
|
+
.addEdge("watch", END)
|
|
360
|
+
.compile();
|
|
361
|
+
/**
|
|
362
|
+
* The agent that started this run, as an async subagent's run finds its lead:
|
|
363
|
+
* its thread, once this run's caller is verified to own it, and its assistant,
|
|
364
|
+
* from the thread's metadata the Agent Server copies into every run on it.
|
|
365
|
+
*/
|
|
366
|
+
async function launchingAgent(config) {
|
|
367
|
+
const lead = await verifiedLeadThread(config);
|
|
368
|
+
const assistantId = config.metadata?.parent_assistant_id;
|
|
369
|
+
return {
|
|
370
|
+
threadId: lead?.id,
|
|
371
|
+
assistantId: typeof assistantId === "string" ? assistantId : undefined,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/** A run's context for another run, without deepagents' parent reference, as deepagents forwards it. */
|
|
375
|
+
function forwardedContext(context) {
|
|
376
|
+
if (!context || typeof context !== "object")
|
|
377
|
+
return undefined;
|
|
378
|
+
const forwarded = Object.fromEntries(Object.entries(context).filter(([key]) => key !== PARENT_REFERENCE_KEY));
|
|
379
|
+
return Object.keys(forwarded).length > 0 ? forwarded : undefined;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* This Agent Server, called as the user who started the run, as Python's
|
|
383
|
+
* in-process `get_client()` is. With no `apiUrl`, the client takes the Agent
|
|
384
|
+
* Server's loopback, which marks each call with the live run so the server
|
|
385
|
+
* restores that run's user; managed auth never sees the call. It answers only
|
|
386
|
+
* while the run is live.
|
|
387
|
+
*/
|
|
388
|
+
function loopback() {
|
|
389
|
+
return new Client();
|
|
390
|
+
}
|
|
391
|
+
function pending(state) {
|
|
392
|
+
const value = state && typeof state === "object"
|
|
393
|
+
? state[HANDOFF_KEY]
|
|
394
|
+
: undefined;
|
|
395
|
+
return (value ?? undefined);
|
|
396
|
+
}
|
|
397
|
+
function sameHandoff(a, b) {
|
|
398
|
+
return (a?.browser === b.browser &&
|
|
399
|
+
a.window_id === b.window_id &&
|
|
400
|
+
a.until === b.until);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* A notification in deepagents' task notification's shape: a user-role message
|
|
404
|
+
* framed as a system notification. Its `mda_notification` tells clients how the
|
|
405
|
+
* handoff ended, so they can tell it from the user's message.
|
|
406
|
+
*/
|
|
407
|
+
export function wakeMessage(status) {
|
|
408
|
+
return {
|
|
409
|
+
role: "user",
|
|
410
|
+
content: `[SYSTEM NOTIFICATION - NOT USER INPUT]\n${WAKE_TEXT[status]}`,
|
|
411
|
+
additional_kwargs: {
|
|
412
|
+
[MDA_NOTIFICATION_KEY]: { type: "browser_handoff", status },
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function isHandoffNotification(message) {
|
|
417
|
+
const notification = message.additional_kwargs?.[MDA_NOTIFICATION_KEY];
|
|
418
|
+
return notification?.type === "browser_handoff";
|
|
419
|
+
}
|
|
420
|
+
/** What the model gets for `err`, releasing the session if its connection may be dead. */
|
|
421
|
+
async function failed(key, err) {
|
|
422
|
+
if (err instanceof StagehandFacadeInputError)
|
|
423
|
+
return `error: ${err.message}`;
|
|
424
|
+
if (err instanceof StagehandFacadeExecutionError) {
|
|
425
|
+
return `error: ${err.name}: ${err.message}`; // thrown by run code
|
|
426
|
+
}
|
|
427
|
+
// The connection may be dead, unless the browser answered or the facade turned
|
|
428
|
+
// away run code that does not parse, with a plain SyntaxError. Stagehand rejects
|
|
429
|
+
// a JSON-RPC error reply (Python's RPCError) with the reply, and its code, as cause.
|
|
430
|
+
const reply = err instanceof Error ? err.cause : null;
|
|
431
|
+
if (typeof reply?.code !== "number" && !(err instanceof SyntaxError)) {
|
|
432
|
+
await release(key);
|
|
433
|
+
}
|
|
434
|
+
return `error: ${sanitizeErrorMessage(errorText(err))}`;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Share the thread's sandbox's localhost with its browser, once that sandbox exists.
|
|
438
|
+
*
|
|
439
|
+
* Runs before every browser call. Only a thread's browser shares: a user's
|
|
440
|
+
* serves several threads, whose sandboxes cannot all be `localhost`.
|
|
441
|
+
*/
|
|
442
|
+
async function share(session, key, runtime) {
|
|
443
|
+
const sandbox = key.startsWith("thread:")
|
|
444
|
+
? threadSandbox(runtime.backend)
|
|
445
|
+
: undefined;
|
|
446
|
+
if (sandbox === undefined || session.shared === sandbox)
|
|
447
|
+
return;
|
|
448
|
+
try {
|
|
449
|
+
await shareLocalhost(session.browser, sandbox.id);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
// Browsing goes on; tried again next call.
|
|
453
|
+
const label = logger.safeErrorLabel(err);
|
|
454
|
+
logger.warn(`could not share sandbox ${sandbox.id}'s localhost with ${session.browser} (${label})`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
session.shared = sandbox;
|
|
458
|
+
}
|
|
459
|
+
/** The facade's tools as its hosts advertise them, names prefixed `browser_`. */
|
|
460
|
+
function browserTools(options) {
|
|
461
|
+
const facade = (name) => {
|
|
462
|
+
const entry = FACADE_TOOLS.find((candidate) => candidate.name === name);
|
|
463
|
+
const { description, inputSchema: schema } = entry;
|
|
464
|
+
return { name: `browser_${name}`, description, schema };
|
|
465
|
+
};
|
|
466
|
+
const screenshot = facade("screenshot");
|
|
467
|
+
const path = { type: "string", description: SCREENSHOT_PATH };
|
|
468
|
+
const properties = { ...screenshot.schema.properties, path };
|
|
469
|
+
const run = tool(async (input, config) => {
|
|
470
|
+
const { code, actions } = input;
|
|
471
|
+
const runtime = config;
|
|
472
|
+
return call(options, runtime, async (session) => {
|
|
473
|
+
let value;
|
|
474
|
+
let saved;
|
|
475
|
+
try {
|
|
476
|
+
value =
|
|
477
|
+
code === undefined
|
|
478
|
+
? await session.tools.runActions(actions)
|
|
479
|
+
: await session.tools.run(code);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
// Code that threw may have saved some first.
|
|
483
|
+
saved = await saveArtifacts(runtime.backend, session.artifacts);
|
|
484
|
+
}
|
|
485
|
+
return [JSON.stringify(value ?? null), ...saved].join("\n");
|
|
486
|
+
});
|
|
487
|
+
}, facade("run"));
|
|
488
|
+
const snapshot = tool(async (input, config) => {
|
|
489
|
+
const includeIframes = input.includeIframes ?? true;
|
|
490
|
+
return call(options, config, (session) => session.tools.snapshot({ includeIframes }));
|
|
491
|
+
}, facade("snapshot"));
|
|
492
|
+
const shoot = tool(async (input, config) => {
|
|
493
|
+
const { fullPage, type = "png", quality, path } = input;
|
|
494
|
+
const runtime = config;
|
|
495
|
+
const shot = await call(options, runtime, (session) => session.tools.screenshot({ fullPage, type, quality }));
|
|
496
|
+
if (typeof shot === "string")
|
|
497
|
+
return shot;
|
|
498
|
+
let text = "Screenshot of the current page.";
|
|
499
|
+
if (path) {
|
|
500
|
+
const data = Buffer.from(shot.data, "base64");
|
|
501
|
+
text += ` ${await save(runtime.backend, path, data)}`;
|
|
502
|
+
}
|
|
503
|
+
const url = `data:${shot.mimeType};base64,${shot.data}`;
|
|
504
|
+
return [
|
|
505
|
+
{ type: "text", text },
|
|
506
|
+
{ type: "image", url },
|
|
507
|
+
];
|
|
508
|
+
}, { ...screenshot, schema: { ...screenshot.schema, properties } });
|
|
509
|
+
const handoff = tool(async (input, config) => handToUser(options, config, input.reason), {
|
|
510
|
+
name: "browser_handoff",
|
|
511
|
+
description: HANDOFF_DESCRIPTION,
|
|
512
|
+
schema: {
|
|
513
|
+
type: "object",
|
|
514
|
+
properties: {
|
|
515
|
+
reason: { type: "string" },
|
|
516
|
+
},
|
|
517
|
+
required: ["reason"],
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
return [run, snapshot, shoot, handoff, recordTool(options)];
|
|
521
|
+
}
|
|
522
|
+
/** `browser_record`: start recording the window's tab, or stop and save it. */
|
|
523
|
+
function recordTool(options) {
|
|
524
|
+
return tool(async (input, config) => {
|
|
525
|
+
const runtime = config;
|
|
526
|
+
const found = locate(runtime, options.scope);
|
|
527
|
+
if (typeof found === "string")
|
|
528
|
+
return found;
|
|
529
|
+
const where = takeKey(found.key, found.window);
|
|
530
|
+
const { path } = input;
|
|
531
|
+
if (path !== undefined && !posix.isAbsolute(path)) {
|
|
532
|
+
// Before a take starts or ends, so none is lost.
|
|
533
|
+
if (input.action === "start") {
|
|
534
|
+
return `error: ${path} is not an absolute path. Recording not started.`;
|
|
535
|
+
}
|
|
536
|
+
return `error: ${path} not saved: give an absolute path. Still recording.`;
|
|
537
|
+
}
|
|
538
|
+
return withLock(where, async () => {
|
|
539
|
+
if (input.action === "start") {
|
|
540
|
+
if (runtime.backend == null) {
|
|
541
|
+
return "error: this agent has no sandbox to save a recording in. Recording not started.";
|
|
542
|
+
}
|
|
543
|
+
return call(options, runtime, (session) => startRecording(session, where, runtime.backend, path));
|
|
544
|
+
}
|
|
545
|
+
const take = takes.get(where);
|
|
546
|
+
if (take === undefined) {
|
|
547
|
+
return ("error: not recording. A recording ends when the run ends or you " +
|
|
548
|
+
"hand the browser to the user, and is saved where `start` said, " +
|
|
549
|
+
`or under ${RECORDINGS}/.`);
|
|
550
|
+
}
|
|
551
|
+
takes.delete(where);
|
|
552
|
+
return finishTake(take, path || take.path);
|
|
553
|
+
});
|
|
554
|
+
}, {
|
|
555
|
+
name: "browser_record",
|
|
556
|
+
description: RECORD_DESCRIPTION,
|
|
557
|
+
schema: RECORD_SCHEMA,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
/** Start recording the session's window, beside Chrome, unless it is already. */
|
|
561
|
+
async function startRecording(session, where, backend, path) {
|
|
562
|
+
if (takes.has(where)) {
|
|
563
|
+
return "error: already recording. Stop this recording first.";
|
|
564
|
+
}
|
|
565
|
+
const tab = await newestTab(session.stagehand.browser.context);
|
|
566
|
+
try {
|
|
567
|
+
const box = await browserBox(session.browser);
|
|
568
|
+
const folder = await startTake(box, session.localUrl, tab.pageId);
|
|
569
|
+
takes.set(where, { browser: session.browser, folder, backend, path });
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
if (!(err instanceof RecorderError))
|
|
573
|
+
throw err; // `call` answers the rest
|
|
574
|
+
return `error: ${sanitizeErrorMessage(err.message)}`; // the client is fine
|
|
575
|
+
}
|
|
576
|
+
const [width, height] = RECORD_VIEWPORT;
|
|
577
|
+
return (`Recording. The viewport is ${width}x${height} until you stop, ` +
|
|
578
|
+
"so take a browser_snapshot before acting on the page.");
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Save the window's recording, if any, where `start` said or under recordings/.
|
|
582
|
+
* Call it under the window's lock.
|
|
583
|
+
*
|
|
584
|
+
* The run's end calls it, and so does `browser_handoff` before it hands the
|
|
585
|
+
* window over: the take is then saved, and the recorder has let go of the
|
|
586
|
+
* window, before the user has it, so a recording never shows what they do.
|
|
587
|
+
* Returns what `finishTake` does, or undefined if nothing was recording.
|
|
588
|
+
*/
|
|
589
|
+
async function endTake(key, window) {
|
|
590
|
+
const where = takeKey(key, window);
|
|
591
|
+
const take = takes.get(where);
|
|
592
|
+
if (take === undefined)
|
|
593
|
+
return undefined;
|
|
594
|
+
takes.delete(where);
|
|
595
|
+
let saved;
|
|
596
|
+
let why = "";
|
|
597
|
+
try {
|
|
598
|
+
saved = await finishTake(take, take.path);
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
// The run ends, or the user gets the window, regardless.
|
|
602
|
+
why = ` (${logger.safeErrorLabel(err)})`;
|
|
603
|
+
saved = `error: ${sanitizeErrorMessage(errorText(err))}`;
|
|
604
|
+
}
|
|
605
|
+
if (typeof saved === "string") {
|
|
606
|
+
logger.warn(`could not save ${key}'s recording${why}`);
|
|
607
|
+
}
|
|
608
|
+
return saved;
|
|
609
|
+
}
|
|
610
|
+
/** `text`, then what became of the recording a handoff ended, if one was running. */
|
|
611
|
+
function withRecording(text, ended) {
|
|
612
|
+
if (ended === undefined)
|
|
613
|
+
return text;
|
|
614
|
+
if (typeof ended === "string") {
|
|
615
|
+
return `${text}\nYour recording ended here: ${ended}`;
|
|
616
|
+
}
|
|
617
|
+
const [note, ...frames] = ended;
|
|
618
|
+
const said = `${text}\nYour recording ended here. ${note.text}`;
|
|
619
|
+
return [{ type: "text", text: said }, ...frames];
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Stop `take` and save it to `path`, or under recordings/ if it can't be saved there.
|
|
623
|
+
*
|
|
624
|
+
* Returns what was saved and the first and last frames, for the model to check.
|
|
625
|
+
*/
|
|
626
|
+
async function finishTake(take, path) {
|
|
627
|
+
let finished;
|
|
628
|
+
try {
|
|
629
|
+
finished = await stopTake(await browserBox(take.browser), take.folder);
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
return `error: ${sanitizeErrorMessage(errorText(err))}`;
|
|
633
|
+
}
|
|
634
|
+
const fallback = `${RECORDINGS}/${timestamp()}.mp4`;
|
|
635
|
+
let video = path || fallback;
|
|
636
|
+
let saved = await save(take.backend, video, finished.video);
|
|
637
|
+
if (saved.startsWith("error:")) {
|
|
638
|
+
// The browser's sandbox no longer has the take, so try once more, under recordings/.
|
|
639
|
+
const missed = sanitizeErrorMessage(saved.slice("error: ".length));
|
|
640
|
+
const moved = await save(take.backend, fallback, finished.video);
|
|
641
|
+
if (moved.startsWith("error:")) {
|
|
642
|
+
return `error: ${missed}. The recording is lost.`;
|
|
643
|
+
}
|
|
644
|
+
saved =
|
|
645
|
+
video === fallback ? moved : `${missed}. ${moved.slice(0, -1)} instead.`;
|
|
646
|
+
video = fallback;
|
|
647
|
+
}
|
|
648
|
+
const named = normpath(video);
|
|
649
|
+
const timeline = `${named.slice(0, named.length - posix.extname(named).length)}.json`;
|
|
650
|
+
const noted = await save(take.backend, timeline, finished.timeline);
|
|
651
|
+
const frame = (jpeg) => {
|
|
652
|
+
const data = Buffer.from(jpeg).toString("base64");
|
|
653
|
+
return { type: "image", url: `data:image/jpeg;base64,${data}` };
|
|
654
|
+
};
|
|
655
|
+
const megabytes = (finished.video.length / 1e6).toFixed(1);
|
|
656
|
+
const seconds = finished.seconds.toFixed(0);
|
|
657
|
+
let text = `${saved.slice(0, -1)}: ${seconds}s, ${megabytes} MB. ${noted}`;
|
|
658
|
+
if (finished.early)
|
|
659
|
+
text += ` ${finished.early}`;
|
|
660
|
+
text += `\nWhat happened, at video time:\n${finished.summary}`;
|
|
661
|
+
text += "\nIts first and last frames follow; check them before sharing it.";
|
|
662
|
+
return [{ type: "text", text }, frame(finished.first), frame(finished.last)];
|
|
663
|
+
}
|
|
664
|
+
/** The local time as Python's `time.strftime("%Y%m%d-%H%M%S")` writes it. */
|
|
665
|
+
function timestamp(now = new Date()) {
|
|
666
|
+
const offset = now.getTimezoneOffset() * 60_000;
|
|
667
|
+
const local = new Date(now.getTime() - offset).toISOString();
|
|
668
|
+
return local.slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
|
|
669
|
+
}
|
|
670
|
+
/** Where a window's take is kept in `takes`. */
|
|
671
|
+
function takeKey(key, window) {
|
|
672
|
+
return JSON.stringify([key, window]);
|
|
673
|
+
}
|
|
674
|
+
function errorText(err) {
|
|
675
|
+
return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
676
|
+
}
|
|
677
|
+
/** The facade's answer to run input it turns away; the page never sees it. */
|
|
678
|
+
function invalidRun(args) {
|
|
679
|
+
if ((args.code === undefined) === (args.actions === undefined)) {
|
|
680
|
+
return "error: run requires exactly one of code or actions";
|
|
681
|
+
}
|
|
682
|
+
const { error } = CodeModeRunInputSchema.safeParse(args);
|
|
683
|
+
return error && `error: Invalid run input: ${error.issues[0]?.message}`;
|
|
684
|
+
}
|
|
685
|
+
function unexpected(names) {
|
|
686
|
+
const quoted = names.map((name) => `'${name}'`).join(", ");
|
|
687
|
+
return `error: unexpected argument${names.length > 1 ? "s" : ""} ${quoted}.`;
|
|
688
|
+
}
|
|
689
|
+
/** Move what the facade saved under `root` to the same relative path in /workspace. */
|
|
690
|
+
async function saveArtifacts(backend, root) {
|
|
691
|
+
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
692
|
+
const files = entries.filter((entry) => entry.isFile());
|
|
693
|
+
const saved = [];
|
|
694
|
+
for (const path of files.map((f) => join(f.parentPath, f.name)).sort()) {
|
|
695
|
+
const data = await readFile(path);
|
|
696
|
+
await unlink(path);
|
|
697
|
+
const rel = relative(root, path).split(sep).join("/");
|
|
698
|
+
saved.push(await save(backend, `${WORKSPACE}/${rel}`, data));
|
|
699
|
+
}
|
|
700
|
+
return saved;
|
|
701
|
+
}
|
|
702
|
+
/** Write `data` to absolute `path` in the agent's sandbox, creating its folder. */
|
|
703
|
+
export async function save(backend, path, data) {
|
|
704
|
+
if (backend == null) {
|
|
705
|
+
return `error: ${path} not saved: this agent has no sandbox.`;
|
|
706
|
+
}
|
|
707
|
+
if (!posix.isAbsolute(path)) {
|
|
708
|
+
return `error: ${path} not saved: give an absolute path.`;
|
|
709
|
+
}
|
|
710
|
+
const target = normpath(path);
|
|
711
|
+
let result;
|
|
712
|
+
try {
|
|
713
|
+
if (isSandboxBackend(backend)) {
|
|
714
|
+
await backend.execute(`mkdir -p -- ${shellQuote(posix.dirname(target))}`);
|
|
715
|
+
}
|
|
716
|
+
[result] = await backend.uploadFiles([[target, data]]);
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
return `error: ${target} not saved: ${errorText(err)}`;
|
|
720
|
+
}
|
|
721
|
+
return result?.error
|
|
722
|
+
? `error: ${target} not saved: ${result.error}`
|
|
723
|
+
: `Saved ${target}.`;
|
|
724
|
+
}
|
|
725
|
+
/** `path` as Python's `posixpath.normpath` writes it. */
|
|
726
|
+
function normpath(path) {
|
|
727
|
+
return posix.normalize(path).replace(/(.)\/$/, "$1");
|
|
728
|
+
}
|
|
729
|
+
/** Under user scope, a person's browser; service principals stand for many people. */
|
|
730
|
+
export function browserKey(serverInfo, thread, scope) {
|
|
731
|
+
if (scope === "user") {
|
|
732
|
+
const principal = serverInfo?.principal;
|
|
733
|
+
if (principal === undefined) {
|
|
734
|
+
throw new Error('browser scope "user" needs a signed-in user');
|
|
735
|
+
}
|
|
736
|
+
if (principal.kind === "person")
|
|
737
|
+
return `user:${principal.id}`;
|
|
738
|
+
}
|
|
739
|
+
return `thread:${thread}`;
|
|
740
|
+
}
|
|
741
|
+
/** The run's browser key and window, or the error that leaves it without them. */
|
|
742
|
+
function locate(runtime, scope) {
|
|
743
|
+
const id = runtime.configurable?.thread_id;
|
|
744
|
+
if (!id)
|
|
745
|
+
return "error: browser use needs a thread_id";
|
|
746
|
+
const thread = String(id);
|
|
747
|
+
try {
|
|
748
|
+
const key = browserKey(runtime.serverInfo, thread, scope);
|
|
749
|
+
return { thread, key, window: windowName(key, thread) };
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
return `error: ${err.message}`;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* The run's sandbox once MDA has opened it; the browser never opens one.
|
|
757
|
+
*
|
|
758
|
+
* Tools see it through a lazy handle made fresh for each run, so the handle
|
|
759
|
+
* asks the process whether the box is open.
|
|
760
|
+
*/
|
|
761
|
+
export function threadSandbox(backend) {
|
|
762
|
+
const box = backend instanceof LazyManagedSandboxBackend ? backend.opened() : backend;
|
|
763
|
+
if (!isSandboxBackend(box) || box.id.startsWith("pending:"))
|
|
764
|
+
return undefined;
|
|
765
|
+
return box;
|
|
766
|
+
}
|
|
767
|
+
/** A thread's own browser has the one window `mda`; a user's, one per thread. */
|
|
768
|
+
function windowName(key, thread) {
|
|
769
|
+
if (key.startsWith("thread:"))
|
|
770
|
+
return "mda";
|
|
771
|
+
return `mda-${createHash("sha256").update(thread).digest("hex").slice(0, 16)}`;
|
|
772
|
+
}
|
|
773
|
+
/** Run `fn` after `key`'s earlier calls, so calls on one browser take turns. */
|
|
774
|
+
function withLock(key, fn) {
|
|
775
|
+
const result = (locks.get(key) ?? Promise.resolve()).then(fn);
|
|
776
|
+
const tail = result.then(() => { }).catch(() => { }); // settles once `fn` has
|
|
777
|
+
locks.set(key, tail);
|
|
778
|
+
tail.then(() => {
|
|
779
|
+
if (locks.get(key) === tail)
|
|
780
|
+
locks.delete(key); // no one is waiting
|
|
781
|
+
});
|
|
782
|
+
return result;
|
|
783
|
+
}
|
|
784
|
+
/** Close every session and stop its timer, and forget recordings, for tests. */
|
|
785
|
+
export async function clearBrowserSessionsForTests() {
|
|
786
|
+
for (const timer of releases.values())
|
|
787
|
+
clearTimeout(timer);
|
|
788
|
+
releases.clear();
|
|
789
|
+
locks.clear();
|
|
790
|
+
takes.clear();
|
|
791
|
+
const open = [...sessions.values()];
|
|
792
|
+
sessions.clear();
|
|
793
|
+
await Promise.all(open.map(closeSession));
|
|
794
|
+
}
|
|
795
|
+
//# sourceMappingURL=browser.js.map
|