dopus 0.2.3 → 0.2.4

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 CHANGED
@@ -204,9 +204,55 @@ defineAgent({
204
204
  })
205
205
  ```
206
206
 
207
+ ## Timeouts and long-running tools
208
+
209
+ An agent turn can take a while: several model calls plus your tools. Two kinds of limits apply, handled differently.
210
+
211
+ **Idle timeouts** (CloudFront, ALB, nginx: usually 60 s without a byte). Handled:
212
+
213
+ - The route streams tokens and tool states as they happen; it never waits for the whole answer.
214
+ - While a slow tool runs, an SSE comment (`: keep-alive`) goes out every 15 s (`keepAlive` option). Clients ignore it.
215
+ - Waiting for an approval holds nothing open. The request ends at the approval card, and the answer starts a new one.
216
+
217
+ **The platform's total limit** for one request (Vercel `maxDuration`, Lambda timeout, Cloud Run `--timeout`). It cannot be
218
+ streamed around, so dopus stays inside it:
219
+
220
+ ```ts
221
+ // app/api/agent/route.ts (as generated)
222
+ export const maxDuration = 60
223
+ export const POST = createAgentHandler(agent, { maxDuration })
224
+ ```
225
+
226
+ With `maxDuration` passed, once 75% of it has passed the agent starts no more tool calls. It answers with what it has done
227
+ and says what is left, and the user can reply "continue". Without it, a long turn is cut off mid-stream and the chat shows
228
+ an error. A step already running (one model call or one tool) is not interrupted, so keep single tools well under the
229
+ limit. `maxSteps` (default 10) also bounds a turn.
230
+
231
+ **Tools that take longer than a request.** Exports, bulk updates, report generation and the like should not run inside a
232
+ tool call. Split them into two tools:
233
+
234
+ ```ts
235
+ startExport: defineTool((input) => startExportJob(input), { // enqueue, return { jobId } at once
236
+ description: 'Starts a CSV export and returns a job id.',
237
+ input: z.object({ from: z.string(), to: z.string() }),
238
+ confirm: true,
239
+ }),
240
+ exportStatus: defineTool((input) => getExportJob(input.jobId), { // { status, url? }
241
+ description: 'Returns the status of an export job and its download link when done.',
242
+ input: z.object({ jobId: z.string() }),
243
+ }),
244
+ ```
245
+
246
+ The job runs in your queue or background worker, outside the request, so it does not have the request's cookies. Check
247
+ permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
248
+ check that the job belongs to the caller.
249
+
250
+ **If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
251
+ Tools behind `confirm: true` need a new approval; make other writes idempotent.
252
+
207
253
  ## Deploying
208
254
 
209
- **Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it.
255
+ **Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it; the time budget follows it.
210
256
 
211
257
  **AWS (OpenNext / SST).**
212
258
 
@@ -221,7 +267,7 @@ SST reads OpenNext's streaming flag and sets the Function URL to `RESPONSE_STREA
221
267
  Things to know:
222
268
 
223
269
  - **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.
224
- - **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.
270
+ - **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. Keep the route's `maxDuration` equal to the Lambda timeout so the time budget matches.
225
271
  - **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.
226
272
  - **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.
227
273
 
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-CwDWliFC.js";
2
+ import { n as PROVIDERS, t as VERSION } from "./version-CNF5aJ7W.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";
@@ -227,10 +227,11 @@ function routeTemplate(configImport) {
227
227
  return `import { createAgentHandler } from 'dopus'
228
228
  import agent from '${configImport}'
229
229
 
230
- // Agent replies stream for a while; raise this if your platform cuts requests short.
230
+ // Seconds this route may run (your platform's limit applies). Passing it lets the agent stop calling tools at
231
+ // 75% of it and answer with what it has, instead of being cut off mid-stream.
231
232
  export const maxDuration = 60
232
233
 
233
- export const POST = createAgentHandler(agent)
234
+ export const POST = createAgentHandler(agent, { maxDuration })
234
235
  `;
235
236
  }
236
237
  // src/init/run.ts
package/dist/index.d.ts CHANGED
@@ -131,12 +131,19 @@ interface ClientContext {
131
131
  locale?: string;
132
132
  page?: string;
133
133
  }
