dopus 0.2.3 → 0.2.5
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 +98 -11
- package/dist/cli.js +7 -4
- package/dist/index.d.ts +55 -4
- package/dist/index.js +89 -6
- package/dist/{version-CwDWliFC.js → version-DmrGrSmM.js} +1 -1
- package/package.json +3 -1
- package/templates/ui/agent-chat.css +92 -0
- package/templates/ui/agent-chat.tsx +98 -19
package/README.md
CHANGED
|
@@ -134,14 +134,14 @@ Any other error goes to `onError` and is replaced with a generic message, so dat
|
|
|
134
134
|
|
|
135
135
|
**Prompt injection.** Assume the model can be talked into anything: by the user, or by text inside tool results that other people wrote (a task title, a comment, an email body). dopus does not rely on the model resisting it. The limits are structural:
|
|
136
136
|
|
|
137
|
-
| Layer | What it guarantees
|
|
138
|
-
| ---------------------- |
|
|
139
|
-
| Permission inheritance | A tool can do only what the signed-in user can do through your server functions.
|
|
140
|
-
| `confirm: true` | The user sees the exact input and must approve; signed approvals cannot be forged or edited.
|
|
141
|
-
|
|
|
142
|
-
| Request limits | `limits.maxBodyBytes` (1 MB) and `limits.maxMessageChars` (8000) answer 413 before the model runs.
|
|
143
|
-
| `maxSteps` | At most 10 model ↔ tool round trips per request.
|
|
144
|
-
| Core prompt | Tells the model that tool results are data, not instructions. Helpful, but not a guarantee.
|
|
137
|
+
| Layer | What it guarantees |
|
|
138
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
139
|
+
| Permission inheritance | A tool can do only what the signed-in user can do through your server functions. |
|
|
140
|
+
| `confirm: true` | The user sees the exact input and must approve; signed approvals cannot be forged or edited. |
|
|
141
|
+
| Chat UI rendering | Markdown without raw HTML, images never loaded, only http(s)/mailto/relative links: no XSS and no image-URL data leaks. |
|
|
142
|
+
| Request limits | `limits.maxBodyBytes` (1 MB) and `limits.maxMessageChars` (8000) answer 413 before the model runs. |
|
|
143
|
+
| `maxSteps` | At most 10 model ↔ tool round trips per request. |
|
|
144
|
+
| Core prompt | Tells the model that tool results are data, not instructions. Helpful, but not a guarantee. |
|
|
145
145
|
|
|
146
146
|
Your part, so an injected instruction cannot cause damage:
|
|
147
147
|
|
|
@@ -175,7 +175,7 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
|
|
|
175
175
|
|
|
176
176
|
Both accept `api`, `suggestions`, `labels` (every UI string, for translation) and `className`.
|
|
177
177
|
|
|
178
|
-
The UI shows each tool call as it runs. It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
|
|
178
|
+
The UI shows each tool call as it runs. It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation, and a "New chat" button clears it. Assistant replies render as Markdown (tables, lists, code) through `react-markdown` with `remark-gfm`, which `init` installs. Raw HTML is never rendered, images show only their alt text, and links keep only http(s), mailto and relative URLs. If a request is over the size limit (413), the UI says so and offers a new chat instead of a useless retry. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
|
|
179
179
|
|
|
180
180
|
Styling is one plain CSS file (`agent-chat.css`) whose classes all start with `dopus-`, themed through `--dopus-*` variables, with dark mode included. It is deliberately not a CSS Module: create-next-app's Tailwind setup routes every `*.css` file, modules included, through its own Turbopack loader, which left module classes unstyled. It needs no Tailwind and no component library. The file is yours, so restyle it or swap in your own components.
|
|
181
181
|
|
|
@@ -190,6 +190,9 @@ defineAgent({
|
|
|
190
190
|
identify: async (request) => ({ id: 'user-1' }), // optional
|
|
191
191
|
maxSteps: 10, // model ↔ tool round trips per request
|
|
192
192
|
limits: { maxBodyBytes: 1_000_000, maxMessageChars: 8_000 }, // over → 413
|
|
193
|
+
beforeRequest,
|
|
194
|
+
onToolCall,
|
|
195
|
+
onFinish, // see "Audit log, usage and rate limits"
|
|
193
196
|
approvalSecret, // default: process.env.DOPUS_APPROVAL_SECRET
|
|
194
197
|
callOptions: {
|
|
195
198
|
temperature,
|
|
@@ -204,9 +207,93 @@ defineAgent({
|
|
|
204
207
|
})
|
|
205
208
|
```
|
|
206
209
|
|
|
210
|
+
## Audit log, usage and rate limits
|
|
211
|
+
|
|
212
|
+
Three optional hooks in `defineAgent`. `user` in each comes from `identify`, so define it if you need to know who asked.
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
defineAgent({
|
|
216
|
+
// ...
|
|
217
|
+
identify: async () => {
|
|
218
|
+
const session = await getSession()
|
|
219
|
+
return session ? { id: session.userId } : undefined
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
// Before the model is called. Return a Response to answer with it instead.
|
|
223
|
+
beforeRequest: async ({ user, request, messages }) => {
|
|
224
|
+
const { success } = await ratelimit.limit(user?.id ?? ip(request))
|
|
225
|
+
if (!success) return Response.json({ error: 'Too many requests' }, { status: 429 })
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
// Every tool call: outcome 'success' | 'error' | 'denied' (the user declined the approval).
|
|
229
|
+
onToolCall: async ({ user, tool, input, outcome, output, error, durationMs }) => {
|
|
230
|
+
await db.auditLog.insert({ userId: user?.id, tool, input, outcome, error, durationMs })
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// Once per request, when the reply is done: tokens summed over all steps.
|
|
234
|
+
onFinish: async ({ user, model, usage, steps, finishReason, durationMs }) => {
|
|
235
|
+
await db.usage.insert({ userId: user?.id, model: model.modelId, ...usage })
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- `onToolCall` gets the tool's original error message. It stays on the server; the model and the browser get the safe
|
|
241
|
+
message described under [Errors](#errors).
|
|
242
|
+
- A hook that throws goes to `onError` and does not break the chat. Hooks are awaited, so a slow log write delays the
|
|
243
|
+
reply; queue the write if that matters.
|
|
244
|
+
- `usage.cachedInputTokens` shows prompt caching at work. In the demo task board with Claude, about 90% of input tokens
|
|
245
|
+
were cache reads.
|
|
246
|
+
- The chat UI shows a generic error for a 429. Pass your own `labels.error` if you want different wording.
|
|
247
|
+
|
|
248
|
+
## Timeouts and long-running tools
|
|
249
|
+
|
|
250
|
+
An agent turn can take a while: several model calls plus your tools. Two kinds of limits apply, handled differently.
|
|
251
|
+
|
|
252
|
+
**Idle timeouts** (CloudFront, ALB, nginx: usually 60 s without a byte). Handled:
|
|
253
|
+
|
|
254
|
+
- The route streams tokens and tool states as they happen; it never waits for the whole answer.
|
|
255
|
+
- While a slow tool runs, an SSE comment (`: keep-alive`) goes out every 15 s (`keepAlive` option). Clients ignore it.
|
|
256
|
+
- Waiting for an approval holds nothing open. The request ends at the approval card, and the answer starts a new one.
|
|
257
|
+
|
|
258
|
+
**The platform's total limit** for one request (Vercel `maxDuration`, Lambda timeout, Cloud Run `--timeout`). It cannot be
|
|
259
|
+
streamed around, so dopus stays inside it:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// app/api/agent/route.ts (as generated)
|
|
263
|
+
export const maxDuration = 60
|
|
264
|
+
export const POST = createAgentHandler(agent, { maxDuration })
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
With `maxDuration` passed, once 75% of it has passed the agent starts no more tool calls. It answers with what it has done
|
|
268
|
+
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
|
|
269
|
+
an error. A step already running (one model call or one tool) is not interrupted, so keep single tools well under the
|
|
270
|
+
limit. `maxSteps` (default 10) also bounds a turn.
|
|
271
|
+
|
|
272
|
+
**Tools that take longer than a request.** Exports, bulk updates, report generation and the like should not run inside a
|
|
273
|
+
tool call. Split them into two tools:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
startExport: defineTool((input) => startExportJob(input), { // enqueue, return { jobId } at once
|
|
277
|
+
description: 'Starts a CSV export and returns a job id.',
|
|
278
|
+
input: z.object({ from: z.string(), to: z.string() }),
|
|
279
|
+
confirm: true,
|
|
280
|
+
}),
|
|
281
|
+
exportStatus: defineTool((input) => getExportJob(input.jobId), { // { status, url? }
|
|
282
|
+
description: 'Returns the status of an export job and its download link when done.',
|
|
283
|
+
input: z.object({ jobId: z.string() }),
|
|
284
|
+
}),
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The job runs in your queue or background worker, outside the request, so it does not have the request's cookies. Check
|
|
288
|
+
permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
|
|
289
|
+
check that the job belongs to the caller.
|
|
290
|
+
|
|
291
|
+
**If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
|
|
292
|
+
Tools behind `confirm: true` need a new approval; make other writes idempotent.
|
|
293
|
+
|
|
207
294
|
## Deploying
|
|
208
295
|
|
|
209
|
-
**Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it.
|
|
296
|
+
**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
297
|
|
|
211
298
|
**AWS (OpenNext / SST).**
|
|
212
299
|
|
|
@@ -221,7 +308,7 @@ SST reads OpenNext's streaming flag and sets the Function URL to `RESPONSE_STREA
|
|
|
221
308
|
Things to know:
|
|
222
309
|
|
|
223
310
|
- **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.
|
|
311
|
+
- **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
312
|
- **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
313
|
- **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
314
|
|
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-DmrGrSmM.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
|
-
//
|
|
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
|
|
@@ -366,7 +367,9 @@ async function runInit(options, io) {
|
|
|
366
367
|
["ai", "ai@^7"],
|
|
367
368
|
["zod", "zod@^4"],
|
|
368
369
|
[info.packageName, `${info.packageName}@^4`],
|
|
369
|
-
["@ai-sdk/react", "@ai-sdk/react@^4"]
|
|
370
|
+
["@ai-sdk/react", "@ai-sdk/react@^4"],
|
|
371
|
+
["react-markdown", "react-markdown@^10"],
|
|
372
|
+
["remark-gfm", "remark-gfm@^4"]
|
|
370
373
|
].filter(([name]) => !project.dependencies[name]).map(([, spec]) => spec);
|
|
371
374
|
if (missing.length > 0) {
|
|
372
375
|
if (options.install === false) io.warn(`Install these yourself: ${project.packageManager} ${installArgs(project.packageManager, missing).join(" ")}`);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LanguageModel, SystemModelMessage, streamText } from "ai";
|
|
1
|
+
import { LanguageModel, SystemModelMessage, UIMessage, streamText } from "ai";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
/**
|
|
4
4
|
* Layer 1 (Core). Kept short: tool-use and safety rules that apply to every app.
|
|
@@ -94,6 +94,19 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
|
|
|
94
94
|
* needed when tools run outside the request. Return `undefined` for anonymous users.
|
|
95
95
|
*/
|
|
96
96
|
identify?: (request: Request) => AgentUser | undefined | Promise<AgentUser | undefined>;
|
|
97
|
+
/**
|
|
98
|
+
* Runs before the model is called, after the body is validated. Return a `Response` (e.g. a 429 from your rate
|
|
99
|
+
* limiter) to answer with it instead; return nothing to continue.
|
|
100
|
+
*/
|
|
101
|
+
beforeRequest?: (ctx: {
|
|
102
|
+
request: Request;
|
|
103
|
+
user: AgentUser | undefined;
|
|
104
|
+
messages: UIMessage[];
|
|
105
|
+
}) => Response | undefined | Promise<Response | undefined>;
|
|
106
|
+
/** Called once per tool call: ran (`success` / `error`) or declined by the user (`denied`). For audit logs. */
|
|
107
|
+
onToolCall?: (event: ToolCallEvent) => void | Promise<void>;
|
|
108
|
+
/** Called once per request when the reply is finished: tokens, steps, model. For usage tracking. */
|
|
109
|
+
onFinish?: (event: FinishEvent) => void | Promise<void>;
|
|
97
110
|
/** Maximum model steps (tool round trips) per request. Default 10. */
|
|
98
111
|
maxSteps?: number;
|
|
99
112
|
/**
|
|
@@ -120,6 +133,37 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
|
|
|
120
133
|
*/
|
|
121
134
|
keepAlive?: number | false;
|
|
122
135
|
}
|
|
136
|
+
interface ToolCallEvent {
|
|
137
|
+
tool: string;
|
|
138
|
+
toolCallId: string;
|
|
139
|
+
input: unknown;
|
|
140
|
+
/** From `identify`; `undefined` if you did not define it. */
|
|
141
|
+
user: AgentUser | undefined;
|
|
142
|
+
outcome: 'success' | 'error' | 'denied';
|
|
143
|
+
/** The tool's return value (`success`). */
|
|
144
|
+
output?: unknown;
|
|
145
|
+
/** The original error message (`error`). Server-side only: the model and the browser see a safe message. */
|
|
146
|
+
error?: string;
|
|
147
|
+
/** Execution time (`success` / `error`). */
|
|
148
|
+
durationMs?: number;
|
|
149
|
+
}
|
|
150
|
+
interface FinishEvent {
|
|
151
|
+
user: AgentUser | undefined;
|
|
152
|
+
model: {
|
|
153
|
+
provider: string;
|
|
154
|
+
modelId: string;
|
|
155
|
+
};
|
|
156
|
+
/** Summed over all steps of this request. */
|
|
157
|
+
usage: {
|
|
158
|
+
inputTokens: number | undefined;
|
|
159
|
+
outputTokens: number | undefined;
|
|
160
|
+
totalTokens: number | undefined;
|
|
161
|
+
cachedInputTokens: number | undefined;
|
|
162
|
+
};
|
|
163
|
+
steps: number;
|
|
164
|
+
finishReason: string;
|
|
165
|
+
durationMs: number;
|
|
166
|
+
}
|
|
123
167
|
/** Identity function that gives `agent.config.ts` type checking. */
|
|
124
168
|
export declare function defineAgent<TOOLS extends DopusToolSet>(config: AgentConfig<TOOLS>): AgentConfig<TOOLS>;
|
|
125
169
|
/**
|
|
@@ -131,12 +175,19 @@ interface ClientContext {
|
|
|
131
175
|
locale?: string;
|
|
132
176
|
page?: string;
|
|
133
177
|
}
|
|
178
|
+
interface HandlerOptions {
|
|
179
|
+
/**
|
|
180
|
+
* The route's `maxDuration` in seconds (pass the exported constant). Once 75% of it has passed, the agent
|
|
181
|
+
* starts no further tool calls and answers with what it has, instead of being cut off by the platform.
|
|
182
|
+
*/
|
|
183
|
+
maxDuration?: number;
|
|
184
|
+
}
|
|
134
185
|
/**
|
|
135
186
|
* Creates a `POST` route handler that speaks the AI SDK UI message stream protocol (`useChat`).
|
|
136
187
|
*
|
|
137
188
|
* Tools execute inside this request, so server functions they call see the caller's cookies.
|
|
138
189
|
*/
|
|
139
|
-
export declare function createAgentHandler(config: AgentConfig): (request: Request) => Promise<Response>;
|
|
190
|
+
export declare function createAgentHandler(config: AgentConfig, options?: HandlerOptions): (request: Request) => Promise<Response>;
|
|
140
191
|
/**
|
|
141
192
|
* An error whose message is safe to show to the model (and, through it, the user).
|
|
142
193
|
* Any other error thrown by a tool is reported via `onError` and replaced with a generic message,
|
|
@@ -159,5 +210,5 @@ interface ProviderInfo {
|
|
|
159
210
|
}
|
|
160
211
|
/** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
|
|
161
212
|
export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
|
|
162
|
-
export declare const VERSION = "0.2.
|
|
163
|
-
export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolContext, ToolOptions };
|
|
213
|
+
export declare const VERSION = "0.2.5";
|
|
214
|
+
export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as PROVIDERS, t as VERSION } from "./version-
|
|
2
|
-
import { convertToModelMessages, createUIMessageStreamResponse, isStepCount, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
|
|
1
|
+
import { n as PROVIDERS, t as VERSION } from "./version-DmrGrSmM.js";
|
|
2
|
+
import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
|
|
3
3
|
// src/next-errors.ts
|
|
4
4
|
/**
|
|
5
5
|
* An error whose message is safe to show to the model (and, through it, the user).
|
|
@@ -167,6 +167,15 @@ function defineAgent(config) {
|
|
|
167
167
|
}
|
|
168
168
|
return config;
|
|
169
169
|
}
|
|
170
|
+
/** Runs a user hook; a failing hook is reported but never breaks the chat. */
|
|
171
|
+
async function runHook(hook, event, report) {
|
|
172
|
+
if (!hook) return;
|
|
173
|
+
try {
|
|
174
|
+
await hook(event);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
report?.(error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
170
179
|
function buildTools(tools, ctx) {
|
|
171
180
|
const aiTools = {};
|
|
172
181
|
const approval = {};
|
|
@@ -176,14 +185,34 @@ function buildTools(tools, ctx) {
|
|
|
176
185
|
description: toolDescription(options),
|
|
177
186
|
inputSchema: options.input,
|
|
178
187
|
execute: async (input, { toolCallId, abortSignal }) => {
|
|
188
|
+
const started = Date.now();
|
|
189
|
+
const base = {
|
|
190
|
+
tool: name,
|
|
191
|
+
toolCallId,
|
|
192
|
+
input,
|
|
193
|
+
user: ctx.user
|
|
194
|
+
};
|
|
179
195
|
try {
|
|
180
196
|
const result = await fn(input, {
|
|
181
197
|
user: ctx.user,
|
|
182
198
|
toolCallId,
|
|
183
199
|
abortSignal
|
|
184
200
|
});
|
|
185
|
-
|
|
201
|
+
const output = options.output ? options.output(result) : result;
|
|
202
|
+
await runHook(ctx.onToolCall, {
|
|
203
|
+
...base,
|
|
204
|
+
outcome: "success",
|
|
205
|
+
output,
|
|
206
|
+
durationMs: Date.now() - started
|
|
207
|
+
}, ctx.report);
|
|
208
|
+
return output;
|
|
186
209
|
} catch (error) {
|
|
210
|
+
await runHook(ctx.onToolCall, {
|
|
211
|
+
...base,
|
|
212
|
+
outcome: "error",
|
|
213
|
+
error: error instanceof Error ? error.message : String(error),
|
|
214
|
+
durationMs: Date.now() - started
|
|
215
|
+
}, ctx.report);
|
|
187
216
|
const translated = translateNextError(error);
|
|
188
217
|
if (translated instanceof ToolError) throw translated;
|
|
189
218
|
ctx.report?.(error);
|
|
@@ -220,15 +249,20 @@ function errorResponse(status, message) {
|
|
|
220
249
|
headers: JSON_HEADERS
|
|
221
250
|
});
|
|
222
251
|
}
|
|
252
|
+
/** Share of `maxDuration` after which no new tool calls start; the rest is left for the final answer. */
|
|
253
|
+
const TIME_BUDGET_SHARE = .75;
|
|
254
|
+
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
255
|
/**
|
|
224
256
|
* Creates a `POST` route handler that speaks the AI SDK UI message stream protocol (`useChat`).
|
|
225
257
|
*
|
|
226
258
|
* Tools execute inside this request, so server functions they call see the caller's cookies.
|
|
227
259
|
*/
|
|
228
|
-
function createAgentHandler(config) {
|
|
260
|
+
function createAgentHandler(config, options = {}) {
|
|
229
261
|
const maxSteps = config.maxSteps ?? 10;
|
|
230
262
|
const report = config.onError ?? ((error) => console.error("[dopus]", error));
|
|
231
263
|
return async function handleAgentRequest(request) {
|
|
264
|
+
const started = Date.now();
|
|
265
|
+
const deadline = options.maxDuration ? Date.now() + options.maxDuration * 1e3 * TIME_BUDGET_SHARE : void 0;
|
|
232
266
|
const maxBodyBytes = config.limits?.maxBodyBytes ?? 1e6;
|
|
233
267
|
const maxMessageChars = config.limits?.maxMessageChars ?? 8e3;
|
|
234
268
|
if (Number(request.headers.get("content-length") ?? 0) > maxBodyBytes) return errorResponse(413, "Conversation is too long");
|
|
@@ -243,12 +277,29 @@ function createAgentHandler(config) {
|
|
|
243
277
|
const user = await config.identify?.(request);
|
|
244
278
|
const { tools, toolApproval } = buildTools(config.tools, {
|
|
245
279
|
user,
|
|
246
|
-
report
|
|
280
|
+
report,
|
|
281
|
+
onToolCall: config.onToolCall
|
|
247
282
|
});
|
|
248
283
|
const validated = await safeValidateUIMessages({ messages: body?.messages });
|
|
249
284
|
if (!validated.success) return errorResponse(400, "Invalid messages");
|
|
250
285
|
const messages = validated.data;
|
|
251
286
|
if (messages.some((message) => message.role === "user" && message.parts.some((part) => part.type === "text" && part.text.length > maxMessageChars))) return errorResponse(413, "Message is too long");
|
|
287
|
+
const early = await config.beforeRequest?.({
|
|
288
|
+
request,
|
|
289
|
+
user,
|
|
290
|
+
messages
|
|
291
|
+
});
|
|
292
|
+
if (early instanceof Response) return early;
|
|
293
|
+
for (const part of messages.at(-1)?.role === "assistant" ? messages.at(-1)?.parts ?? [] : []) {
|
|
294
|
+
if (!isToolUIPart(part) || part.state !== "approval-responded" || part.approval.approved) continue;
|
|
295
|
+
await runHook(config.onToolCall, {
|
|
296
|
+
tool: getToolName(part),
|
|
297
|
+
toolCallId: part.toolCallId,
|
|
298
|
+
input: part.input,
|
|
299
|
+
user,
|
|
300
|
+
outcome: "denied"
|
|
301
|
+
}, report);
|
|
302
|
+
}
|
|
252
303
|
const client = readClientContext(body, request);
|
|
253
304
|
const runtime = {
|
|
254
305
|
...client,
|
|
@@ -266,9 +317,41 @@ function createAgentHandler(config) {
|
|
|
266
317
|
tools,
|
|
267
318
|
toolApproval,
|
|
268
319
|
stopWhen: isStepCount(maxSteps),
|
|
320
|
+
prepareStep: ({ stepNumber, instructions }) => {
|
|
321
|
+
if (deadline === void 0 || stepNumber === 0 || Date.now() < deadline) return {};
|
|
322
|
+
return {
|
|
323
|
+
toolChoice: "none",
|
|
324
|
+
instructions: [...(instructions === void 0 ? [] : [instructions].flat()).map((m) => typeof m === "string" ? {
|
|
325
|
+
role: "system",
|
|
326
|
+
content: m
|
|
327
|
+
} : m).filter((m) => m.content !== TIME_UP), {
|
|
328
|
+
role: "system",
|
|
329
|
+
content: TIME_UP
|
|
330
|
+
}]
|
|
331
|
+
};
|
|
332
|
+
},
|
|
269
333
|
abortSignal: request.signal,
|
|
270
334
|
...approvalSecret ? { experimental_toolApprovalSecret: approvalSecret } : {},
|
|
271
|
-
onError: ({ error }) => report(error)
|
|
335
|
+
onError: ({ error }) => report(error),
|
|
336
|
+
onEnd: async (event) => {
|
|
337
|
+
await config.callOptions?.onEnd?.(event);
|
|
338
|
+
await runHook(config.onFinish, {
|
|
339
|
+
user,
|
|
340
|
+
model: {
|
|
341
|
+
provider: event.model.provider,
|
|
342
|
+
modelId: event.model.modelId
|
|
343
|
+
},
|
|
344
|
+
usage: {
|
|
345
|
+
inputTokens: event.totalUsage.inputTokens,
|
|
346
|
+
outputTokens: event.totalUsage.outputTokens,
|
|
347
|
+
totalTokens: event.totalUsage.totalTokens,
|
|
348
|
+
cachedInputTokens: event.totalUsage.inputTokenDetails?.cacheReadTokens
|
|
349
|
+
},
|
|
350
|
+
steps: event.stepNumber + 1,
|
|
351
|
+
finishReason: event.finishReason,
|
|
352
|
+
durationMs: Date.now() - started
|
|
353
|
+
}, report);
|
|
354
|
+
}
|
|
272
355
|
});
|
|
273
356
|
const response = createUIMessageStreamResponse({ stream: toUIMessageStream({
|
|
274
357
|
stream: result.stream,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dopus",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
@@ -70,6 +70,8 @@
|
|
|
70
70
|
"ai": "7.0.166",
|
|
71
71
|
"next": "16.4.6",
|
|
72
72
|
"react": "19.3.1",
|
|
73
|
+
"react-markdown": "10.1.0",
|
|
74
|
+
"remark-gfm": "4.0.1",
|
|
73
75
|
"tsdown": "0.23.7",
|
|
74
76
|
"typescript": "6.0.3",
|
|
75
77
|
"vitest": "5.1.0",
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
.dopus-header {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: space-between;
|
|
50
|
+
gap: 8px;
|
|
47
51
|
padding: 12px 16px;
|
|
48
52
|
font-weight: 600;
|
|
49
53
|
border-bottom: 1px solid var(--dopus-border);
|
|
@@ -257,3 +261,91 @@
|
|
|
257
261
|
cursor: pointer;
|
|
258
262
|
box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
|
|
259
263
|
}
|
|
264
|
+
|
|
265
|
+
/* Assistant replies (Markdown). Compact spacing for a chat bubble. */
|
|
266
|
+
.dopus-markdown {
|
|
267
|
+
white-space: normal;
|
|
268
|
+
}
|
|
269
|
+
.dopus-markdown > :first-child {
|
|
270
|
+
margin-top: 0;
|
|
271
|
+
}
|
|
272
|
+
.dopus-markdown > :last-child {
|
|
273
|
+
margin-bottom: 0;
|
|
274
|
+
}
|
|
275
|
+
.dopus-markdown p,
|
|
276
|
+
.dopus-markdown ul,
|
|
277
|
+
.dopus-markdown ol,
|
|
278
|
+
.dopus-markdown pre,
|
|
279
|
+
.dopus-table {
|
|
280
|
+
margin: 0 0 8px;
|
|
281
|
+
}
|
|
282
|
+
.dopus-table {
|
|
283
|
+
overflow-x: auto;
|
|
284
|
+
}
|
|
285
|
+
.dopus-markdown ul,
|
|
286
|
+
.dopus-markdown ol {
|
|
287
|
+
padding-left: 20px;
|
|
288
|
+
}
|
|
289
|
+
.dopus-markdown ul {
|
|
290
|
+
list-style: disc;
|
|
291
|
+
}
|
|
292
|
+
.dopus-markdown ol {
|
|
293
|
+
list-style: decimal;
|
|
294
|
+
}
|
|
295
|
+
.dopus-markdown h1,
|
|
296
|
+
.dopus-markdown h2,
|
|
297
|
+
.dopus-markdown h3,
|
|
298
|
+
.dopus-markdown h4 {
|
|
299
|
+
margin: 12px 0 6px;
|
|
300
|
+
font-size: 15px;
|
|
301
|
+
font-weight: 600;
|
|
302
|
+
}
|
|
303
|
+
.dopus-markdown a {
|
|
304
|
+
color: inherit;
|
|
305
|
+
text-decoration: underline;
|
|
306
|
+
}
|
|
307
|
+
.dopus-markdown strong {
|
|
308
|
+
font-weight: 600;
|
|
309
|
+
}
|
|
310
|
+
.dopus-markdown code {
|
|
311
|
+
padding: 1px 4px;
|
|
312
|
+
border-radius: 4px;
|
|
313
|
+
background: var(--dopus-surface);
|
|
314
|
+
font:
|
|
315
|
+
12px / 1.4 ui-monospace,
|
|
316
|
+
SFMono-Regular,
|
|
317
|
+
Menlo,
|
|
318
|
+
monospace;
|
|
319
|
+
}
|
|
320
|
+
.dopus-markdown pre {
|
|
321
|
+
padding: 8px 10px;
|
|
322
|
+
border-radius: 8px;
|
|
323
|
+
background: var(--dopus-surface);
|
|
324
|
+
overflow-x: auto;
|
|
325
|
+
}
|
|
326
|
+
.dopus-markdown pre code {
|
|
327
|
+
padding: 0;
|
|
328
|
+
background: none;
|
|
329
|
+
}
|
|
330
|
+
.dopus-markdown table {
|
|
331
|
+
border-collapse: collapse;
|
|
332
|
+
font-size: 13px;
|
|
333
|
+
}
|
|
334
|
+
.dopus-markdown th,
|
|
335
|
+
.dopus-markdown td {
|
|
336
|
+
padding: 4px 8px;
|
|
337
|
+
border: 1px solid var(--dopus-border);
|
|
338
|
+
text-align: left;
|
|
339
|
+
vertical-align: top;
|
|
340
|
+
}
|
|
341
|
+
.dopus-markdown th {
|
|
342
|
+
background: var(--dopus-surface);
|
|
343
|
+
font-weight: 600;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* After .dopus-link, which resets font. */
|
|
347
|
+
.dopus-new-chat {
|
|
348
|
+
font-weight: 400;
|
|
349
|
+
font-size: 13px;
|
|
350
|
+
color: var(--dopus-muted);
|
|
351
|
+
}
|
|
@@ -14,7 +14,16 @@ import {
|
|
|
14
14
|
type UIMessage,
|
|
15
15
|
} from 'ai'
|
|
16
16
|
import { useRouter } from 'next/navigation'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type ComponentProps,
|
|
19
|
+
type FormEvent,
|
|
20
|
+
type KeyboardEvent,
|
|
21
|
+
useEffect,
|
|
22
|
+
useRef,
|
|
23
|
+
useState,
|
|
24
|
+
} from 'react'
|
|
25
|
+
import Markdown, { defaultUrlTransform } from 'react-markdown'
|
|
26
|
+
import remarkGfm from 'remark-gfm'
|
|
18
27
|
import './agent-chat.css'
|
|
19
28
|
|
|
20
29
|
const DEFAULT_LABELS = {
|
|
@@ -23,6 +32,8 @@ const DEFAULT_LABELS = {
|
|
|
23
32
|
send: 'Send',
|
|
24
33
|
stop: 'Stop',
|
|
25
34
|
retry: 'Retry',
|
|
35
|
+
newChat: 'New chat',
|
|
36
|
+
tooLong: 'Too long to send. Start a new chat or shorten your message.',
|
|
26
37
|
error: 'Something went wrong.',
|
|
27
38
|
thinking: 'Thinking…',
|
|
28
39
|
running: 'Running',
|
|
@@ -137,6 +148,41 @@ function ToolCall({
|
|
|
137
148
|
}
|
|
138
149
|
}
|
|
139
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Assistant replies are Markdown (tables, lists, bold). Rendered with react-markdown, which never renders raw HTML.
|
|
153
|
+
* Images are shown as their alt text and never loaded, and links keep only http(s), mailto and relative URLs:
|
|
154
|
+
* a reply influenced by prompt injection cannot load a URL that leaks data, or run script.
|
|
155
|
+
*/
|
|
156
|
+
const MARKDOWN_COMPONENTS: ComponentProps<typeof Markdown>['components'] = {
|
|
157
|
+
a: ({ href, children }) => (
|
|
158
|
+
<a href={href} target="_blank" rel="noopener noreferrer nofollow">
|
|
159
|
+
{children}
|
|
160
|
+
</a>
|
|
161
|
+
),
|
|
162
|
+
img: ({ alt }) => (alt ? <span>[{alt}]</span> : null),
|
|
163
|
+
table: ({ children }) => (
|
|
164
|
+
<div className="dopus-table">
|
|
165
|
+
<table>{children}</table>
|
|
166
|
+
</div>
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function AssistantText({ text }: { text: string }) {
|
|
171
|
+
return (
|
|
172
|
+
<div className="dopus-text dopus-markdown">
|
|
173
|
+
<Markdown
|
|
174
|
+
remarkPlugins={[remarkGfm]}
|
|
175
|
+
components={MARKDOWN_COMPONENTS}
|
|
176
|
+
urlTransform={(url) =>
|
|
177
|
+
/^(https?:|mailto:|\/|#)/i.test(url) ? defaultUrlTransform(url) : ''
|
|
178
|
+
}
|
|
179
|
+
>
|
|
180
|
+
{text}
|
|
181
|
+
</Markdown>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
140
186
|
function Message({
|
|
141
187
|
message,
|
|
142
188
|
labels,
|
|
@@ -151,11 +197,14 @@ function Message({
|
|
|
151
197
|
{message.parts.map((part, index) => {
|
|
152
198
|
const key = `${message.id}-${index}`
|
|
153
199
|
if (part.type === 'text') {
|
|
154
|
-
|
|
200
|
+
if (!part.text) return null
|
|
201
|
+
return message.role === 'assistant' ? (
|
|
202
|
+
<AssistantText key={key} text={part.text} />
|
|
203
|
+
) : (
|
|
155
204
|
<div key={key} className="dopus-text">
|
|
156
205
|
{part.text}
|
|
157
206
|
</div>
|
|
158
|
-
)
|
|
207
|
+
)
|
|
159
208
|
}
|
|
160
209
|
if (isToolUIPart(part)) {
|
|
161
210
|
return <ToolCall key={key} part={part} labels={labels} onRespond={onRespond} />
|
|
@@ -182,18 +231,27 @@ export function AgentChat({
|
|
|
182
231
|
body: () => ({ context: { page: window.location.pathname, locale: navigator.language } }),
|
|
183
232
|
}),
|
|
184
233
|
)
|
|
185
|
-
const {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
234
|
+
const {
|
|
235
|
+
messages,
|
|
236
|
+
setMessages,
|
|
237
|
+
sendMessage,
|
|
238
|
+
status,
|
|
239
|
+
stop,
|
|
240
|
+
error,
|
|
241
|
+
clearError,
|
|
242
|
+
regenerate,
|
|
243
|
+
addToolApprovalResponse,
|
|
244
|
+
} = useChat({
|
|
245
|
+
transport,
|
|
246
|
+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
247
|
+
// A tool ran, so data the page shows may have changed (server actions call revalidatePath):
|
|
248
|
+
// re-render the current route's server components.
|
|
249
|
+
onFinish: ({ message }) => {
|
|
250
|
+
if (message.parts.some((part) => isToolUIPart(part) && part.state === 'output-available')) {
|
|
251
|
+
router.refresh()
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
})
|
|
197
255
|
const [input, setInput] = useState('')
|
|
198
256
|
const listRef = useRef<HTMLDivElement>(null)
|
|
199
257
|
const busy = status === 'submitted' || status === 'streaming'
|
|
@@ -225,12 +283,29 @@ export function AgentChat({
|
|
|
225
283
|
|
|
226
284
|
const respond = (id: string, approved: boolean) => addToolApprovalResponse({ id, approved })
|
|
227
285
|
|
|
286
|
+
function newChat() {
|
|
287
|
+
if (busy) stop()
|
|
288
|
+
clearError()
|
|
289
|
+
setMessages([])
|
|
290
|
+
setInput('')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The route answers 413 when the history is over its size limit; retrying cannot help then.
|
|
294
|
+
const tooLong = (error as { statusCode?: number } | undefined)?.statusCode === 413
|
|
295
|
+
|
|
228
296
|
return (
|
|
229
297
|
<section
|
|
230
298
|
className={['dopus-chat', className].filter(Boolean).join(' ')}
|
|
231
299
|
aria-label={labels.title}
|
|
232
300
|
>
|
|
233
|
-
<header className="dopus-header">
|
|
301
|
+
<header className="dopus-header">
|
|
302
|
+
<span>{labels.title}</span>
|
|
303
|
+
{messages.length > 0 && (
|
|
304
|
+
<button type="button" className="dopus-link dopus-new-chat" onClick={newChat}>
|
|
305
|
+
{labels.newChat}
|
|
306
|
+
</button>
|
|
307
|
+
)}
|
|
308
|
+
</header>
|
|
234
309
|
<div ref={listRef} className="dopus-messages" aria-live="polite">
|
|
235
310
|
{messages.length === 0 && suggestions.length > 0 && (
|
|
236
311
|
<div className="dopus-suggestions">
|
|
@@ -252,9 +327,13 @@ export function AgentChat({
|
|
|
252
327
|
{status === 'submitted' && <div className="dopus-thinking">{labels.thinking}</div>}
|
|
253
328
|
{error && (
|
|
254
329
|
<div className="dopus-error" role="alert">
|
|
255
|
-
{labels.error}{' '}
|
|
256
|
-
<button
|
|
257
|
-
|
|
330
|
+
{tooLong ? labels.tooLong : labels.error}{' '}
|
|
331
|
+
<button
|
|
332
|
+
type="button"
|
|
333
|
+
className="dopus-link"
|
|
334
|
+
onClick={() => (tooLong ? newChat() : regenerate())}
|
|
335
|
+
>
|
|
336
|
+
{tooLong ? labels.newChat : labels.retry}
|
|
258
337
|
</button>
|
|
259
338
|
</div>
|
|
260
339
|
)}
|