dopus 0.1.0 → 0.2.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/README.md +67 -3
- package/dist/cli.js +499 -14
- package/dist/index.d.ts +7 -2
- package/dist/index.js +50 -2
- package/dist/{version-BPyQRLlj.js → version-BWFAqCId.js} +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ Only two kinds of error message reach the model:
|
|
|
115
115
|
- a `ToolError` you throw on purpose, such as `throw new ToolError('Order o-1 has already shipped')`;
|
|
116
116
|
- Next.js `redirect()`, `notFound()`, `forbidden()` and `unauthorized()`, which are translated into plain messages such as "the user is not signed in".
|
|
117
117
|
|
|
118
|
-
Any other error goes to `onError` and is replaced with a generic message, so database errors and stack details stay on the server. The browser
|
|
118
|
+
Any other error goes to `onError` and is replaced with a generic message, so database errors and stack details stay on the server. The browser sees the same text as the model: the `ToolError` message, or the generic one.
|
|
119
119
|
|
|
120
120
|
## Permissions and security
|
|
121
121
|
|
|
@@ -184,6 +184,67 @@ defineAgent({
|
|
|
184
184
|
})
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
+
## Deploying
|
|
188
|
+
|
|
189
|
+
**Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it.
|
|
190
|
+
|
|
191
|
+
**AWS (OpenNext / SST).**
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pnpm dopus infra aws
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The command writes `open-next.config.ts` with Lambda response streaming (`aws-lambda-streaming`) and installs `@opennextjs/aws`. It also prints an `sst.aws.Nextjs` snippet that passes your API key and `DOPUS_APPROVAL_SECRET` as SST secrets.
|
|
198
|
+
|
|
199
|
+
SST reads OpenNext's streaming flag and sets the Function URL to `RESPONSE_STREAM`. Nothing is deployed for you.
|
|
200
|
+
|
|
201
|
+
Things to know:
|
|
202
|
+
|
|
203
|
+
- **Keep-alive.** While a tool runs and nothing else is sent, dopus writes an SSE comment (`: keep-alive`) every 15 s (`keepAlive` option). Clients ignore it. CloudFront, ALB and nginx time out on gaps between packets, not on total time, so a slow tool does not cut the stream.
|
|
204
|
+
- **Turn length.** A turn still has to finish within the Lambda timeout. Behind CloudFront that is 60 s by default (up to 180 s with a quota increase). A bare Function URL allows up to 15 min.
|
|
205
|
+
- **No API Gateway.** Don't put API Gateway HTTP APIs in front of `/api/agent`. They cap requests at 30 s and buffer the response.
|
|
206
|
+
- **Long-running work.** Work that outlives a request, such as queues and background jobs, loses the request's cookies. Use `identify` and pass `ctx.user` on explicitly.
|
|
207
|
+
|
|
208
|
+
`pnpm aws:emu` in this repo builds the example with OpenNext and invokes the Lambda handler locally through a stand-in for the streaming runtime. It checks that the response streams and that tools still read the caller's cookie.
|
|
209
|
+
|
|
210
|
+
**Cloudflare Workers (OpenNext).**
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
pnpm dopus infra cloudflare
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The command writes `open-next.config.ts` (`defineCloudflareConfig`), `wrangler.jsonc` (`nodejs_compat`) and `.dev.vars`. It also adds `cf:preview` and `cf:deploy` scripts, git-ignores `.open-next`, `.dev.vars` and `.wrangler`, and installs `@opennextjs/cloudflare` and `wrangler`.
|
|
217
|
+
|
|
218
|
+
Wrangler reads `.dev.vars` instead of `.env.local`. So the command offers to copy your API key and `DOPUS_APPROVAL_SECRET` into `.dev.vars` for the local preview. The values are never printed.
|
|
219
|
+
|
|
220
|
+
For production, set the same two values with `npx wrangler secret put`.
|
|
221
|
+
|
|
222
|
+
Things to know:
|
|
223
|
+
|
|
224
|
+
- **No wall-clock limit.** A Worker can keep streaming as long as the client stays connected, and waiting on the LLM does not count as CPU time.
|
|
225
|
+
- **CPU time.** Rendering and tool code do count toward CPU time. Paid plans allow 30 s by default and up to 5 min with `limits.cpu_ms`. The free plan allows 10 ms per request, which a Next.js app usually exceeds.
|
|
226
|
+
- **Node runtime.** OpenNext runs the Node.js runtime, so remove `export const runtime = 'edge'`.
|
|
227
|
+
|
|
228
|
+
`pnpm cf:e2e` in this repo copies the example and runs `dopus infra cloudflare` on it. It then builds the copy, serves it with `wrangler dev` (local workerd) and runs the same e2e suite against it, covering cookies, ownership checks and signed approvals.
|
|
229
|
+
|
|
230
|
+
**Google Cloud Run.**
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
pnpm dopus infra gcp
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The command sets `output: 'standalone'` in `next.config` (it asks first). It writes a multi-stage `Dockerfile` that pins your pnpm or yarn version through corepack. It also writes a `.dockerignore` that keeps `.env*` and `.dev.vars` out of the image.
|
|
237
|
+
|
|
238
|
+
It then prints the deploy commands: Secret Manager setup, and `gcloud run deploy --source . --timeout 3600 --set-secrets ...`.
|
|
239
|
+
|
|
240
|
+
Things to know:
|
|
241
|
+
|
|
242
|
+
- **Timeout.** A request may last up to 60 min (`--timeout 3600`; the default is 5 min). The timeout covers the whole streamed reply.
|
|
243
|
+
- **Longer work.** Use Cloud Run Jobs and pass the user explicitly (`identify` → `ctx.user`), because there are no cookies outside the request.
|
|
244
|
+
- **Build-time variables.** `.env` files are not copied into the image. If your build needs `NEXT_PUBLIC_*` values, pass them as build args.
|
|
245
|
+
|
|
246
|
+
`pnpm gcp:e2e` in this repo copies the example and runs `dopus infra gcp` on it. It builds the image with Docker and runs it the way Cloud Run does (`PORT=8080`, secrets as env vars). It checks that a canary secret from `.env.local` is not in the image, then runs the e2e suite against the container.
|
|
247
|
+
|
|
187
248
|
## Providers
|
|
188
249
|
|
|
189
250
|
| Provider | Package | API key env | Default model |
|
|
@@ -200,11 +261,10 @@ Swap in a scripted `MockLanguageModelV4` from `ai/test` behind an environment va
|
|
|
200
261
|
|
|
201
262
|
## Status and roadmap
|
|
202
263
|
|
|
203
|
-
dopus is
|
|
264
|
+
dopus is pre-1.0, so expect breaking changes before 1.0. It is tested on Next.js 16 with the Node.js runtime.
|
|
204
265
|
|
|
205
266
|
Planned next:
|
|
206
267
|
|
|
207
|
-
- infra adapters for long-running agents (AWS first, then Cloudflare, then GCP);
|
|
208
268
|
- `dopus prompts diff` to compare your copies with newer templates;
|
|
209
269
|
- tool input schemas inferred from TypeScript types.
|
|
210
270
|
|
|
@@ -214,7 +274,11 @@ Planned next:
|
|
|
214
274
|
pnpm install
|
|
215
275
|
pnpm build && pnpm lint && pnpm typecheck && pnpm test
|
|
216
276
|
pnpm example:build && pnpm example:e2e # next start + scripted model
|
|
277
|
+
pnpm example:live # real model from examples/next-app/.env.local (costs cents)
|
|
217
278
|
pnpm smoke # packed tarball → fresh app → dopus init → next build
|
|
279
|
+
pnpm aws:emu # OpenNext build → local Lambda streaming invoke
|
|
280
|
+
pnpm cf:e2e # dopus infra cloudflare → wrangler dev (workerd) → e2e
|
|
281
|
+
pnpm gcp:e2e # dopus infra gcp → docker build/run (Cloud Run style) → e2e
|
|
218
282
|
```
|
|
219
283
|
|
|
220
284
|
Design notes: [`docs/design.md`](docs/design.md).
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as PROVIDERS, t as VERSION } from "./version-
|
|
2
|
+
import { n as PROVIDERS, t as VERSION } from "./version-BWFAqCId.js";
|
|
3
3
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import * as p from "@clack/prompts";
|
|
@@ -126,11 +126,25 @@ function upsertEnv(content, vars, replace = []) {
|
|
|
126
126
|
}
|
|
127
127
|
/** True if git (or, without git, .gitignore patterns) keeps `.env.local` out of the repo. */
|
|
128
128
|
function isEnvLocalIgnored(root) {
|
|
129
|
+
return isGitIgnored(root, ".env.local", [
|
|
130
|
+
".env.local",
|
|
131
|
+
".env*.local",
|
|
132
|
+
".env*",
|
|
133
|
+
"*.local",
|
|
134
|
+
".env.*"
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
/** True if git ignores `path`; outside a repo, falls back to exact lines in .gitignore. */
|
|
138
|
+
function isGitIgnored(root, path, patterns = [
|
|
139
|
+
path,
|
|
140
|
+
`${path}/`,
|
|
141
|
+
`/${path}`
|
|
142
|
+
]) {
|
|
129
143
|
try {
|
|
130
144
|
execFileSync("git", [
|
|
131
145
|
"check-ignore",
|
|
132
146
|
"-q",
|
|
133
|
-
|
|
147
|
+
path
|
|
134
148
|
], {
|
|
135
149
|
cwd: root,
|
|
136
150
|
stdio: "ignore"
|
|
@@ -141,13 +155,7 @@ function isEnvLocalIgnored(root) {
|
|
|
141
155
|
}
|
|
142
156
|
const gitignore = join(root, ".gitignore");
|
|
143
157
|
if (!existsSync(gitignore)) return false;
|
|
144
|
-
return readFileSync(gitignore, "utf8").split(/\r?\n/).map((l) => l.trim()).some((
|
|
145
|
-
".env.local",
|
|
146
|
-
".env*.local",
|
|
147
|
-
".env*",
|
|
148
|
-
"*.local",
|
|
149
|
-
".env.*"
|
|
150
|
-
].includes(p));
|
|
158
|
+
return readFileSync(gitignore, "utf8").split(/\r?\n/).map((l) => l.trim()).some((line) => patterns.includes(line));
|
|
151
159
|
}
|
|
152
160
|
// src/init/templates.ts
|
|
153
161
|
const PRESETS = [
|
|
@@ -233,8 +241,12 @@ const ADD_COMMAND = {
|
|
|
233
241
|
yarn: ["add"],
|
|
234
242
|
bun: ["add"]
|
|
235
243
|
};
|
|
236
|
-
function installArgs(packageManager, packages) {
|
|
237
|
-
return [
|
|
244
|
+
function installArgs(packageManager, packages, dev = false) {
|
|
245
|
+
return [
|
|
246
|
+
...ADD_COMMAND[packageManager],
|
|
247
|
+
...dev ? ["-D"] : [],
|
|
248
|
+
...packages
|
|
249
|
+
];
|
|
238
250
|
}
|
|
239
251
|
function toImportPath(fromFile, toFileWithoutExt) {
|
|
240
252
|
const path = relative(dirname(fromFile), toFileWithoutExt).split("\\").join("/");
|
|
@@ -365,11 +377,442 @@ async function runInit(options, io) {
|
|
|
365
377
|
}
|
|
366
378
|
return result;
|
|
367
379
|
}
|
|
380
|
+
// src/infra/aws.ts
|
|
381
|
+
const OPEN_NEXT_CONFIG = `import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js'
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Generated by \`dopus infra aws\`.
|
|
385
|
+
*
|
|
386
|
+
* Lambda response streaming lets /api/agent stream replies instead of buffering them. OpenNext marks the
|
|
387
|
+
* server as streaming in its build output; SST (sst.aws.Nextjs) reads that and sets the Function URL
|
|
388
|
+
* invoke mode to RESPONSE_STREAM. With your own CDK/Terraform, set InvokeMode RESPONSE_STREAM yourself.
|
|
389
|
+
*/
|
|
390
|
+
const config = {
|
|
391
|
+
default: {
|
|
392
|
+
override: {
|
|
393
|
+
wrapper: 'aws-lambda-streaming',
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
} satisfies OpenNextConfig
|
|
397
|
+
|
|
398
|
+
export default config
|
|
399
|
+
`;
|
|
400
|
+
/** Which provider agent.config.ts uses, so the deploy snippet names the right env var. */
|
|
401
|
+
function detectConfiguredProvider(source) {
|
|
402
|
+
if (!source) return void 0;
|
|
403
|
+
return Object.values(PROVIDERS).find((info) => source.includes(`'${info.packageName}'`));
|
|
404
|
+
}
|
|
405
|
+
function secretName(envVar) {
|
|
406
|
+
return envVar.toLowerCase().split("_").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
407
|
+
}
|
|
408
|
+
function sstSnippet(provider) {
|
|
409
|
+
const keyEnv = provider?.envVar ?? "OPENAI_API_KEY";
|
|
410
|
+
const keySecret = secretName(keyEnv);
|
|
411
|
+
const approval = secretName(APPROVAL_SECRET_ENV);
|
|
412
|
+
return `// sst.config.ts → run()
|
|
413
|
+
const ${keySecret[0]?.toLowerCase()}${keySecret.slice(1)} = new sst.Secret('${keySecret}')
|
|
414
|
+
const ${approval[0]?.toLowerCase()}${approval.slice(1)} = new sst.Secret('${approval}')
|
|
415
|
+
|
|
416
|
+
new sst.aws.Nextjs('Web', {
|
|
417
|
+
server: {
|
|
418
|
+
// Lambda timeout; SST also uses it as the CloudFront read
|
|
419
|
+
// timeout (max 60 s, or up to 180 s with a quota increase).
|
|
420
|
+
timeout: '60 seconds',
|
|
421
|
+
},
|
|
422
|
+
environment: {
|
|
423
|
+
${keyEnv}: ${keySecret[0]?.toLowerCase()}${keySecret.slice(1)}.value,
|
|
424
|
+
${APPROVAL_SECRET_ENV}: ${approval[0]?.toLowerCase()}${approval.slice(1)}.value,
|
|
425
|
+
},
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// then, once per stage:
|
|
429
|
+
// npx sst secret set ${keySecret} <your key>
|
|
430
|
+
// npx sst secret set ${approval} "$(openssl rand -base64 32)"`;
|
|
431
|
+
}
|
|
432
|
+
async function runInfraAws(options, io) {
|
|
433
|
+
const project = detectProject(options.cwd);
|
|
434
|
+
const { root } = project;
|
|
435
|
+
const ask = async (message, initial) => options.yes ? initial : io.confirm(message, initial);
|
|
436
|
+
const configFile = [
|
|
437
|
+
"open-next.config.ts",
|
|
438
|
+
"open-next.config.js",
|
|
439
|
+
"open-next.config.mjs"
|
|
440
|
+
].find((f) => existsSync(join(root, f)));
|
|
441
|
+
const agentConfig = [join(project.configDir, "agent.config.ts"), "agent.config.ts"].map((f) => join(root, f)).find((f) => existsSync(f));
|
|
442
|
+
const provider = detectConfiguredProvider(agentConfig ? readFileSync(agentConfig, "utf8") : void 0);
|
|
443
|
+
const result = {
|
|
444
|
+
configFile: configFile ?? "open-next.config.ts",
|
|
445
|
+
created: false,
|
|
446
|
+
streaming: false,
|
|
447
|
+
provider,
|
|
448
|
+
installed: []
|
|
449
|
+
};
|
|
450
|
+
if (configFile) {
|
|
451
|
+
result.streaming = readFileSync(join(root, configFile), "utf8").includes("aws-lambda-streaming");
|
|
452
|
+
if (result.streaming) io.info(`${configFile} already enables Lambda streaming — left unchanged.`);
|
|
453
|
+
else io.warn(`${configFile} exists and does not enable streaming. Add \`override: { wrapper: 'aws-lambda-streaming' }\` to the function that serves /api/agent (or to \`default\`).`);
|
|
454
|
+
} else {
|
|
455
|
+
writeFileSync(join(root, "open-next.config.ts"), OPEN_NEXT_CONFIG);
|
|
456
|
+
result.created = true;
|
|
457
|
+
result.streaming = true;
|
|
458
|
+
io.success("Created open-next.config.ts (Lambda response streaming)");
|
|
459
|
+
}
|
|
460
|
+
if (!project.dependencies["@opennextjs/aws"]) {
|
|
461
|
+
const spec = "@opennextjs/aws@^4";
|
|
462
|
+
if (options.install === false) io.warn(`Install it yourself: ${project.packageManager} ${installArgs(project.packageManager, [spec], true).join(" ")}`);
|
|
463
|
+
else if (await ask(`Install ${spec} (dev dependency) with ${project.packageManager}?`, true)) {
|
|
464
|
+
await io.install(project.packageManager, [spec], root, true);
|
|
465
|
+
result.installed = [spec];
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!provider) io.warn("Could not tell the provider from agent.config.ts; the snippet below assumes OpenAI.");
|
|
469
|
+
io.note(sstSnippet(provider), "Deploy with SST");
|
|
470
|
+
io.note([
|
|
471
|
+
"Build: npx @opennextjs/aws build (SST runs it for you).",
|
|
472
|
+
"Keep-alive: dopus sends an SSE comment every 15 s while a tool",
|
|
473
|
+
" runs. CloudFront times out on gaps between packets, not on",
|
|
474
|
+
" total time, so long tool calls keep the stream open.",
|
|
475
|
+
"Limit: a turn must finish within the Lambda timeout: 60 s",
|
|
476
|
+
" behind CloudFront by default, up to 15 min on a bare",
|
|
477
|
+
" Function URL.",
|
|
478
|
+
"Avoid API Gateway HTTP APIs for /api/agent (30 s cap)."
|
|
479
|
+
].join("\n"), "AWS notes");
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
// src/infra/cloudflare.ts
|
|
483
|
+
const CLOUDFLARE_OPEN_NEXT_CONFIG = `import { defineCloudflareConfig } from '@opennextjs/cloudflare'
|
|
484
|
+
|
|
485
|
+
// Generated by \`dopus infra cloudflare\`. Add an incremental cache (R2/KV) here if your pages use ISR.
|
|
486
|
+
export default defineCloudflareConfig({})
|
|
487
|
+
`;
|
|
488
|
+
/** Worker names: lowercase letters, digits and dashes, at most 63 characters. */
|
|
489
|
+
function workerName(packageName) {
|
|
490
|
+
return (packageName ?? "next-app").replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "next-app";
|
|
491
|
+
}
|
|
492
|
+
/** A month back, so the installed workerd always supports it. */
|
|
493
|
+
function compatibilityDate(now = /* @__PURE__ */ new Date()) {
|
|
494
|
+
return (/* @__PURE__ */ new Date(now.getTime() - 2592e6)).toISOString().slice(0, 10);
|
|
495
|
+
}
|
|
496
|
+
function wranglerConfig(name, date) {
|
|
497
|
+
return `{
|
|
498
|
+
// Generated by \`dopus infra cloudflare\` for @opennextjs/cloudflare.
|
|
499
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
500
|
+
"name": "${name}",
|
|
501
|
+
"main": ".open-next/worker.js",
|
|
502
|
+
"compatibility_date": "${date}",
|
|
503
|
+
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
504
|
+
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" },
|
|
505
|
+
"services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "${name}" }],
|
|
506
|
+
// Waiting on the LLM does not count as CPU time, but rendering and tool code do (paid plans: 30 s default).
|
|
507
|
+
// "limits": { "cpu_ms": 60000 },
|
|
508
|
+
"observability": { "enabled": true }
|
|
509
|
+
}
|
|
510
|
+
`;
|
|
511
|
+
}
|
|
512
|
+
const SCRIPTS = {
|
|
513
|
+
"cf:preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
|
514
|
+
"cf:deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy"
|
|
515
|
+
};
|
|
516
|
+
async function runInfraCloudflare(options, io) {
|
|
517
|
+
const project = detectProject(options.cwd);
|
|
518
|
+
const { root } = project;
|
|
519
|
+
const ask = async (message, initial) => options.yes ? initial : io.confirm(message, initial);
|
|
520
|
+
const result = {
|
|
521
|
+
created: [],
|
|
522
|
+
skipped: [],
|
|
523
|
+
scripts: [],
|
|
524
|
+
installed: []
|
|
525
|
+
};
|
|
526
|
+
const openNext = [
|
|
527
|
+
"open-next.config.ts",
|
|
528
|
+
"open-next.config.js",
|
|
529
|
+
"open-next.config.mjs"
|
|
530
|
+
].find((f) => existsSync(join(root, f)));
|
|
531
|
+
if (openNext && !readFileSync(join(root, openNext), "utf8").includes("@opennextjs/cloudflare")) throw new InitError(`${openNext} is set up for another target (AWS?). OpenNext uses one config per app: move it away and rerun.`);
|
|
532
|
+
const pkgPath = join(root, "package.json");
|
|
533
|
+
const pkgSource = readFileSync(pkgPath, "utf8");
|
|
534
|
+
const pkg = JSON.parse(pkgSource);
|
|
535
|
+
const name = workerName(pkg.name);
|
|
536
|
+
const agentConfig = [join(project.configDir, "agent.config.ts"), "agent.config.ts"].map((f) => join(root, f)).find((f) => existsSync(f));
|
|
537
|
+
const keyEnv = detectConfiguredProvider(agentConfig ? readFileSync(agentConfig, "utf8") : void 0)?.envVar ?? "OPENAI_API_KEY";
|
|
538
|
+
const files = [
|
|
539
|
+
[
|
|
540
|
+
"open-next.config.ts",
|
|
541
|
+
CLOUDFLARE_OPEN_NEXT_CONFIG,
|
|
542
|
+
["open-next.config.js", "open-next.config.mjs"]
|
|
543
|
+
],
|
|
544
|
+
[
|
|
545
|
+
"wrangler.jsonc",
|
|
546
|
+
wranglerConfig(name, compatibilityDate()),
|
|
547
|
+
["wrangler.json", "wrangler.toml"]
|
|
548
|
+
],
|
|
549
|
+
[
|
|
550
|
+
".dev.vars",
|
|
551
|
+
"NEXTJS_ENV=development\n",
|
|
552
|
+
[]
|
|
553
|
+
]
|
|
554
|
+
];
|
|
555
|
+
for (const [file, content, alternatives] of files) {
|
|
556
|
+
const existing = [file, ...alternatives].find((f) => existsSync(join(root, f)));
|
|
557
|
+
if (existing) {
|
|
558
|
+
result.skipped.push(existing);
|
|
559
|
+
io.info(`${existing} exists — left unchanged.`);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
writeFileSync(join(root, file), content, file === ".dev.vars" ? { mode: 384 } : void 0);
|
|
563
|
+
result.created.push(file);
|
|
564
|
+
io.success(`Created ${file}`);
|
|
565
|
+
}
|
|
566
|
+
const envLocal = join(root, ".env.local");
|
|
567
|
+
const devVars = join(root, ".dev.vars");
|
|
568
|
+
const local = existsSync(envLocal) ? parseEnvKeys(readFileSync(envLocal, "utf8")) : /* @__PURE__ */ new Map();
|
|
569
|
+
const present = existsSync(devVars) ? parseEnvKeys(readFileSync(devVars, "utf8")) : /* @__PURE__ */ new Map();
|
|
570
|
+
const copy = [keyEnv, APPROVAL_SECRET_ENV].filter((key) => local.get(key) && !present.get(key));
|
|
571
|
+
if (copy.length > 0 && await ask(`Copy ${copy.join(" and ")} from .env.local into .dev.vars for the local preview?`, true)) {
|
|
572
|
+
const vars = Object.fromEntries(copy.map((key) => [key, local.get(key) ?? ""]));
|
|
573
|
+
writeFileSync(devVars, upsertEnv(readFileSync(devVars, "utf8"), vars).content, { mode: 384 });
|
|
574
|
+
io.success(`.dev.vars: set ${copy.join(", ")}`);
|
|
575
|
+
}
|
|
576
|
+
if (result.skipped.some((f) => f.startsWith("wrangler."))) io.warn("Check that your wrangler config has main \".open-next/worker.js\" and the \"nodejs_compat\" flag.");
|
|
577
|
+
const scripts = { ...pkg.scripts };
|
|
578
|
+
for (const [key, command] of Object.entries(SCRIPTS)) if (!scripts[key]) {
|
|
579
|
+
scripts[key] = command;
|
|
580
|
+
result.scripts.push(key);
|
|
581
|
+
}
|
|
582
|
+
if (result.scripts.length > 0) {
|
|
583
|
+
const indent = /^(\s+)"/m.exec(pkgSource)?.[1] ?? " ";
|
|
584
|
+
writeFileSync(pkgPath, `${JSON.stringify({
|
|
585
|
+
...pkg,
|
|
586
|
+
scripts
|
|
587
|
+
}, null, indent)}\n`);
|
|
588
|
+
io.success(`package.json: added ${result.scripts.join(", ")}`);
|
|
589
|
+
}
|
|
590
|
+
const ignore = [
|
|
591
|
+
".open-next",
|
|
592
|
+
".dev.vars",
|
|
593
|
+
".wrangler"
|
|
594
|
+
].filter((path) => !isGitIgnored(root, path));
|
|
595
|
+
if (ignore.length > 0) {
|
|
596
|
+
const gitignore = join(root, ".gitignore");
|
|
597
|
+
const prefix = existsSync(gitignore) && !readFileSync(gitignore, "utf8").endsWith("\n") ? "\n" : "";
|
|
598
|
+
appendFileSync(gitignore, `${prefix}\n# Cloudflare (dopus)\n${ignore.map((p) => `${p}\n`).join("")}`);
|
|
599
|
+
io.success(`.gitignore: added ${ignore.join(", ")}`);
|
|
600
|
+
}
|
|
601
|
+
const deps = [[
|
|
602
|
+
"@opennextjs/cloudflare",
|
|
603
|
+
"@opennextjs/cloudflare@^1",
|
|
604
|
+
false
|
|
605
|
+
], [
|
|
606
|
+
"wrangler",
|
|
607
|
+
"wrangler@^4",
|
|
608
|
+
true
|
|
609
|
+
]];
|
|
610
|
+
for (const dev of [false, true]) {
|
|
611
|
+
const missing = deps.filter(([n, , d]) => d === dev && !project.dependencies[n]).map(([, spec]) => spec);
|
|
612
|
+
if (missing.length === 0) continue;
|
|
613
|
+
if (options.install === false) io.warn(`Install yourself: ${project.packageManager} ${installArgs(project.packageManager, missing, dev).join(" ")}`);
|
|
614
|
+
else if (await ask(`Install ${missing.join(", ")}${dev ? " (dev)" : ""} with ${project.packageManager}?`, true)) {
|
|
615
|
+
await io.install(project.packageManager, missing, root, dev);
|
|
616
|
+
result.installed.push(...missing);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const run = project.packageManager === "npm" ? "npm run" : project.packageManager;
|
|
620
|
+
io.note([
|
|
621
|
+
`Local Workers runtime: ${run} cf:preview`,
|
|
622
|
+
" Wrangler reads .dev.vars (not .env.local): keep your key there too.",
|
|
623
|
+
"",
|
|
624
|
+
"Production secrets, once per Worker:",
|
|
625
|
+
` npx wrangler secret put ${keyEnv}`,
|
|
626
|
+
` npx wrangler secret put ${APPROVAL_SECRET_ENV}`,
|
|
627
|
+
`Deploy: ${run} cf:deploy`
|
|
628
|
+
].join("\n"), "Deploy to Cloudflare Workers");
|
|
629
|
+
io.note([
|
|
630
|
+
"Duration: no wall-clock limit while the client is connected, and",
|
|
631
|
+
" waiting on the LLM is not CPU time. CPU limit: 30 s by default on",
|
|
632
|
+
" paid plans (limits.cpu_ms, max 5 min); the free plan allows 10 ms,",
|
|
633
|
+
" which a Next.js app usually exceeds.",
|
|
634
|
+
"Keep-alive SSE comments (15 s) keep long tool calls open.",
|
|
635
|
+
"Remove `export const runtime = \"edge\"`: OpenNext runs the Node runtime."
|
|
636
|
+
].join("\n"), "Cloudflare notes");
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
// src/infra/gcp.ts
|
|
640
|
+
const LOCKFILE = {
|
|
641
|
+
pnpm: "pnpm-lock.yaml",
|
|
642
|
+
npm: "package-lock.json",
|
|
643
|
+
yarn: "yarn.lock"
|
|
644
|
+
};
|
|
645
|
+
/**
|
|
646
|
+
* The install line. pnpm/yarn come from corepack; without a `packageManager` field corepack would fetch the
|
|
647
|
+
* newest release, whose policies (e.g. pnpm's minimumReleaseAge) can reject a lockfile your version accepted,
|
|
648
|
+
* so pin the version the project uses.
|
|
649
|
+
*/
|
|
650
|
+
function installCommand(packageManager, pinned) {
|
|
651
|
+
if (packageManager === "npm") return "npm ci";
|
|
652
|
+
return `${pinned ? `corepack enable ${packageManager} && corepack prepare ${packageManager}@${pinned} --activate` : `corepack enable ${packageManager}`} && ${packageManager} install --frozen-lockfile`;
|
|
653
|
+
}
|
|
654
|
+
/** Version to pin when package.json has no `packageManager` field: the one installed here. */
|
|
655
|
+
function localVersion(packageManager, cwd) {
|
|
656
|
+
try {
|
|
657
|
+
const version = execFileSync(packageManager, ["--version"], {
|
|
658
|
+
cwd,
|
|
659
|
+
encoding: "utf8",
|
|
660
|
+
stdio: [
|
|
661
|
+
"ignore",
|
|
662
|
+
"pipe",
|
|
663
|
+
"ignore"
|
|
664
|
+
]
|
|
665
|
+
}).trim();
|
|
666
|
+
return /^\d+\.\d+\.\d+/.test(version) ? version : void 0;
|
|
667
|
+
} catch {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** `pin`: version to activate with corepack; leave undefined when package.json declares `packageManager`. */
|
|
672
|
+
function dockerfile(packageManager, pin) {
|
|
673
|
+
if (packageManager === "bun") throw new InitError("Bun projects: write your own Dockerfile (dopus generates npm, pnpm and yarn ones).");
|
|
674
|
+
return `# Generated by \`dopus infra gcp\`: Next.js standalone server for Cloud Run.
|
|
675
|
+
# Secrets are not baked in: .dockerignore excludes .env files; Cloud Run injects them at runtime.
|
|
676
|
+
FROM node:24-slim AS base
|
|
677
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
678
|
+
|
|
679
|
+
FROM base AS deps
|
|
680
|
+
WORKDIR /app
|
|
681
|
+
COPY package.json ${LOCKFILE[packageManager]} ./
|
|
682
|
+
RUN ${installCommand(packageManager, pin)}
|
|
683
|
+
|
|
684
|
+
FROM base AS build
|
|
685
|
+
WORKDIR /app
|
|
686
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
687
|
+
COPY . .
|
|
688
|
+
RUN node_modules/.bin/next build && mkdir -p public
|
|
689
|
+
|
|
690
|
+
FROM base AS run
|
|
691
|
+
WORKDIR /app
|
|
692
|
+
ENV NODE_ENV=production PORT=8080 HOSTNAME=0.0.0.0
|
|
693
|
+
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs
|
|
694
|
+
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
|
|
695
|
+
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
696
|
+
COPY --from=build --chown=nextjs:nodejs /app/public ./public
|
|
697
|
+
USER nextjs
|
|
698
|
+
EXPOSE 8080
|
|
699
|
+
CMD ["node", "server.js"]
|
|
700
|
+
`;
|
|
701
|
+
}
|
|
702
|
+
const DOCKERIGNORE = `# Generated by \`dopus infra gcp\`. Keep secrets and build output out of the image.
|
|
703
|
+
node_modules
|
|
704
|
+
.next
|
|
705
|
+
.open-next
|
|
706
|
+
.wrangler
|
|
707
|
+
.git
|
|
708
|
+
.env
|
|
709
|
+
.env.*
|
|
710
|
+
.dev.vars*
|
|
711
|
+
npm-debug.log*
|
|
712
|
+
Dockerfile
|
|
713
|
+
.dockerignore
|
|
714
|
+
`;
|
|
715
|
+
const STANDALONE = /output\s*:\s*['"]standalone['"]/;
|
|
716
|
+
/**
|
|
717
|
+
* Adds `output: 'standalone'` to the usual `const nextConfig = {…}` / `export default {…}` shapes.
|
|
718
|
+
* Returns undefined when the file does not match a shape it can edit safely.
|
|
719
|
+
*/
|
|
720
|
+
function addStandalone(source) {
|
|
721
|
+
if (STANDALONE.test(source)) return source;
|
|
722
|
+
if (/\boutput\s*:/.test(source)) return void 0;
|
|
723
|
+
for (const pattern of [
|
|
724
|
+
/(const\s+nextConfig(?:\s*:\s*NextConfig)?\s*=\s*)\{\s*\}/,
|
|
725
|
+
/(const\s+nextConfig(?:\s*:\s*NextConfig)?\s*=\s*)\{/,
|
|
726
|
+
/(export\s+default\s*)\{\s*\}/,
|
|
727
|
+
/(module\.exports\s*=\s*)\{\s*\}/
|
|
728
|
+
]) {
|
|
729
|
+
if (!pattern.test(source)) continue;
|
|
730
|
+
return source.replace(pattern, (match, head) => match.trimEnd().endsWith("}") ? `${head}{\n output: 'standalone',\n}` : `${head}{\n output: 'standalone',`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function secretId(envVar) {
|
|
734
|
+
return envVar.toLowerCase().replaceAll("_", "-");
|
|
735
|
+
}
|
|
736
|
+
async function runInfraGcp(options, io) {
|
|
737
|
+
const project = detectProject(options.cwd);
|
|
738
|
+
const { root } = project;
|
|
739
|
+
const ask = async (message, initial) => options.yes ? initial : io.confirm(message, initial);
|
|
740
|
+
const result = {
|
|
741
|
+
created: [],
|
|
742
|
+
skipped: [],
|
|
743
|
+
standalone: false
|
|
744
|
+
};
|
|
745
|
+
const nextConfig = [
|
|
746
|
+
"next.config.ts",
|
|
747
|
+
"next.config.mjs",
|
|
748
|
+
"next.config.js"
|
|
749
|
+
].find((f) => existsSync(join(root, f)));
|
|
750
|
+
if (!nextConfig) {
|
|
751
|
+
writeFileSync(join(root, "next.config.mjs"), "/** @type {import('next').NextConfig} */\nexport default { output: 'standalone' }\n");
|
|
752
|
+
result.created.push("next.config.mjs");
|
|
753
|
+
result.standalone = true;
|
|
754
|
+
io.success("Created next.config.mjs with output: 'standalone'");
|
|
755
|
+
} else {
|
|
756
|
+
const source = readFileSync(join(root, nextConfig), "utf8");
|
|
757
|
+
const patched = addStandalone(source);
|
|
758
|
+
if (patched === source) result.standalone = true;
|
|
759
|
+
else if (patched && await ask(`Set output: 'standalone' in ${nextConfig}? (needed for the container)`, true)) {
|
|
760
|
+
writeFileSync(join(root, nextConfig), patched);
|
|
761
|
+
result.standalone = true;
|
|
762
|
+
io.success(`${nextConfig}: set output: 'standalone'`);
|
|
763
|
+
} else io.warn(`Set \`output: 'standalone'\` in ${nextConfig} yourself; the Dockerfile copies .next/standalone.`);
|
|
764
|
+
}
|
|
765
|
+
const files = [["Dockerfile", () => {
|
|
766
|
+
const declared = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).packageManager;
|
|
767
|
+
return dockerfile(project.packageManager, declared ? void 0 : localVersion(project.packageManager, root));
|
|
768
|
+
}], [".dockerignore", () => DOCKERIGNORE]];
|
|
769
|
+
for (const [file, content] of files) {
|
|
770
|
+
if (existsSync(join(root, file))) {
|
|
771
|
+
result.skipped.push(file);
|
|
772
|
+
io.info(`${file} exists — left unchanged.`);
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
writeFileSync(join(root, file), content());
|
|
776
|
+
result.created.push(file);
|
|
777
|
+
io.success(`Created ${file}`);
|
|
778
|
+
}
|
|
779
|
+
if (result.skipped.includes(".dockerignore")) {
|
|
780
|
+
const ignore = readFileSync(join(root, ".dockerignore"), "utf8");
|
|
781
|
+
if (!/^\.env/m.test(ignore)) io.warn(".dockerignore does not exclude .env files: your secrets would end up in the image.");
|
|
782
|
+
}
|
|
783
|
+
const agentConfig = [join(project.configDir, "agent.config.ts"), "agent.config.ts"].map((f) => join(root, f)).find((f) => existsSync(f));
|
|
784
|
+
const keyEnv = detectConfiguredProvider(agentConfig ? readFileSync(agentConfig, "utf8") : void 0)?.envVar ?? "OPENAI_API_KEY";
|
|
785
|
+
const service = workerName(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name);
|
|
786
|
+
io.note([
|
|
787
|
+
"Secrets (Secret Manager), once per project:",
|
|
788
|
+
` printf %s "$KEY" | gcloud secrets create ${secretId(keyEnv)} --data-file=-`,
|
|
789
|
+
` openssl rand -base64 32 | tr -d '\\n' | gcloud secrets create ${secretId(APPROVAL_SECRET_ENV)} --data-file=-`,
|
|
790
|
+
" (the Cloud Run service account needs roles/secretmanager.secretAccessor)",
|
|
791
|
+
"",
|
|
792
|
+
"Deploy (Cloud Build uses the Dockerfile):",
|
|
793
|
+
` gcloud run deploy ${service} --source . --region <region> --timeout 3600 \\`,
|
|
794
|
+
` --set-secrets ${keyEnv}=${secretId(keyEnv)}:latest,${APPROVAL_SECRET_ENV}=${secretId(APPROVAL_SECRET_ENV)}:latest`,
|
|
795
|
+
"",
|
|
796
|
+
"Local check:",
|
|
797
|
+
` docker build -t ${service} . && docker run --rm -p 8080:8080 --env-file .env.local ${service}`
|
|
798
|
+
].join("\n"), "Deploy to Cloud Run");
|
|
799
|
+
io.note([
|
|
800
|
+
"Timeout: a request (the whole streamed reply) may last up to 60 min",
|
|
801
|
+
" (--timeout 3600; default 5 min). For longer work use Cloud Run Jobs",
|
|
802
|
+
" and pass the user explicitly (identify → ctx.user): no cookies there.",
|
|
803
|
+
"Instances are billed while a stream is open; keep-alive comments (15 s)",
|
|
804
|
+
" keep load balancers and proxies from dropping long tool calls."
|
|
805
|
+
].join("\n"), "Cloud Run notes");
|
|
806
|
+
return result;
|
|
807
|
+
}
|
|
368
808
|
// src/cli.ts
|
|
369
809
|
const HELP = `dopus ${VERSION}
|
|
370
810
|
|
|
371
811
|
Usage:
|
|
372
|
-
dopus init [options]
|
|
812
|
+
dopus init [options] Add an AI agent to this Next.js app
|
|
813
|
+
dopus infra aws [options] Prepare the agent route for AWS Lambda (OpenNext streaming + SST snippet)
|
|
814
|
+
dopus infra cloudflare Prepare the app for Cloudflare Workers (@opennextjs/cloudflare)
|
|
815
|
+
dopus infra gcp Prepare the app for Cloud Run (standalone build + Dockerfile)
|
|
373
816
|
|
|
374
817
|
Options for init:
|
|
375
818
|
--provider <id> openai | anthropic | google
|
|
@@ -380,6 +823,11 @@ Options for init:
|
|
|
380
823
|
--force Overwrite generated files that already exist
|
|
381
824
|
--cwd <dir> Project root (default: current directory)
|
|
382
825
|
|
|
826
|
+
Options for infra:
|
|
827
|
+
-y, --yes Do not ask before installing @opennextjs/aws
|
|
828
|
+
--no-install Print the install command instead
|
|
829
|
+
--cwd <dir> Project root
|
|
830
|
+
|
|
383
831
|
-h, --help Show this help
|
|
384
832
|
-v, --version Show the version
|
|
385
833
|
`;
|
|
@@ -424,9 +872,9 @@ const io = {
|
|
|
424
872
|
info: (message) => p.log.info(message),
|
|
425
873
|
warn: (message) => p.log.warn(message),
|
|
426
874
|
success: (message) => p.log.success(message),
|
|
427
|
-
install(packageManager, packages, cwd) {
|
|
875
|
+
install(packageManager, packages, cwd, dev) {
|
|
428
876
|
return new Promise((resolve, reject) => {
|
|
429
|
-
const child = spawn(packageManager, installArgs(packageManager, packages), {
|
|
877
|
+
const child = spawn(packageManager, installArgs(packageManager, packages, dev), {
|
|
430
878
|
cwd,
|
|
431
879
|
stdio: "inherit",
|
|
432
880
|
shell: process.platform === "win32"
|
|
@@ -477,10 +925,47 @@ async function init(args) {
|
|
|
477
925
|
p.note(steps.map((s, i) => `${i + 1}. ${s}`).join("\n"), "Next steps");
|
|
478
926
|
p.outro("Done.");
|
|
479
927
|
}
|
|
928
|
+
async function infra(args) {
|
|
929
|
+
const [target, ...rest] = args;
|
|
930
|
+
const runners = {
|
|
931
|
+
aws: runInfraAws,
|
|
932
|
+
cloudflare: runInfraCloudflare,
|
|
933
|
+
gcp: runInfraGcp
|
|
934
|
+
};
|
|
935
|
+
if (!target || !(target in runners)) throw new InitError("Usage: dopus infra aws | cloudflare | gcp");
|
|
936
|
+
const { values } = parseArgs({
|
|
937
|
+
args: rest,
|
|
938
|
+
options: {
|
|
939
|
+
yes: {
|
|
940
|
+
type: "boolean",
|
|
941
|
+
short: "y"
|
|
942
|
+
},
|
|
943
|
+
install: {
|
|
944
|
+
type: "boolean",
|
|
945
|
+
default: true
|
|
946
|
+
},
|
|
947
|
+
"no-install": { type: "boolean" },
|
|
948
|
+
cwd: { type: "string" }
|
|
949
|
+
},
|
|
950
|
+
allowNegative: true
|
|
951
|
+
});
|
|
952
|
+
p.intro(`dopus ${VERSION} · infra ${target}`);
|
|
953
|
+
const run = runners[target];
|
|
954
|
+
await run({
|
|
955
|
+
cwd: values.cwd ?? process.cwd(),
|
|
956
|
+
yes: values.yes,
|
|
957
|
+
install: values["no-install"] ? false : values.install
|
|
958
|
+
}, {
|
|
959
|
+
...io,
|
|
960
|
+
note: (message, title) => p.note(message, title)
|
|
961
|
+
});
|
|
962
|
+
p.outro("Done. Nothing was deployed.");
|
|
963
|
+
}
|
|
480
964
|
async function main() {
|
|
481
965
|
const [command, ...rest] = process.argv.slice(2);
|
|
482
966
|
if (command === "--version" || command === "-v") return console.log(VERSION);
|
|
483
967
|
if (command === "init") return init(rest);
|
|
968
|
+
if (command === "infra") return infra(rest);
|
|
484
969
|
console.log(HELP);
|
|
485
970
|
if (command && command !== "--help" && command !== "-h") process.exitCode = 1;
|
|
486
971
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -103,8 +103,13 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
|
|
|
103
103
|
approvalSecret?: string;
|
|
104
104
|
/** Extra `streamText` settings (temperature, providerOptions, ...). */
|
|
105
105
|
callOptions?: Pick<StreamTextOptions, 'temperature' | 'maxOutputTokens' | 'providerOptions' | 'maxRetries' | 'headers' | 'onEnd' | 'onStepEnd'>;
|
|
106
|
-
/** Called for errors; the client only
|
|
106
|
+
/** Called for unexpected errors (not `ToolError`); the client only sees a generic message for those. */
|
|
107
107
|
onError?: (error: unknown) => void;
|
|
108
|
+
/**
|
|
109
|
+
* While the stream is idle (e.g. a slow tool), send an SSE comment every this many ms so proxies
|
|
110
|
+
* (CloudFront, ALB, nginx) do not drop the connection. Clients ignore comments. Default 15000; `false` disables.
|
|
111
|
+
*/
|
|
112
|
+
keepAlive?: number | false;
|
|
108
113
|
}
|
|
109
114
|
/** Identity function that gives `agent.config.ts` type checking. */
|
|
110
115
|
export declare function defineAgent<TOOLS extends DopusToolSet>(config: AgentConfig<TOOLS>): AgentConfig<TOOLS>;
|
|
@@ -145,5 +150,5 @@ interface ProviderInfo {
|
|
|
145
150
|
}
|
|
146
151
|
/** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
|
|
147
152
|
export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
|
|
148
|
-
export declare const VERSION = "0.1
|
|
153
|
+
export declare const VERSION = "0.2.1";
|
|
149
154
|
export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolContext, ToolOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as PROVIDERS, t as VERSION } from "./version-
|
|
1
|
+
import { n as PROVIDERS, t as VERSION } from "./version-BWFAqCId.js";
|
|
2
2
|
import { convertToModelMessages, createUIMessageStreamResponse, isStepCount, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
|
|
3
3
|
// src/next-errors.ts
|
|
4
4
|
/**
|
|
@@ -112,6 +112,52 @@ function toolDescription(options) {
|
|
|
112
112
|
return usage ? `${description}\n\nWhen to use: ${usage}` : description;
|
|
113
113
|
}
|
|
114
114
|
// src/agent.ts
|
|
115
|
+
const KEEP_ALIVE = new TextEncoder().encode(": keep-alive\n\n");
|
|
116
|
+
/**
|
|
117
|
+
* Inserts SSE comment lines while the body is idle. The UI message stream emits one complete
|
|
118
|
+
* `data: …\n\n` event per chunk, so writing between chunks never splits an event.
|
|
119
|
+
*/
|
|
120
|
+
function withKeepAlive(response, intervalMs) {
|
|
121
|
+
const source = response.body;
|
|
122
|
+
if (!source || intervalMs <= 0) return response;
|
|
123
|
+
const reader = source.getReader();
|
|
124
|
+
let timer;
|
|
125
|
+
let lastWrite = Date.now();
|
|
126
|
+
const body = new ReadableStream({
|
|
127
|
+
start(controller) {
|
|
128
|
+
timer = setInterval(() => {
|
|
129
|
+
if (Date.now() - lastWrite >= intervalMs) {
|
|
130
|
+
controller.enqueue(KEEP_ALIVE);
|
|
131
|
+
lastWrite = Date.now();
|
|
132
|
+
}
|
|
133
|
+
}, Math.min(intervalMs, 1e3));
|
|
134
|
+
},
|
|
135
|
+
async pull(controller) {
|
|
136
|
+
try {
|
|
137
|
+
const { done, value } = await reader.read();
|
|
138
|
+
if (done) {
|
|
139
|
+
clearInterval(timer);
|
|
140
|
+
controller.close();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
lastWrite = Date.now();
|
|
144
|
+
controller.enqueue(value);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
clearInterval(timer);
|
|
147
|
+
controller.error(error);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
cancel(reason) {
|
|
151
|
+
clearInterval(timer);
|
|
152
|
+
return reader.cancel(reason);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return new Response(body, {
|
|
156
|
+
status: response.status,
|
|
157
|
+
statusText: response.statusText,
|
|
158
|
+
headers: response.headers
|
|
159
|
+
});
|
|
160
|
+
}
|
|
115
161
|
/** Identity function that gives `agent.config.ts` type checking. */
|
|
116
162
|
function defineAgent(config) {
|
|
117
163
|
for (const [name, value] of Object.entries(config.tools)) {
|
|
@@ -217,15 +263,17 @@ function createAgentHandler(config) {
|
|
|
217
263
|
...approvalSecret ? { experimental_toolApprovalSecret: approvalSecret } : {},
|
|
218
264
|
onError: ({ error }) => report(error)
|
|
219
265
|
});
|
|
220
|
-
|
|
266
|
+
const response = createUIMessageStreamResponse({ stream: toUIMessageStream({
|
|
221
267
|
stream: result.stream,
|
|
222
268
|
tools,
|
|
223
269
|
originalMessages: messages,
|
|
224
270
|
onError: (error) => {
|
|
271
|
+
if (error instanceof ToolError) return error.message;
|
|
225
272
|
report(error);
|
|
226
273
|
return "Something went wrong. Please try again.";
|
|
227
274
|
}
|
|
228
275
|
}) });
|
|
276
|
+
return config.keepAlive === false ? response : withKeepAlive(response, config.keepAlive ?? 15e3);
|
|
229
277
|
};
|
|
230
278
|
}
|
|
231
279
|
export { CORE_PROMPT, PROVIDERS, ToolError, VERSION, buildInstructions, createAgentHandler, defineAgent, defineTool };
|