134
+ interface HandlerOptions {
135
+ /**
136
+ * The route's `maxDuration` in seconds (pass the exported constant). Once 75% of it has passed, the agent
137
+ * starts no further tool calls and answers with what it has, instead of being cut off by the platform.
138
+ */
139
+ maxDuration?: number;
140
+ }
134
141
  /**
135
142
  * Creates a `POST` route handler that speaks the AI SDK UI message stream protocol (`useChat`).
136
143
  *
137
144
  * Tools execute inside this request, so server functions they call see the caller's cookies.
138
145
  */
139
- export declare function createAgentHandler(config: AgentConfig): (request: Request) => Promise<Response>;
146
+ export declare function createAgentHandler(config: AgentConfig, options?: HandlerOptions): (request: Request) => Promise<Response>;
140
147
  /**
141
148
  * An error whose message is safe to show to the model (and, through it, the user).
142
149
  * Any other error thrown by a tool is reported via `onError` and replaced with a generic message,
@@ -159,5 +166,5 @@ interface ProviderInfo {
159
166
  }
160
167
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
161
168
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
162
- export declare const VERSION = "0.2.3";
163
- export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolContext, ToolOptions };
169
+ export declare const VERSION = "0.2.4";
170
+ export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, HandlerOptions, 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-CwDWliFC.js";
1
+ import { n as PROVIDERS, t as VERSION } from "./version-CNF5aJ7W.js";
2
2
  import { convertToModelMessages, createUIMessageStreamResponse, isStepCount, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
3
3
  // src/next-errors.ts
4
4
  /**
@@ -220,15 +220,19 @@ function errorResponse(status, message) {
220
220
  headers: JSON_HEADERS
221
221
  });
222
222
  }
223
+ /** Share of `maxDuration` after which no new tool calls start; the rest is left for the final answer. */
224
+ const TIME_BUDGET_SHARE = .75;
225
+ const TIME_UP = `Time for this request is almost up. Do not call any more tools. Answer now with what you have done and found so far. If work remains, say what is left so the user can ask you to continue.`;
223
226
  /**
224
227
  * Creates a `POST` route handler that speaks the AI SDK UI message stream protocol (`useChat`).
225
228
  *
226
229
  * Tools execute inside this request, so server functions they call see the caller's cookies.
227
230
  */
228
- function createAgentHandler(config) {
231
+ function createAgentHandler(config, options = {}) {
229
232
  const maxSteps = config.maxSteps ?? 10;
230
233
  const report = config.onError ?? ((error) => console.error("[dopus]", error));
231
234
  return async function handleAgentRequest(request) {
235
+ const deadline = options.maxDuration ? Date.now() + options.maxDuration * 1e3 * TIME_BUDGET_SHARE : void 0;
232
236
  const maxBodyBytes = config.limits?.maxBodyBytes ?? 1e6;
233
237
  const maxMessageChars = config.limits?.maxMessageChars ?? 8e3;
234
238
  if (Number(request.headers.get("content-length") ?? 0) > maxBodyBytes) return errorResponse(413, "Conversation is too long");
@@ -266,6 +270,19 @@ function createAgentHandler(config) {
266
270
  tools,
267
271
  toolApproval,
268
272
  stopWhen: isStepCount(maxSteps),
273
+ prepareStep: ({ stepNumber, instructions }) => {
274
+ if (deadline === void 0 || stepNumber === 0 || Date.now() < deadline) return {};
275
+ return {
276
+ toolChoice: "none",
277
+ instructions: [...(instructions === void 0 ? [] : [instructions].flat()).map((m) => typeof m === "string" ? {
278
+ role: "system",
279
+ content: m
280
+ } : m).filter((m) => m.content !== TIME_UP), {
281
+ role: "system",
282
+ content: TIME_UP
283
+ }]
284
+ };
285
+ },
269
286
  abortSignal: request.signal,
270
287
  ...approvalSecret ? { experimental_toolApprovalSecret: approvalSecret } : {},
271
288
  onError: ({ error }) => report(error)
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.3";
30
+ const VERSION = "0.2.4";
31
31
  export { PROVIDERS as n, VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dopus",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Drop an AI agent into your existing Next.js app — your server functions become its tools.",
5
5
  "license": "MIT",
6
6
  "author": "akodm",