dopus 0.2.9 → 0.2.12

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
@@ -142,6 +142,7 @@ export default defineAgent({
142
142
  | `input` | **Required.** A zod schema. The model's arguments are validated before `fn` runs. |
143
143
  | `confirm` | `true`, or `(input, ctx) => boolean`, to ask the user before each call. Use it for anything that writes. |
144
144
  | `output` | `(result) => unknown`. Trims the result before the model sees it, for example to drop internal fields. |
145
+ | `public` | `true` to offer the tool to users who are not signed in too. Needs `identify` (see below). |
145
146
 
146
147
  `ctx` is `{ user, toolCallId, abortSignal }`. `user` is only set when you configure `identify` (see below).
147
148
 
@@ -202,6 +203,33 @@ Your part, so an injected instruction cannot cause damage:
202
203
 
203
204
  We tested this with a real model in a small task-board app. A member wrote a task whose title ordered the assistant to delete other tasks, then an admin's assistant listed the tasks. Claude (claude-sonnet-5-5) did not even request the deletion and pointed out the planted text. If a model did request it, the approval card would still stop it. Other models were not tested this way.
204
205
 
206
+ **Signed-in and anonymous users.** Define `identify` and dopus knows who is signed in. When it returns `undefined`, the
207
+ model gets only the tools marked `public: true` (product info, opening hours, "am I signed in?"), and the prompt says
208
+ the rest needs sign-in, so the agent tells the visitor to sign in instead of trying. Member tools are not offered at
209
+ all, so a visitor does not see which features exist. Signed-in users get every tool. An approval for a member tool
210
+ that arrives after the user signed out (in another tab) is not run.
211
+
212
+ ```ts
213
+ defineAgent({
214
+ tools: {
215
+ getOpeningHours: defineTool(getOpeningHours, {
216
+ description: '...',
217
+ input: z.object({}),
218
+ public: true,
219
+ }),
220
+ listMyOrders: defineTool(() => listMyOrders(), { description: '...', input: z.object({}) }),
221
+ },
222
+ identify: async () => {
223
+ const session = await getSession()
224
+ return session ? { id: session.userId } : undefined
225
+ },
226
+ })
227
+ ```
228
+
229
+ This decides what the model sees, nothing more: keep the checks in your server functions. Without `identify` dopus
230
+ cannot tell, so every tool is offered to everyone (and `defineAgent` warns if you used `public`). Anonymous users cost
231
+ model tokens too; rate-limit them in `beforeRequest` (see [Audit log, usage and rate limits](#audit-log-usage-and-rate-limits)).
232
+
205
233
  **Running tools outside the request.** Background jobs and queues lose the request's cookies. For that case, define `identify(request)` to resolve a serialisable user. Tools then receive it as `ctx.user` and can pass it on explicitly. dopus itself always runs tools inside the request; this is for jobs your tools hand off.
206
234
 
207
235
  ## Prompts
@@ -256,27 +284,35 @@ export function Agent({ userId, ...props }: AgentChatProps & { userId: string })
256
284
  }
257
285
  ```
258
286
 
259
- In your database: save on the server, where you know the user, and load from the client.
287
+ In your database: save on the server, where you know the user, and load from the client. `onFinish` gives you who
288
+ (`user`, from `identify`), which conversation (`conversationId`) and what this request added (`newMessages`: the user's
289
+ message and the agent's reply, with tool calls and results inside).
260
290
 
261
291
  ```ts
262
- // agent.config.ts
263
- onFinish: async ({ user, messages }) => {
264
- if (user) await db.chat.upsert({ userId: user.id, messages }) // needs `identify`
292
+ // agent.config.ts (needs `identify`, so `user` is set)
293
+ onFinish: async ({ user, conversationId, newMessages }) => {
294
+ if (!user) return
295
+ for (const message of newMessages) {
296
+ // Upsert by id: after an approval the reply arrives again, continued. Key by user too, since
297
+ // conversationId comes from the browser.
298
+ await db.chatMessage.upsert({ id: message.id, userId: user.id, conversationId, message })
299
+ }
265
300
  },
266
301
  ```
267
302
 
268
303
  ```tsx
269
- // components/agent/agent.tsx: loadChat is your server action returning the saved messages
270
- ;<AgentWidget
271
- history={{
272
- load: () => loadChat(),
273
- save: (m) => {
274
- if (m.length === 0) clearChat()
275
- },
276
- }}
277
- />
304
+ // components/agent/agent.tsx: loadChat is your server action returning the latest conversation's messages
305
+ ;<AgentWidget history={{ load: () => loadChat() }} />
278
306
  ```
279
307
 
308
+ - **`conversationId`** is the id of the conversation's first message: the same after a reload (when `history.load`
309
+ restores it), new after "New chat". A list of past conversations is yours to build from it.
310
+ - **`newMessages`** rather than the whole history: the browser sends the whole conversation with every request, so
311
+ saving only what this request added keeps earlier stored messages out of its reach. `messages` (the whole
312
+ conversation) is there too if one snapshot per conversation is enough.
313
+ - **Stopped or failed turns** do not reach `onFinish`. To keep the user's message anyway, save it in `beforeRequest`
314
+ (it gets `user` and `messages`).
315
+
280
316
  Things to know:
281
317
 
282
318
  - **Tool results are stored too.** Whatever your tools returned (orders, names) is in the saved conversation. Keep it
@@ -340,7 +376,7 @@ defineAgent({
340
376
  },
341
377
 
342
378
  // Once per request, when the reply is done: tokens summed over all steps.
343
- // also gets `messages`: the whole conversation after this reply (see "Chat history")
379
+ // also gets conversationId, newMessages and messages, to save the chat (see "Chat history")
344
380
  onFinish: async ({ user, model, usage, steps, finishReason, durationMs }) => {
345
381
  await db.usage.insert({ userId: user?.id, model: model.modelId, ...usage })
346
382
  },
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-NYbd20Zw.js";
2
+ import { n as PROVIDERS, t as VERSION } from "./version-gbic1Mgf.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";
package/dist/index.d.ts CHANGED
@@ -66,6 +66,12 @@ interface ToolOptions<SCHEMA extends z.ZodType, RESULT> {
66
66
  * the job finishes the agent is told, reads the result with another of your tools, and continues.
67
67
  */
68
68
  job?: (result: Awaited<RESULT>) => string;
69
+ /**
70
+ * Also offered to users who are not signed in. Needs `identify` in the agent config: when it returns
71
+ * `undefined`, only public tools are given to the model, so features that need an account stay out of sight.
72
+ * This only decides which tools the model sees; keep the permission checks in your server functions.
73
+ */
74
+ public?: boolean;
69
75
  }
70
76
  type ToolFunction<INPUT, RESULT> = (input: INPUT, ctx: ToolContext) => RESULT;
71
77
  interface DopusTool<SCHEMA extends z.ZodType = z.ZodType, RESULT = unknown> {
@@ -112,8 +118,8 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
112
118
  /** Called once per tool call: ran (`success` / `error`) or declined by the user (`denied`). For audit logs. */
113
119
  onToolCall?: (event: ToolCallEvent) => void | Promise<void>;
114
120
  /**
115
- * Called once per request when the reply is finished: tokens, steps, model (usage tracking), and the whole
116
- * conversation including the reply (to save chat history in your database).
121
+ * Called once per request when the reply is finished: tokens, steps, model (usage tracking), and the conversation
122
+ * (to save chat history in your database: `user`, `conversationId`, `newMessages`).
117
123
  */
118
124
  onFinish?: (event: FinishEvent) => void | Promise<void>;
119
125
  /** Maximum model steps (tool round trips) per request. Default 10. */
@@ -173,8 +179,19 @@ interface FinishEvent {
173
179
  finishReason: string;
174
180
  durationMs: number;
175
181
  /**
176
- * The conversation after this reply, as the chat UI holds it (`UIMessage[]`). Save it to restore the chat later
177
- * (the chat UI's `history.load`). It came from the browser, so treat it as the user's own data, not as trusted.
182
+ * Which conversation this is: the id of its first message. The same across reloads (when the chat UI restores
183
+ * its `history`), new after "New chat". Sent by the browser, so store it under `user`, never on its own.
184
+ */
185
+ conversationId: string;
186
+ /**
187
+ * What this request added or changed: the user's message (when the request started with one) and the
188
+ * assistant's reply. After an approval the reply is the same message, continued, so upsert by `id`.
189
+ * Saving only these keeps earlier stored messages out of the browser's reach.
190
+ */
191
+ newMessages: UIMessage[];
192
+ /**
193
+ * The whole conversation after this reply, as the chat UI holds it. Handy to store one snapshot per
194
+ * conversation; it comes from the browser, so it can differ from what you stored earlier.
178
195
  */
179
196
  messages: UIMessage[];
180
197
  }
@@ -224,5 +241,5 @@ interface ProviderInfo {
224
241
  }
225
242
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
226
243
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
227
- export declare const VERSION = "0.2.9";
244
+ export declare const VERSION = "0.2.12";
228
245
  export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as PROVIDERS, t as VERSION } from "./version-NYbd20Zw.js";
1
+ import { n as PROVIDERS, t as VERSION } from "./version-gbic1Mgf.js";
2
2
  import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
3
3
  // src/job.ts
4
4
  /**
@@ -82,7 +82,8 @@ const CACHE_BREAKPOINT = { anthropic: { cacheControl: { type: "ephemeral" } } };
82
82
  const RUNTIME_LABELS = {
83
83
  locale: "Response language",
84
84
  role: "User role",
85
- page: "Current page (reported by the browser)"
85
+ page: "Current page (reported by the browser)",
86
+ access: "Access"
86
87
  };
87
88
  /** Picks `ko-KR`, then `ko`, from the locale notes. */
88
89
  function pickLocaleNotes(notes, locale) {
@@ -194,8 +195,27 @@ function defineAgent(config) {
194
195
  if (!isDopusTool(value)) throw new TypeError(`defineAgent: tools.${name} must be created with defineTool()`);
195
196
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) throw new TypeError(`defineAgent: tool name "${name}" must match [a-zA-Z0-9_-]{1,64}`);
196
197
  }
198
+ if (!config.identify && Object.values(config.tools).some((t) => t.options.public)) console.warn("[dopus] `public` tools need `identify` in defineAgent: without it dopus cannot tell who is signed in, so every tool is offered to everyone.");
197
199
  return config;
198
200
  }
201
+ /**
202
+ * The tools this request may use: all of them for a signed-in user, only `public` ones for an anonymous user.
203
+ * Without `identify` dopus cannot tell who is signed in, so every tool is offered (the server functions decide).
204
+ */
205
+ function visibleTools(config, user) {
206
+ if (!config.identify || user) return {
207
+ tools: config.tools,
208
+ hidden: []
209
+ };
210
+ const tools = {};
211
+ const hidden = [];
212
+ for (const [name, t] of Object.entries(config.tools)) if (t.options.public) tools[name] = t;
213
+ else hidden.push(name);
214
+ return {
215
+ tools,
216
+ hidden
217
+ };
218
+ }
199
219
  /** Runs a user hook; a failing hook is reported but never breaks the chat. */
200
220
  async function runHook(hook, event, report) {
201
221
  if (!hook) return;
@@ -277,6 +297,10 @@ function readClientContext(body, request) {
277
297
  page: clean(raw.page, 200)
278
298
  };
279
299
  }
300
+ /** Reason recorded for a confirmation of a tool that needs sign-in, answered by a user who is not signed in. */
301
+ const SIGN_IN_REASON = "Not run: this needs the user to be signed in, and they are not (anymore). Tell them to sign in first.";
302
+ /** Runtime prompt line for anonymous users when some tools are hidden from them. */
303
+ const SIGNED_OUT_NOTE = "Not signed in. Only features that work without an account are available; if the user asks for something else, tell them to sign in first.";
280
304
  /** Reason recorded for a confirmation the conversation moved past (the model reads it). */
281
305
  const NOT_RUN_REASON = "Not run: the user sent a new message instead of approving it. If they still want it, call the tool again so they can approve it.";
282
306
  /**
@@ -286,11 +310,11 @@ const NOT_RUN_REASON = "Not run: the user sent a new message instead of approvin
286
310
  * approved-but-unrun call makes every later request fail with a missing tool result. A stale approval is never
287
311
  * executed: the user has moved on, so it may no longer be what they want.
288
312
  */
289
- function settleStaleApprovals(messages) {
313
+ function settleStaleApprovals(messages, hidden = []) {
290
314
  const last = messages.length - 1;
291
315
  return messages.map((message, index) => {
292
- if (message.role !== "assistant" || index === last) return message;
293
- const pending = (part) => isToolUIPart(part) && (part.state === "approval-requested" || part.state === "approval-responded");
316
+ if (message.role !== "assistant") return message;
317
+ const pending = (part) => isToolUIPart(part) && (part.state === "approval-requested" || part.state === "approval-responded") && (index !== last || hidden.includes(getToolName(part)));
294
318
  if (!message.parts.some(pending)) return message;
295
319
  return {
296
320
  ...message,
@@ -303,7 +327,7 @@ function settleStaleApprovals(messages) {
303
327
  approval: {
304
328
  id: part.approval.id,
305
329
  approved: false,
306
- reason: declined ? part.approval.reason : NOT_RUN_REASON
330
+ reason: declined ? part.approval.reason : index === last ? SIGN_IN_REASON : NOT_RUN_REASON
307
331
  }
308
332
  };
309
333
  })
@@ -343,14 +367,15 @@ function createAgentHandler(config, options = {}) {
343
367
  return errorResponse(400, "Request body must be JSON");
344
368
  }
345
369
  const user = await config.identify?.(request);
346
- const { tools, toolApproval } = buildTools(config.tools, {
370
+ const visible = visibleTools(config, user);
371
+ const { tools, toolApproval } = buildTools(visible.tools, {
347
372
  user,
348
373
  report,
349
374
  onToolCall: config.onToolCall
350
375
  });
351
376
  const validated = await safeValidateUIMessages({ messages: body?.messages });
352
377
  if (!validated.success) return errorResponse(400, "Invalid messages");
353
- const messages = settleStaleApprovals(validated.data);
378
+ const messages = settleStaleApprovals(validated.data, visible.hidden);
354
379
  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");
355
380
  if (messages.some((message) => message.parts.some((part) => part.type === "data-dopus-job" && !isJobId(part.data?.id)))) return errorResponse(400, "Invalid job notice");
356
381
  const early = await config.beforeRequest?.({
@@ -372,6 +397,7 @@ function createAgentHandler(config, options = {}) {
372
397
  const client = readClientContext(body, request);
373
398
  const runtime = {
374
399
  ...client,
400
+ ...visible.hidden.length > 0 ? { access: SIGNED_OUT_NOTE } : {},
375
401
  ...await config.runtime?.(request, {
376
402
  user,
377
403
  client
@@ -379,11 +405,11 @@ function createAgentHandler(config, options = {}) {
379
405
  };
380
406
  const approvalSecret = config.approvalSecret ?? process.env.DOPUS_APPROVAL_SECRET;
381
407
  const finish = { async emit() {
382
- if (this.done || !this.usage || !this.messages) return;
408
+ if (this.done || !this.usage || !this.conversation) return;
383
409
  this.done = true;
384
410
  await runHook(config.onFinish, {
385
411
  ...this.usage,
386
- messages: this.messages
412
+ ...this.conversation
387
413
  }, report);
388
414
  } };
389
415
  const result = streamText({
@@ -441,9 +467,14 @@ function createAgentHandler(config, options = {}) {
441
467
  stream: result.stream,
442
468
  tools,
443
469
  originalMessages: messages,
444
- onEnd: async ({ messages: final, isAborted }) => {
470
+ onEnd: async ({ messages: final, responseMessage, isAborted }) => {
445
471
  if (isAborted) return;
446
- finish.messages = final;
472
+ const last = messages.at(-1);
473
+ finish.conversation = {
474
+ conversationId: messages[0]?.id ?? responseMessage.id,
475
+ newMessages: last?.role === "user" ? [last, responseMessage] : [responseMessage],
476
+ messages: final
477
+ };
447
478
  await finish.emit();
448
479
  },
449
480
  onError: (error) => {
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.9";
30
+ const VERSION = "0.2.12";
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.9",
3
+ "version": "0.2.12",
4
4
  "description": "A lightweight agent for Next.js: your existing server functions become its tools, run with the signed-in user's permissions.",
5
5
  "license": "MIT",
6
6
  "author": "akodm",