@ghuri/agents 0.0.0-stage → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ghuri contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,459 @@
1
- # Temporary Holding Version
1
+ # @ghuri/agents
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ AI agents for [Ghuri](https://github.com/ghurijs/ghuri) whose tools are **the models, serializers, viewsets
4
+ and permission classes you already wrote for your REST API** — plus a built-in **MCP server** so Claude
5
+ Desktop, Claude Code and other MCP clients can use the same tools. Built on the
6
+ [Vercel AI SDK](https://ai-sdk.dev) (v7): Workers AI, Anthropic and OpenAI out of the box.
7
+
8
+ > Status: alpha (`0.1.0-alpha.0`). APIs may change.
9
+
10
+ ```bash
11
+ npm install @ghuri/agents@next ai zod
12
+ # plus the provider(s) you use:
13
+ npm install workers-ai-provider # 'workers-ai:…' models (Cloudflare)
14
+ npm install @ai-sdk/anthropic # 'anthropic:…'
15
+ npm install @ai-sdk/openai # 'openai:…'
16
+ ```
17
+
18
+ ## Contents
19
+
20
+ 1. [Concepts](#1-concepts)
21
+ 2. [Quick start (Workers AI on Cloudflare)](#2-quick-start-workers-ai-on-cloudflare)
22
+ 3. [Models and providers](#3-models-and-providers)
23
+ 4. [Tools from your API: ModelTools, ViewSetTools, RouterTools](#4-tools-from-your-api)
24
+ 5. [Custom tools](#5-custom-tools)
25
+ 6. [Running agents from code](#6-running-agents-from-code)
26
+ 7. [Conversation memory](#7-conversation-memory)
27
+ 8. [HTTP endpoints and streaming to a frontend](#8-http-endpoints-and-streaming-to-a-frontend)
28
+ 9. [MCP server (Claude Desktop / Claude Code)](#9-mcp-server)
29
+ 10. [Background runs (Queues)](#10-background-runs)
30
+ 11. [Security model](#11-security-model)
31
+ 12. [Settings](#12-settings)
32
+ 13. [Testing your agents](#13-testing-your-agents)
33
+
34
+ ## 1. Concepts
35
+
36
+ | Piece | What it is |
37
+ |---|---|
38
+ | `Agent` | A class with a `model`, `instructions`, `tools` and `permissionClasses`. `run()` / `stream()` drive the AI SDK tool loop. |
39
+ | Tools | `ModelTools(Model)`, `ViewSetTools(ViewSet)`, `RouterTools(router)` generate tools (with JSON Schemas from your serializers) that execute through the **same REST pipeline** as HTTP; `@tool` / `tool()` for your own. |
40
+ | User | Every run has a user (`run(input, { user })`). Every tool executes **as that user**: your permission classes, querysets and `filter` scoping decide what the model may see or change. |
41
+ | Memory | `AgentsConfig` stores `Conversation`s and `Message`s with the ORM (D1, Postgres, SQLite). |
42
+ | `AgentRouter` | Mounts agents as HTTP endpoints (JSON or SSE in the AI SDK UI message stream format) and as an MCP server. |
43
+
44
+ ## 2. Quick start (Workers AI on Cloudflare)
45
+
46
+ `wrangler.jsonc` — add the Workers AI binding:
47
+
48
+ ```jsonc
49
+ {
50
+ "compatibility_flags": ["nodejs_compat"],
51
+ "ai": { "binding": "AI" }
52
+ }
53
+ ```
54
+
55
+ `src/settings.ts`:
56
+
57
+ ```ts
58
+ import { AuthConfig, AuthenticationMiddleware, ContentTypesConfig, SessionMiddleware, SessionsConfig, AuthTokenConfig } from '@ghuri/auth'
59
+ import { SessionAuthentication, TokenAuthentication } from '@ghuri/auth/rest'
60
+ import { AgentsConfig } from '@ghuri/agents'
61
+
62
+ export default defineSettings((env) => ({
63
+ // …
64
+ INSTALLED_APPS: [ContentTypesConfig, AuthConfig, SessionsConfig, AuthTokenConfig, ShopConfig, AgentsConfig],
65
+ AGENTS: {
66
+ DEFAULT_MODEL: 'workers-ai:@cf/meta/llama-3.3-70b-instruct-fp8-fast',
67
+ AUTHENTICATION_CLASSES: [TokenAuthentication, SessionAuthentication],
68
+ },
69
+ }))
70
+ ```
71
+
72
+ `src/shop/agents.ts`:
73
+
74
+ ```ts
75
+ import { Agent, ModelTools, ToolError, ViewSetTools, tool } from '@ghuri/agents'
76
+ import { IsAuthenticated } from '@ghuri/rest'
77
+ import { z } from 'zod'
78
+ import { Order } from './models'
79
+ import { OrderSerializer } from './serializers'
80
+ import { TicketViewSet } from './views'
81
+
82
+ export class SupportAgent extends Agent {
83
+ static agentName = 'support'
84
+ instructions = 'You help customers with their orders. Be brief.'
85
+ tools = [
86
+ new ModelTools(Order, {
87
+ actions: ['list', 'retrieve'],
88
+ serializer: OrderSerializer,
89
+ filter: (qs, user) => qs.filter({ customer: user.pk }), // users only ever see their own orders
90
+ searchFields: ['number'],
91
+ orderingFields: ['createdAt'],
92
+ }),
93
+ new ViewSetTools(TicketViewSet, { actions: ['create'] }), // tickets_create, with TicketViewSet's rules
94
+ ]
95
+ permissionClasses = [IsAuthenticated]
96
+
97
+ @tool({ description: 'Request a refund for an order', input: z.object({ orderId: z.number() }) })
98
+ async refund({ orderId }: { orderId: number }, { user }) {
99
+ const order = await Order.objects.filter({ id: orderId, customer: user.pk }).first()
100
+ if (!order) throw new ToolError(`Order ${orderId} not found.`)
101
+ // …
102
+ return { ok: true }
103
+ }
104
+ }
105
+ ```
106
+
107
+ `src/urls.ts`:
108
+
109
+ ```ts
110
+ import { AgentRouter } from '@ghuri/agents'
111
+ import { include, path } from '@ghuri/core'
112
+ import { SupportAgent } from './shop/agents'
113
+
114
+ export const agents = new AgentRouter()
115
+ agents.register('support', SupportAgent)
116
+
117
+ export const urlpatterns = [
118
+ path('agents/', include(agents.urls)), // POST /agents/support/chat, GET /agents/support/conversations/
119
+ path('mcp/', agents.mcp({ name: 'shop' })), // Streamable HTTP MCP endpoint
120
+ ]
121
+ ```
122
+
123
+ The agents app ships its migration (`Conversation`, `Message`; the user foreign key follows
124
+ `AUTH_USER_MODEL`): create the tables with `ghuri migrate`.
125
+
126
+ ```bash
127
+ curl -X POST https://shop.example.com/agents/support/chat \
128
+ -H 'Authorization: Token <key>' -H 'Content-Type: application/json' \
129
+ -d '{"message": "Where is my last order?"}'
130
+ # {"conversationId":"7c0e…","text":"Your order A-1042 shipped yesterday.","toolCalls":[…],"usage":{…}}
131
+ ```
132
+
133
+ > `@tool` works as a standard TC39 decorator (TypeScript 5, esbuild, wrangler) and as a legacy one (Vitest's
134
+ > oxc with `oxc: { decorator: { legacy: true } }`, as `ghuri startproject` configures; jiti / the `ghuri` CLI).
135
+ > Prefer no decorators? See [Custom tools](#5-custom-tools).
136
+
137
+ ## 3. Models and providers
138
+
139
+ `model` is a `'provider:model-id'` string or any AI SDK `LanguageModel` instance. Unset → `AGENTS.DEFAULT_MODEL`.
140
+
141
+ | Prefix | Package (loaded lazily) | Credentials |
142
+ |---|---|---|
143
+ | `workers-ai:` | `workers-ai-provider` | the `AI` binding (`AGENTS.AI_BINDING`) |
144
+ | `anthropic:` | `@ai-sdk/anthropic` | `AGENTS.PROVIDERS.anthropic.apiKey`, else env `ANTHROPIC_API_KEY` |
145
+ | `openai:` | `@ai-sdk/openai` | `AGENTS.PROVIDERS.openai.apiKey`, else env `OPENAI_API_KEY` |
146
+
147
+ ```ts
148
+ class Analyst extends Agent {
149
+ model = 'anthropic:claude-sonnet-4-5'
150
+ temperature = 0.2
151
+ maxSteps = 8 // tool round trips (default AGENTS.MAX_STEPS = 10)
152
+ providerOptions = { anthropic: { thinking: { type: 'enabled', budgetTokens: 4000 } } }
153
+ }
154
+ // or an instance: model = createOpenAI({ apiKey, baseURL })('gpt-5-mini')
155
+ ```
156
+
157
+ Extra options in `AGENTS.PROVIDERS[name]` are passed to the provider factory (`baseURL`, `headers`, Workers AI
158
+ `gateway`…). Provider packages are imported only when a model of that provider is first resolved, so unused
159
+ providers need not be installed (bundlers skip missing ones). Add providers with `registerProvider`:
160
+
161
+ ```ts
162
+ import { registerProvider } from '@ghuri/agents'
163
+ registerProvider('google', async (id, { config, env }) => {
164
+ const { createGoogleGenerativeAI } = await import('@ai-sdk/google')
165
+ return createGoogleGenerativeAI({ apiKey: config.apiKey ?? env.GOOGLE_API_KEY })(id)
166
+ })
167
+ ```
168
+
169
+ ## 4. Tools from your API
170
+
171
+ All generated tools run through `router.registry[i].invoke()` — the full DRF pipeline without HTTP:
172
+ authentication is forced to the run's user, then **permission classes, throttles, `getQueryset()`,
173
+ filters, pagination, serializer validation and representation** apply exactly as for an HTTP request.
174
+ 4xx answers become tool errors the model can read (`"You do not have permission to perform this action. (HTTP 403)"`).
175
+
176
+ ### ModelTools
177
+
178
+ ```ts
179
+ new ModelTools(Order, {
180
+ actions: ['list', 'retrieve', 'create', 'update', 'delete'], // default ['list', 'retrieve']
181
+ serializer: OrderSerializer, // default: ModelSerializer with fields '__all__'
182
+ filter: (qs, user) => qs.filter({ customer: user.pk }), // applies to every action
183
+ searchFields: ['number'], // → `search` argument
184
+ orderingFields: ['createdAt', 'total'], // → `ordering` argument
185
+ filterFields: ['status'], // → exact-match arguments
186
+ pageSize: 20, // → `page`, `pageSize` arguments
187
+ permissionClasses: [IsAuthenticated], // default REST_FRAMEWORK.DEFAULT_PERMISSION_CLASSES
188
+ saveKwargs: (user) => ({ customer: user.pk }), // values forced on create / update
189
+ })
190
+ ```
191
+
192
+ Generates `orders_list`, `orders_retrieve`, `orders_create`, `orders_update` (partial update: only the given
193
+ fields change) and `orders_delete`. Input schemas come from the serializer (`jsonSchema({ mode: 'input' })`), so
194
+ read-only fields are not offered and choices become `enum`s. Tools carry MCP annotations
195
+ (`readOnlyHint`, `destructiveHint`, `idempotentHint`).
196
+
197
+ ### ViewSetTools
198
+
199
+ ```ts
200
+ new ViewSetTools(PostViewSet, { actions: ['list', 'retrieve', 'create', 'publish'] }) // posts_list, …, posts_publish
201
+ new ViewSetTools(router.registry[0]) // every routed action
202
+ ```
203
+
204
+ Any routed action works, including `@action`s (detail actions take an `id`; bodies use the action's serializer
205
+ when it sets `serializerClass`, otherwise a free-form `data` object). `'delete'` is accepted as an alias of `destroy`.
206
+ Options: `prefix` (tool name prefix), `basename`, `descriptions: { list: '…' }`.
207
+
208
+ ### RouterTools
209
+
210
+ ```ts
211
+ new RouterTools(router) // the whole API
212
+ new RouterTools(router, { include: ['posts', 'comments'], readOnly: true })
213
+ new RouterTools(router, { exclude: ['users', 'posts_destroy'], actions: { posts: ['list', 'create'] } })
214
+ ```
215
+
216
+ ## 5. Custom tools
217
+
218
+ Three equivalent forms; tools receive `(input, ctx)` where `ctx.user` is the requesting user:
219
+
220
+ ```ts
221
+ class OpsAgent extends Agent {
222
+ // 1. decorator
223
+ @tool({ description: 'Restart a job', input: z.object({ jobId: z.string() }) })
224
+ async restartJob({ jobId }, { user }) { … }
225
+
226
+ // 2. no decorators: options here, implementation in the method of the same name
227
+ static tools = { cancelJob: tool({ description: 'Cancel a job', input: z.object({ jobId: z.string() }) }) }
228
+ async cancelJob({ jobId }, ctx) { … }
229
+
230
+ // 3. standalone, in the tools list
231
+ tools = [tool({ name: 'now', description: 'Current server time', execute: () => new Date().toISOString() })]
232
+ }
233
+ ```
234
+
235
+ `input` is a Zod schema (validated; descriptions and defaults reach the model) or a plain JSON Schema object.
236
+ Throw `ToolError` for failures the model should see; other exceptions are reported to the model as
237
+ "The tool failed with an internal error." (the real message only when `DEBUG`) and logged. Implement the
238
+ `Toolkit` interface (`getTools(): AgentTool[]`) to package your own tool collections.
239
+
240
+ ## 6. Running agents from code
241
+
242
+ ```ts
243
+ const result = await new SupportAgent().run('Where is order A-1042?', { user })
244
+ result.text // final answer
245
+ result.toolCalls // [{ toolName, input, output } | { toolName, input, error }]
246
+ result.messages // AI SDK messages added by this run
247
+ result.usage // tokens
248
+ result.conversationId // pass it back to continue
249
+
250
+ await SupportAgent.run('…', { user, conversationId }) // static shortcut
251
+ ```
252
+
253
+ Options: `user`, `conversationId`, `signal`, `memory` (force on/off), `checkPermissions` (check the agent's
254
+ `permissionClasses` — the HTTP and MCP layers always do), `context` (free-form values for hooks).
255
+
256
+ Instructions may depend on the run:
257
+
258
+ ```ts
259
+ instructions = (ctx: AgentRunContext) => `You help ${ctx.user.username}. Today is ${new Date().toDateString()}.`
260
+ ```
261
+
262
+ Hooks:
263
+
264
+ ```ts
265
+ class Audited extends Agent {
266
+ async beforeRun(ctx) { /* edit ctx.messages, throw to abort */ }
267
+ async onToolCall(call, ctx) { // before every tool call
268
+ if (call.toolName.endsWith('_delete') && !ctx.user.isStaff) throw new ToolError('Ask a staff member.')
269
+ }
270
+ async afterRun(result, ctx) { await AuditLog.objects.create({ … }) }
271
+ }
272
+ ```
273
+
274
+ ## 7. Conversation memory
275
+
276
+ With `AgentsConfig` installed, every run is stored as a `Conversation` (owner, agent name, title, metadata,
277
+ timestamps) with its `Message`s (role + AI SDK content parts — text, tool calls with inputs, tool results and
278
+ errors). Passing `conversationId` loads the history first. Conversations belong to their user: another user's
279
+ id fails with `ConversationNotFound` (404 over HTTP). Set `memory = false` on an agent (or `run(…, { memory: false })`)
280
+ for stateless runs. `loadMessages()`, `saveMessages()`, `createConversation()` are exported for custom flows.
281
+
282
+ The conversation's `agent` column is the agent name: the `AgentRouter.register()` name over HTTP/MCP, otherwise
283
+ `static agentName` (falling back to the class name — set `agentName` explicitly, minifiers rename classes).
284
+
285
+ ## 8. HTTP endpoints and streaming to a frontend
286
+
287
+ `include(agents.urls)` serves, per registered agent:
288
+
289
+ | Route | |
290
+ |---|---|
291
+ | `POST <name>/chat` | body `{ message, conversationId? }` (or an AI SDK `useChat` body with `messages`). JSON `{ conversationId, text, toolCalls, usage, finishReason }`, or **SSE** in the AI SDK UI message stream format with `Accept: text/event-stream` or `?stream=1`. |
292
+ | `GET <name>/conversations/` | the user's conversations `{ count, results }` (`?limit=&offset=`). |
293
+ | `GET <name>/conversations/<id>/` | one conversation with its messages (owner only). |
294
+ | `DELETE <name>/conversations/<id>/` | delete it (owner only). |
295
+
296
+ When the model can't be used (provider package missing, no API key, no Workers AI binding) the chat answers
297
+ **503** `{ "detail": "The agent's language model is not available." }` — with the reason when `DEBUG` is on.
298
+
299
+ Authentication uses `AGENTS.AUTHENTICATION_CLASSES` (default: `REST_FRAMEWORK.DEFAULT_AUTHENTICATION_CLASSES`);
300
+ access uses the agent's `permissionClasses` (default `AGENTS.PERMISSION_CLASSES`, i.e. `[IsAuthenticated]`). With
301
+ session authentication, unsafe requests need the CSRF token (`X-CSRFToken`) like any API view.
302
+
303
+ The SSE stream's `start` chunk carries `messageMetadata: { conversationId }` (also in the `x-conversation-id`
304
+ header) and closes only after the conversation was saved. With React and `@ai-sdk/react`:
305
+
306
+ ```tsx
307
+ import { useChat } from '@ai-sdk/react'
308
+ import { DefaultChatTransport } from 'ai'
309
+
310
+ const conversationId = useRef<string>()
311
+ const { messages, sendMessage, status } = useChat({
312
+ transport: new DefaultChatTransport({
313
+ api: '/agents/support/chat?stream=1',
314
+ headers: { 'X-CSRFToken': getCookie('csrftoken') },
315
+ prepareSendMessagesRequest: ({ messages }) => ({
316
+ body: { message: textOf(messages.at(-1)), conversationId: conversationId.current },
317
+ }),
318
+ }),
319
+ onFinish: ({ message }) => { conversationId.current ??= message.metadata?.conversationId },
320
+ })
321
+ ```
322
+
323
+ Programmatically: `const s = await agent.stream(input, { user })` → `s.toUIMessageStreamResponse()`,
324
+ `s.toUIMessageStream()`, `s.textStream`, `await s.done` (final `AgentRunResult`).
325
+
326
+ ## 9. MCP server
327
+
328
+ `agents.mcp(options)` returns URL patterns for a **Streamable HTTP** MCP endpoint (JSON-RPC 2.0 over POST with
329
+ JSON responses; GET answers 405 — no server-initiated stream). Implemented in-house, Workers-safe.
330
+
331
+ ```ts
332
+ path('mcp/', agents.mcp({
333
+ name: 'shop', version: '1.0.0',
334
+ instructions: 'Tools for the Example shop.',
335
+ tools: [new RouterTools(router, { readOnly: true }), new ModelTools(Order, { filter: … })],
336
+ agents: true, // registered agents as ask_<name> tools (or a list of names, or false)
337
+ // authenticationClasses / permissionClasses: default AGENTS.AUTHENTICATION_CLASSES / AGENTS.PERMISSION_CLASSES
338
+ }))
339
+ // without an AgentRouter: new McpServer({ tools, agents: { support: () => new SupportAgent() } }).urls
340
+ ```
341
+
342
+ Supported: `initialize` (protocol versions `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`; the client's
343
+ version is echoed when supported), `notifications/*` (202), `ping`, `tools/list` (JSON Schema `inputSchema`,
344
+ annotations), `tools/call` (text + `structuredContent`; tool failures are results with `isError: true`),
345
+ empty `resources/list`, `resources/templates/list`, `prompts/list`, batches, and JSON-RPC errors
346
+ (`-32700`, `-32600`, `-32601`, `-32602`, `-32603`). Requests without valid credentials get HTTP 401 with
347
+ `WWW-Authenticate`; browser `Origin`s other than your host / `CSRF_TRUSTED_ORIGINS` are refused (DNS rebinding);
348
+ unknown `MCP-Protocol-Version` headers get 400.
349
+
350
+ Create a token for the user the client acts as (`ghuri drf_create_token alice`, or `Token.objects.create({ user })`).
351
+
352
+ **Claude Code** — `claude mcp add --transport http shop https://shop.example.com/mcp/ --header "Authorization: Token <key>"`,
353
+ or in `.mcp.json`:
354
+
355
+ ```json
356
+ {
357
+ "mcpServers": {
358
+ "shop": {
359
+ "type": "http",
360
+ "url": "https://shop.example.com/mcp/",
361
+ "headers": { "Authorization": "Token ${SHOP_TOKEN}" }
362
+ }
363
+ }
364
+ }
365
+ ```
366
+
367
+ **Claude Desktop** (`claude_desktop_config.json`, through the `mcp-remote` bridge):
368
+
369
+ ```json
370
+ {
371
+ "mcpServers": {
372
+ "shop": {
373
+ "command": "npx",
374
+ "args": ["-y", "mcp-remote", "https://shop.example.com/mcp/", "--header", "Authorization:${SHOP_AUTH}"],
375
+ "env": { "SHOP_AUTH": "Token 0123456789abcdef0123456789abcdef01234567" }
376
+ }
377
+ }
378
+ }
379
+ ```
380
+
381
+ Clients that only send `Authorization: Bearer <token>`: add a keyword variant of `TokenAuthentication`
382
+ (or use `JWTAuthentication`):
383
+
384
+ ```ts
385
+ class BearerTokenAuthentication extends TokenAuthentication { keyword = 'Bearer' }
386
+ AGENTS: { AUTHENTICATION_CLASSES: [TokenAuthentication, BearerTokenAuthentication] }
387
+ ```
388
+
389
+ Mount the endpoint with a trailing slash and use that exact URL (`APPEND_SLASH` only redirects GETs).
390
+
391
+ ## 10. Background runs
392
+
393
+ ```ts
394
+ // wrangler.jsonc: "queues": { "producers": [{ "binding": "AGENTS_QUEUE", "queue": "agents" }],
395
+ // "consumers": [{ "queue": "agents" }] }
396
+ agents.consumeQueue('agents') // once, e.g. in an AppConfig.ready()
397
+ const { conversationId } = await agents.create('support').enqueue('Summarize my week', { user })
398
+ ```
399
+
400
+ The consumer reloads the user (`AUTH_USER_MODEL`), runs the agent and stores the result in the conversation
401
+ (created up front so you can poll `GET …/conversations/<id>/`). Failed runs are retried by the queue.
402
+
403
+ ## 11. Security model
404
+
405
+ - **Tools act as the user, never as the server.** Generated tools force-authenticate the run's user and go
406
+ through your viewset's permission classes, `getQueryset()` / `filter` scoping, object permissions and serializer
407
+ validation. Anonymous runs are unauthenticated requests. The model cannot do anything the user could not do
408
+ with your REST API — but it can do *anything* the user could, so expose only the actions you want
409
+ (`ModelTools` is read-only by default) and keep `filter` scoping on multi-tenant data.
410
+ - **Endpoints require authentication by default** (`AGENTS.PERMISSION_CLASSES = [IsAuthenticated]`); set
411
+ `[AllowAny]` deliberately for public agents (and consider throttling — LLM calls cost money).
412
+ - **Errors**: permission / validation / not-found failures are shown to the model; unexpected exceptions are not
413
+ (generic message unless `DEBUG`).
414
+ - **Conversations** are owner-only; anonymous conversations are reachable only by their unguessable UUID.
415
+ - **Prompt injection**: data returned by tools (e.g. user-written text) can steer the model. Gate destructive or
416
+ irreversible tools with `onToolCall`, keep `maxSteps` low, and audit with `afterRun`.
417
+ - **MCP**: token or session authentication through REST authentication classes; `Origin` validation; tools run as
418
+ the token's user.
419
+
420
+ ## 12. Settings
421
+
422
+ ```ts
423
+ AGENTS: {
424
+ DEFAULT_MODEL: 'workers-ai:@cf/meta/llama-3.3-70b-instruct-fp8-fast', // or a LanguageModel
425
+ AI_BINDING: 'AI', // Workers AI binding name
426
+ PROVIDERS: { anthropic: { apiKey: env.ANTHROPIC_API_KEY }, openai: { apiKey: env.OPENAI_API_KEY } },
427
+ MAX_STEPS: 10, // default tool-loop steps
428
+ AUTHENTICATION_CLASSES: [TokenAuthentication, SessionAuthentication], // default: REST defaults
429
+ PERMISSION_CLASSES: [IsAuthenticated], // default for agents / MCP
430
+ QUEUE_BINDING: 'AGENTS_QUEUE', // enqueue() producer binding
431
+ }
432
+ ```
433
+
434
+ ## 13. Testing your agents
435
+
436
+ Use the AI SDK mock model — no network:
437
+
438
+ ```ts
439
+ import { MockLanguageModelV4 } from 'ai/test'
440
+
441
+ const model = new MockLanguageModelV4({
442
+ doGenerate: [
443
+ { content: [{ type: 'tool-call', toolCallId: '1', toolName: 'orders_list', input: '{}' }],
444
+ finishReason: { unified: 'tool-calls', raw: undefined }, usage, warnings: [] },
445
+ { content: [{ type: 'text', text: 'You have 2 orders.' }],
446
+ finishReason: { unified: 'stop', raw: undefined }, usage, warnings: [] },
447
+ ],
448
+ })
449
+ class TestAgent extends SupportAgent { model = model }
450
+ const result = await new TestAgent().run('orders?', { user })
451
+ expect(result.toolCalls[0].output.count).toBe(2)
452
+ ```
453
+
454
+ For HTTP / MCP tests, mount the router in `ROOT_URLCONF` and use `@ghuri/core/testing` / `@ghuri/rest/testing`
455
+ clients (`client.forceAuthenticate(user)` or a real `Authorization: Token …` header).
456
+
457
+ ## License
458
+
459
+ MIT