@isomorph.ai/app-sdk 1.24.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/dist/aws.d.ts +20 -0
- package/dist/aws.js +45 -3
- package/dist/computer-use.d.ts +227 -0
- package/dist/computer-use.js +90 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -370,6 +370,25 @@ try {
|
|
|
370
370
|
|
|
371
371
|
App code declares exact HTTPS pages and named click/fill selectors reviewed for reads. Tools provide navigate, observe, control, scroll, screenshot and close. Observations are untrusted webpage content. One isolated page per run, one active run per process, 50 actions, five minutes, ten-second operations, bounded text/screenshots. Private destinations, undeclared navigation, downloads, popups, WebSockets, service workers and non-GET/HEAD requests are blocked. GET-only filtering cannot infer website business semantics: never declare URLs/controls that perform external writes. Chromium's sandbox remains enabled; unsupported sandbox environments fail clearly.
|
|
372
372
|
|
|
373
|
+
## Computer use tool
|
|
374
|
+
|
|
375
|
+
A job's agent can hand one browser task, writes included, to Harbour's computer use agent. Use it only in `jobs/*.ts`. Install the optional peer `ai`.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
import { createComputerUseTool } from '@isomorph.ai/app-sdk/dist/computer-use.js';
|
|
379
|
+
// Inside a job handler:
|
|
380
|
+
const computerUse = createComputerUseTool({ runKey: ctx.runId ?? `my-job/${ctx.scheduledAt}`, progress: ctx.progress, cancelled: ctx.cancelled });
|
|
381
|
+
// Add { computerUse } to generateText's tools. It returns { status: 'done' | 'failed', summary }.
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- The tool calls the task API at `ISOMORPH_COMPUTER_USE_URL` (region `ISOMORPH_COMPUTER_USE_REGION`, default `us-east-1`); Provision sets both when the cell has the API. Each request is signed with SigV4 (`execute-api`) as the runtime's own role: the Lambda environment's credentials, or on a cluster pod its ServiceAccount role through STS `AssumeRoleWithWebIdentity`.
|
|
385
|
+
- It starts the task with `POST /tasks`, then checks `POST /tasks/check` every 5 s until the task is `done` or `failed`.
|
|
386
|
+
- It calls `progress(step count, note)` on every new step and at least once a minute, so the job's lease does not run out.
|
|
387
|
+
- When `cancelled()` turns true it sends `POST /tasks/cancel` once and keeps checking until the task has `failed`.
|
|
388
|
+
- One task record per `runKey`: a retried job run sends the same key and gets the queued, running or finished task back. Only a failed task starts a new attempt.
|
|
389
|
+
|
|
390
|
+
(1.25.0; data plane ADR 0078)
|
|
391
|
+
|
|
373
392
|
## Running and rollout
|
|
374
393
|
|
|
375
394
|
`isomorph dev` uses canned server-model fixtures. `dev --real` supplies an approved development connection to the action process. Hosted actions resolve their exact ACTIVE version key. The current V2 PLAN is published after LIVE; before key activation, an AI action fails and later actions retry. LIVE alone is not AI acceptance.
|
package/dist/aws.d.ts
CHANGED
|
@@ -16,3 +16,23 @@ export declare function sendQueueMessage(aws: AwsEnvironment, queueUrl: string,
|
|
|
16
16
|
fetch?: typeof fetch;
|
|
17
17
|
signingDate?: Date;
|
|
18
18
|
}): Promise<void>;
|
|
19
|
+
type Credentials = Omit<AwsEnvironment, 'region'>;
|
|
20
|
+
/**
|
|
21
|
+
* The runtime role's credentials: the Lambda environment's, or on a cluster
|
|
22
|
+
* pod the ServiceAccount role's (IRSA: AWS_ROLE_ARN and the projected token
|
|
23
|
+
* in AWS_WEB_IDENTITY_TOKEN_FILE), exchanged with STS
|
|
24
|
+
* AssumeRoleWithWebIdentity and kept until five minutes before they expire.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runtimeCredentials(env?: NodeJS.ProcessEnv, { fetch: fetcher, now }?: {
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}): Promise<Credentials>;
|
|
30
|
+
/** A JSON POST signed for `service` in `aws.region`, answered with its status and JSON body. */
|
|
31
|
+
export declare function signedJsonPost(aws: AwsEnvironment, service: string, target: string, payload: unknown, { fetch: fetcher, signingDate }?: {
|
|
32
|
+
fetch?: typeof fetch;
|
|
33
|
+
signingDate?: Date;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
status: number;
|
|
36
|
+
body: unknown;
|
|
37
|
+
}>;
|
|
38
|
+
export {};
|
package/dist/aws.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// The AWS calls a
|
|
2
|
-
//
|
|
3
|
-
// region come from the Lambda environment on every call
|
|
1
|
+
// The AWS calls a runtime makes with its own role, signed here rather than
|
|
2
|
+
// through an AWS SDK client (ADR 0065). On a serverless cell the credentials
|
|
3
|
+
// and the region come from the Lambda environment on every call; on a cluster
|
|
4
|
+
// pod, from the ServiceAccount's role (runtimeCredentials).
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
4
6
|
import { Sha256 } from '@aws-crypto/sha256-js';
|
|
5
7
|
import { HttpRequest } from '@smithy/protocol-http';
|
|
6
8
|
import { SignatureV4 } from '@smithy/signature-v4';
|
|
@@ -49,3 +51,43 @@ export async function sendQueueMessage(aws, queueUrl, body, { fetch: fetcher = f
|
|
|
49
51
|
throw new Error(`SQS refused the message (${response.status}${answer?.__type ? ` ${answer.__type}` : ''}${answer?.message ? `: ${answer.message}` : ''})`);
|
|
50
52
|
}
|
|
51
53
|
}
|
|
54
|
+
let webIdentity;
|
|
55
|
+
/**
|
|
56
|
+
* The runtime role's credentials: the Lambda environment's, or on a cluster
|
|
57
|
+
* pod the ServiceAccount role's (IRSA: AWS_ROLE_ARN and the projected token
|
|
58
|
+
* in AWS_WEB_IDENTITY_TOKEN_FILE), exchanged with STS
|
|
59
|
+
* AssumeRoleWithWebIdentity and kept until five minutes before they expire.
|
|
60
|
+
*/
|
|
61
|
+
export async function runtimeCredentials(env = process.env, { fetch: fetcher = fetch, now = Date.now } = {}) {
|
|
62
|
+
if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY)
|
|
63
|
+
return { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}) };
|
|
64
|
+
const roleArn = env.AWS_ROLE_ARN;
|
|
65
|
+
const tokenFile = env.AWS_WEB_IDENTITY_TOKEN_FILE;
|
|
66
|
+
if (!roleArn || !tokenFile)
|
|
67
|
+
throw new Error('the runtime has no AWS credentials (AWS_ACCESS_KEY_ID, or AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE)');
|
|
68
|
+
if (webIdentity?.roleArn === roleArn && webIdentity.expiresAt - now() > 5 * 60_000)
|
|
69
|
+
return webIdentity.credentials;
|
|
70
|
+
const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
71
|
+
// The token is read on each exchange: the kubelet rotates the file.
|
|
72
|
+
const body = new URLSearchParams({ Action: 'AssumeRoleWithWebIdentity', Version: '2011-06-15', RoleArn: roleArn, RoleSessionName: env.AWS_ROLE_SESSION_NAME || 'isomorph-runtime', WebIdentityToken: (await readFile(tokenFile, 'utf8')).trim() });
|
|
73
|
+
const response = await fetcher(`https://sts.${region}.amazonaws.com/`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: AbortSignal.timeout(10_000) });
|
|
74
|
+
const answer = await response.text();
|
|
75
|
+
const field = (name) => answer.match(new RegExp(`<${name}>([^<]+)</${name}>`))?.[1];
|
|
76
|
+
const accessKeyId = field('AccessKeyId'), secretAccessKey = field('SecretAccessKey'), sessionToken = field('SessionToken'), expiration = Date.parse(field('Expiration') ?? '');
|
|
77
|
+
if (!response.ok || !accessKeyId || !secretAccessKey || !sessionToken || Number.isNaN(expiration))
|
|
78
|
+
throw new Error(`STS did not issue the runtime role's credentials (${response.status}${field('Code') ? ` ${field('Code')}` : ''})`);
|
|
79
|
+
webIdentity = { roleArn, credentials: { accessKeyId, secretAccessKey, sessionToken }, expiresAt: expiration };
|
|
80
|
+
return webIdentity.credentials;
|
|
81
|
+
}
|
|
82
|
+
/** A JSON POST signed for `service` in `aws.region`, answered with its status and JSON body. */
|
|
83
|
+
export async function signedJsonPost(aws, service, target, payload, { fetch: fetcher = fetch, signingDate = new Date() } = {}) {
|
|
84
|
+
const url = new URL(target);
|
|
85
|
+
const body = JSON.stringify(payload);
|
|
86
|
+
const request = new HttpRequest({
|
|
87
|
+
method: 'POST', protocol: url.protocol, hostname: url.hostname, ...(url.port ? { port: Number(url.port) } : {}), path: url.pathname,
|
|
88
|
+
headers: { host: url.host, 'content-type': 'application/json' }, body,
|
|
89
|
+
});
|
|
90
|
+
const { host: _host, ...headers } = (await signer(aws, service).sign(request, { signingDate })).headers;
|
|
91
|
+
const response = await fetcher(url.href, { method: 'POST', headers, body, signal: AbortSignal.timeout(30_000) });
|
|
92
|
+
return { status: response.status, body: await response.json().catch(() => undefined) };
|
|
93
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
export interface ComputerUseFile {
|
|
2
|
+
name: string;
|
|
3
|
+
contentType: "application/pdf";
|
|
4
|
+
dataBase64: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ComputerUseOptions {
|
|
7
|
+
/** App-authorized PDFs, at most five files and 2 MiB total. Never supplied by the model. */
|
|
8
|
+
files?: readonly ComputerUseFile[];
|
|
9
|
+
/** The job's runId, or `<job name>/<scheduledAt>` for a scheduled job: one task record per job run. */
|
|
10
|
+
runKey: string;
|
|
11
|
+
/** The job's progress: called on each new step and at least once a minute, which renews the job's lease. */
|
|
12
|
+
progress(percent: number, note?: string): Promise<void> | void;
|
|
13
|
+
/** The job's cancelled(): when true the tool cancels the task and waits until the CUA stops. */
|
|
14
|
+
cancelled(): Promise<boolean> | boolean;
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** The pause between checks; the tests shorten it. */
|
|
18
|
+
pollIntervalMs?: number;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
export interface ComputerUseResult {
|
|
22
|
+
status: 'done' | 'failed';
|
|
23
|
+
summary: string | null;
|
|
24
|
+
}
|
|
25
|
+
export declare function createComputerUseTool(options: ComputerUseOptions): ({
|
|
26
|
+
deferLoading?: boolean;
|
|
27
|
+
title?: string;
|
|
28
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
29
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
30
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
31
|
+
task: string;
|
|
32
|
+
}>;
|
|
33
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
34
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
35
|
+
task: string;
|
|
36
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
37
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
38
|
+
onInputDelta?: ((options: {
|
|
39
|
+
inputTextDelta: string;
|
|
40
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
41
|
+
onInputAvailable?: ((options: {
|
|
42
|
+
input: {
|
|
43
|
+
task: string;
|
|
44
|
+
};
|
|
45
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
46
|
+
toModelOutput?: ((options: {
|
|
47
|
+
toolCallId: string;
|
|
48
|
+
input: {
|
|
49
|
+
task: string;
|
|
50
|
+
};
|
|
51
|
+
output: NoInfer<ComputerUseResult>;
|
|
52
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
53
|
+
} & {
|
|
54
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
55
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
56
|
+
task: string;
|
|
57
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
58
|
+
} & {
|
|
59
|
+
description?: string | ((options: {
|
|
60
|
+
context: NoInfer<import("@ai-sdk/provider-utils").Context>;
|
|
61
|
+
experimental_sandbox?: import("ai").Experimental_SandboxSession;
|
|
62
|
+
}) => string) | undefined;
|
|
63
|
+
strict?: boolean;
|
|
64
|
+
inputExamples?: {
|
|
65
|
+
input: NoInfer<{
|
|
66
|
+
task: string;
|
|
67
|
+
}>;
|
|
68
|
+
}[] | undefined;
|
|
69
|
+
id?: never;
|
|
70
|
+
isProviderExecuted?: never;
|
|
71
|
+
args?: never;
|
|
72
|
+
supportsDeferredResults?: never;
|
|
73
|
+
} & {
|
|
74
|
+
type?: undefined | "function";
|
|
75
|
+
} & {
|
|
76
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
77
|
+
task: string;
|
|
78
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
79
|
+
}) | ({
|
|
80
|
+
deferLoading?: boolean;
|
|
81
|
+
title?: string;
|
|
82
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
83
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
84
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
85
|
+
task: string;
|
|
86
|
+
}>;
|
|
87
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
88
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
89
|
+
task: string;
|
|
90
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
91
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
92
|
+
onInputDelta?: ((options: {
|
|
93
|
+
inputTextDelta: string;
|
|
94
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
95
|
+
onInputAvailable?: ((options: {
|
|
96
|
+
input: {
|
|
97
|
+
task: string;
|
|
98
|
+
};
|
|
99
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
100
|
+
toModelOutput?: ((options: {
|
|
101
|
+
toolCallId: string;
|
|
102
|
+
input: {
|
|
103
|
+
task: string;
|
|
104
|
+
};
|
|
105
|
+
output: NoInfer<ComputerUseResult>;
|
|
106
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
107
|
+
} & {
|
|
108
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
109
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
110
|
+
task: string;
|
|
111
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
112
|
+
} & {
|
|
113
|
+
description?: string | ((options: {
|
|
114
|
+
context: NoInfer<import("@ai-sdk/provider-utils").Context>;
|
|
115
|
+
experimental_sandbox?: import("ai").Experimental_SandboxSession;
|
|
116
|
+
}) => string) | undefined;
|
|
117
|
+
strict?: boolean;
|
|
118
|
+
inputExamples?: {
|
|
119
|
+
input: NoInfer<{
|
|
120
|
+
task: string;
|
|
121
|
+
}>;
|
|
122
|
+
}[] | undefined;
|
|
123
|
+
id?: never;
|
|
124
|
+
isProviderExecuted?: never;
|
|
125
|
+
args?: never;
|
|
126
|
+
supportsDeferredResults?: never;
|
|
127
|
+
} & {
|
|
128
|
+
type: "dynamic";
|
|
129
|
+
} & {
|
|
130
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
131
|
+
task: string;
|
|
132
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
133
|
+
}) | ({
|
|
134
|
+
deferLoading?: boolean;
|
|
135
|
+
title?: string;
|
|
136
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
137
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
138
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
139
|
+
task: string;
|
|
140
|
+
}>;
|
|
141
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
142
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
143
|
+
task: string;
|
|
144
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
145
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
146
|
+
onInputDelta?: ((options: {
|
|
147
|
+
inputTextDelta: string;
|
|
148
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
149
|
+
onInputAvailable?: ((options: {
|
|
150
|
+
input: {
|
|
151
|
+
task: string;
|
|
152
|
+
};
|
|
153
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
154
|
+
toModelOutput?: ((options: {
|
|
155
|
+
toolCallId: string;
|
|
156
|
+
input: {
|
|
157
|
+
task: string;
|
|
158
|
+
};
|
|
159
|
+
output: NoInfer<ComputerUseResult>;
|
|
160
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
161
|
+
} & {
|
|
162
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
163
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
164
|
+
task: string;
|
|
165
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
166
|
+
} & {
|
|
167
|
+
type: "provider";
|
|
168
|
+
id: `${string}.${string}`;
|
|
169
|
+
args: Record<string, unknown>;
|
|
170
|
+
description?: never;
|
|
171
|
+
strict?: never;
|
|
172
|
+
inputExamples?: never;
|
|
173
|
+
} & {
|
|
174
|
+
isProviderExecuted: false;
|
|
175
|
+
supportsDeferredResults?: never;
|
|
176
|
+
} & {
|
|
177
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
178
|
+
task: string;
|
|
179
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
180
|
+
}) | ({
|
|
181
|
+
deferLoading?: boolean;
|
|
182
|
+
title?: string;
|
|
183
|
+
providerOptions?: import("@ai-sdk/provider-utils").ProviderOptions;
|
|
184
|
+
metadata?: import("@ai-sdk/provider").JSONObject;
|
|
185
|
+
inputSchema: import("ai").FlexibleSchema<{
|
|
186
|
+
task: string;
|
|
187
|
+
}>;
|
|
188
|
+
contextSchema?: import("ai").FlexibleSchema<import("@ai-sdk/provider-utils").Context> | undefined;
|
|
189
|
+
needsApproval?: boolean | import("@ai-sdk/provider-utils").ToolNeedsApprovalFunction<{
|
|
190
|
+
task: string;
|
|
191
|
+
}, NoInfer<import("@ai-sdk/provider-utils").Context>> | undefined;
|
|
192
|
+
onInputStart?: ((options: import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
193
|
+
onInputDelta?: ((options: {
|
|
194
|
+
inputTextDelta: string;
|
|
195
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
196
|
+
onInputAvailable?: ((options: {
|
|
197
|
+
input: {
|
|
198
|
+
task: string;
|
|
199
|
+
};
|
|
200
|
+
} & import("ai").ToolExecutionOptions<NoInfer<import("@ai-sdk/provider-utils").Context>>) => void | PromiseLike<void>) | undefined;
|
|
201
|
+
toModelOutput?: ((options: {
|
|
202
|
+
toolCallId: string;
|
|
203
|
+
input: {
|
|
204
|
+
task: string;
|
|
205
|
+
};
|
|
206
|
+
output: NoInfer<ComputerUseResult>;
|
|
207
|
+
}) => import("@ai-sdk/provider-utils").ToolResultOutput | PromiseLike<import("@ai-sdk/provider-utils").ToolResultOutput>) | undefined;
|
|
208
|
+
} & {
|
|
209
|
+
outputSchema?: import("ai").FlexibleSchema<ComputerUseResult> | undefined;
|
|
210
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
211
|
+
task: string;
|
|
212
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
213
|
+
} & {
|
|
214
|
+
type: "provider";
|
|
215
|
+
id: `${string}.${string}`;
|
|
216
|
+
args: Record<string, unknown>;
|
|
217
|
+
description?: never;
|
|
218
|
+
strict?: never;
|
|
219
|
+
inputExamples?: never;
|
|
220
|
+
} & {
|
|
221
|
+
isProviderExecuted: true;
|
|
222
|
+
supportsDeferredResults?: boolean;
|
|
223
|
+
} & {
|
|
224
|
+
execute: import("ai").ToolExecuteFunction<{
|
|
225
|
+
task: string;
|
|
226
|
+
}, ComputerUseResult, NoInfer<import("@ai-sdk/provider-utils").Context>>;
|
|
227
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The computer use tool: hands one browser task to Harbour's computer use
|
|
2
|
+
// agent (CUA) and waits for its result. Calls go to the CUA's task API, signed
|
|
3
|
+
// with SigV4 as the runtime's own role (ADR 0078).
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { jsonSchema, tool } from 'ai';
|
|
6
|
+
import { runtimeCredentials, signedJsonPost } from './aws.js';
|
|
7
|
+
const HEARTBEAT_MS = 60_000;
|
|
8
|
+
// Checks the API did not answer (a throttle, a 5xx, no connection) before the tool gives up: about a minute.
|
|
9
|
+
const MAX_MISSED_CHECKS = 12;
|
|
10
|
+
export function createComputerUseTool(options) {
|
|
11
|
+
const files = options.files?.map(file => ({ ...file }));
|
|
12
|
+
if (files) {
|
|
13
|
+
const names = new Set();
|
|
14
|
+
let total = 0;
|
|
15
|
+
if (files.length > 5)
|
|
16
|
+
throw new Error("Computer use accepts at most five PDFs.");
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}\.pdf$/.test(file.name) || names.has(file.name))
|
|
19
|
+
throw new Error("Computer use PDF names must be unique safe basenames.");
|
|
20
|
+
if (file.contentType !== "application/pdf" || typeof file.dataBase64 !== "string" || file.dataBase64.length > Math.ceil(2 * 1024 * 1024 / 3) * 4)
|
|
21
|
+
throw new Error("Computer use needs base64 PDFs, at most 2 MiB total.");
|
|
22
|
+
const bytes = Buffer.from(file.dataBase64, "base64");
|
|
23
|
+
total += bytes.length;
|
|
24
|
+
if (bytes.toString("base64") !== file.dataBase64 || bytes.subarray(0, 5).toString() !== "%PDF-" || total > 2 * 1024 * 1024)
|
|
25
|
+
throw new Error("Computer use needs PDFs, at most 2 MiB total.");
|
|
26
|
+
names.add(file.name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const env = options.env ?? process.env;
|
|
30
|
+
const base = env.ISOMORPH_COMPUTER_USE_URL?.replace(/\/+$/, '');
|
|
31
|
+
if (!base)
|
|
32
|
+
throw new Error('This app has no computer use API (ISOMORPH_COMPUTER_USE_URL is not set).');
|
|
33
|
+
if (!options.runKey)
|
|
34
|
+
throw new Error('createComputerUseTool needs the job run as runKey.');
|
|
35
|
+
const region = env.ISOMORPH_COMPUTER_USE_REGION || 'us-east-1';
|
|
36
|
+
const now = options.now ?? Date.now;
|
|
37
|
+
const transport = options.fetch ? { fetch: options.fetch } : {};
|
|
38
|
+
const call = async (path, body) => signedJsonPost({ ...await runtimeCredentials(env, transport), region }, 'execute-api', `${base}${path}`, body, transport);
|
|
39
|
+
const refused = (path, status, body) => {
|
|
40
|
+
const error = body?.error;
|
|
41
|
+
return new Error(`The computer use API refused ${path} (${status}${typeof error === 'string' ? `: ${error}` : ''}).`);
|
|
42
|
+
};
|
|
43
|
+
return tool({
|
|
44
|
+
description: 'Do one task in a web browser, such as filling in and submitting a form on a website. Describe the whole task in plain words, with the website address and any details it needs. Waits until the task ends and returns its status and a summary.',
|
|
45
|
+
inputSchema: jsonSchema({ type: 'object', properties: { task: { type: 'string', minLength: 1 } }, required: ['task'], additionalProperties: false }),
|
|
46
|
+
execute: async ({ task }) => {
|
|
47
|
+
const started = await call('/tasks', { run_key: options.runKey, task, ...(files?.length ? { files } : {}) });
|
|
48
|
+
if (started.status !== 200)
|
|
49
|
+
throw refused('/tasks', started.status, started.body);
|
|
50
|
+
await options.progress(0, 'Computer use task started.');
|
|
51
|
+
let reported = now();
|
|
52
|
+
let steps = 0;
|
|
53
|
+
let cancelSent = false;
|
|
54
|
+
let missed = 0;
|
|
55
|
+
for (;;) {
|
|
56
|
+
if (!cancelSent && await options.cancelled()) {
|
|
57
|
+
const cancel = await call('/tasks/cancel', { run_key: options.runKey });
|
|
58
|
+
if (cancel.status !== 200)
|
|
59
|
+
throw refused('/tasks/cancel', cancel.status, cancel.body);
|
|
60
|
+
cancelSent = true;
|
|
61
|
+
}
|
|
62
|
+
let check;
|
|
63
|
+
try {
|
|
64
|
+
check = await call('/tasks/check', { run_key: options.runKey });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
check = undefined;
|
|
68
|
+
}
|
|
69
|
+
if (check?.status === 200) {
|
|
70
|
+
missed = 0;
|
|
71
|
+
const answer = check.body;
|
|
72
|
+
if (answer.status === 'done' || answer.status === 'failed')
|
|
73
|
+
return { status: answer.status, summary: answer.summary ?? null };
|
|
74
|
+
if (answer.steps > steps || now() - reported >= HEARTBEAT_MS) {
|
|
75
|
+
steps = Math.max(steps, answer.steps);
|
|
76
|
+
await options.progress(Math.min(steps, 99), answer.last_step ? `Computer use step ${steps}: ${answer.last_step}` : `Computer use task ${answer.status}.`);
|
|
77
|
+
reported = now();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (check && check.status !== 429 && check.status < 500) {
|
|
81
|
+
throw refused('/tasks/check', check.status, check.body);
|
|
82
|
+
}
|
|
83
|
+
else if (++missed > MAX_MISSED_CHECKS) {
|
|
84
|
+
throw new Error('The computer use API stopped answering.');
|
|
85
|
+
}
|
|
86
|
+
await sleep(options.pollIntervalMs ?? 5_000);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|