@adonis-agora/agent 0.25.2 → 0.25.3
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/CHANGELOG.md +12 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/package.json +5 -2
- package/skills/agent-governance/SKILL.md +233 -0
- package/skills/agent-personas-agents/SKILL.md +220 -0
- package/skills/agent-setup/SKILL.md +273 -0
- package/skills/agent-testing/SKILL.md +236 -0
- package/skills/agent-tools/SKILL.md +270 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @adonis-agora/agent
|
|
2
2
|
|
|
3
|
+
## 0.25.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#82](https://github.com/DavideCarvalho/adonis-agora-agent/pull/82) [`59be4e9`](https://github.com/DavideCarvalho/adonis-agora-agent/commit/59be4e9943b47d79ce5f006c1d24b9b39e052f84) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add TanStack Intent agent skills. Both packages now ship a `skills/` directory
|
|
8
|
+
(five skills under @adonis-agora/agent: setup, tools, governance, personas &
|
|
9
|
+
multi-agent delegation, offline testing; one under @adonis-agora/agent-dashboard:
|
|
10
|
+
the governance console client) that coding agents can load as structured,
|
|
11
|
+
verified documentation. The directories are included in each package's `files`
|
|
12
|
+
allowlist, and `@tanstack/intent` is added as a devDependency so CI can enforce
|
|
13
|
+
skill validity via `intent validate`.
|
|
14
|
+
|
|
3
15
|
## 0.25.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/agent",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.3",
|
|
4
4
|
"description": "Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -112,7 +112,8 @@
|
|
|
112
112
|
"files": [
|
|
113
113
|
"dist/",
|
|
114
114
|
"README.md",
|
|
115
|
-
"CHANGELOG.md"
|
|
115
|
+
"CHANGELOG.md",
|
|
116
|
+
"skills"
|
|
116
117
|
],
|
|
117
118
|
"sideEffects": false,
|
|
118
119
|
"dependencies": {
|
|
@@ -184,6 +185,7 @@
|
|
|
184
185
|
"@adonisjs/core": "7.4.0",
|
|
185
186
|
"@adonisjs/lucid": "22.4.2",
|
|
186
187
|
"@qdrant/js-client-rest": "1.19.0",
|
|
188
|
+
"@tanstack/intent": "^0.3.2",
|
|
187
189
|
"@types/node": "22.20.1",
|
|
188
190
|
"@types/react": "19.2.18",
|
|
189
191
|
"ai": "7.0.59",
|
|
@@ -208,6 +210,7 @@
|
|
|
208
210
|
"governance",
|
|
209
211
|
"ai-sdk",
|
|
210
212
|
"durable",
|
|
213
|
+
"tanstack-intent",
|
|
211
214
|
"agora"
|
|
212
215
|
],
|
|
213
216
|
"scripts": {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-governance
|
|
3
|
+
description: >-
|
|
4
|
+
Lock down @adonis-agora/agent: the ActorResolver identity seam (AuthActorResolver over
|
|
5
|
+
ctx.auth.user with toActor mapper, HeaderActorResolver x-actor-id/x-actor-role behind a
|
|
6
|
+
gateway, UnconfiguredActorResolver throwing default), DefaultToolAuthorizer role
|
|
7
|
+
intersection with the offered-tools + invoke-time double check, object-level ownership
|
|
8
|
+
via evaluateOwnership (404 for unknown ids), governanceAuthorize mounting semantics for
|
|
9
|
+
/agent/governance/* (404 without it) and the dashboard mount refusals,
|
|
10
|
+
evaluateGovernanceGate fail-closed behavior, and GET /agent/approvals/mine exemption.
|
|
11
|
+
Use for "tool forbidden", "governance routes 404", "dashboard won't mount", "IDOR /
|
|
12
|
+
cross-actor access", "swap roles for ability checks".
|
|
13
|
+
metadata:
|
|
14
|
+
type: core
|
|
15
|
+
library: "@adonis-agora/agent"
|
|
16
|
+
library_version: "0.25.2"
|
|
17
|
+
framework: adonisjs
|
|
18
|
+
sources:
|
|
19
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/governance/authorization.mdx"
|
|
20
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/governance/dashboard.mdx"
|
|
21
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/governance-gate.ts"
|
|
22
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/ownership.ts"
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Governance: identity, authorization, ownership
|
|
26
|
+
|
|
27
|
+
Authorization in `@adonis-agora/agent` is fail-closed at three layers: **who is calling**
|
|
28
|
+
(an `ActorResolver` that never fabricates an identity), **what they may do** (a
|
|
29
|
+
`RolesPolicy` checked twice — before the model sees a tool and again at invoke time),
|
|
30
|
+
and **which records they may address** (object-level ownership on every run/thread route).
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
Wire identity once; everything downstream keys off it:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// config/agent.ts
|
|
38
|
+
import { defineConfig, AuthActorResolver } from '@adonis-agora/agent'
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
model: () => aiSdkModel(openai('gpt-4o-mini')),
|
|
42
|
+
// Reads ctx.auth.user (populated by @adonisjs/auth). Throws when unauthenticated —
|
|
43
|
+
// no identity is ever invented. Map non-standard user shapes with toActor.
|
|
44
|
+
actorResolver: new AuthActorResolver({
|
|
45
|
+
toActor: (user) => ({
|
|
46
|
+
id: String(user.id),
|
|
47
|
+
roles: user.roleNames, // e.g. pulled from a relation
|
|
48
|
+
tenantRef: user.tenantId,
|
|
49
|
+
}),
|
|
50
|
+
}),
|
|
51
|
+
defaultRoles: ['STAFF'], // roles a tool requires when it declares none
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Core patterns
|
|
56
|
+
|
|
57
|
+
### Pattern 1 — swap role checks for ability checks
|
|
58
|
+
|
|
59
|
+
`roles` and `ability` share one seam. The default policy intersects roles; an
|
|
60
|
+
ability-aware policy (the `@adonis-agora/authz` Bouncer adapter, `authzToolAuthorizer`)
|
|
61
|
+
consults each tool's `ability` instead — tenant-scoped and fail-closed. Swap only the
|
|
62
|
+
binding; tools keep both annotations:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// config/agent.ts
|
|
66
|
+
import { defineConfig } from '@adonis-agora/agent'
|
|
67
|
+
import { authzToolAuthorizer } from '@adonis-agora/agent/authz'
|
|
68
|
+
|
|
69
|
+
export default defineConfig({
|
|
70
|
+
// ...
|
|
71
|
+
authorizer: authzToolAuthorizer, // alias: rolesPolicy — one seam, both filter layers follow
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Because the offered-tools filter AND the invoke-time re-check call the same
|
|
76
|
+
`can(actor, tool)`, swapping the policy changes both layers together — they can never
|
|
77
|
+
disagree about what an actor may reach.
|
|
78
|
+
|
|
79
|
+
Source: `packages/adonis/docs/governance/authorization.mdx` ("Ability-aware
|
|
80
|
+
authorization"), `packages/adonis/src/authorizer.ts`.
|
|
81
|
+
|
|
82
|
+
### Pattern 2 — mount the governance read-model with `governanceAuthorize`
|
|
83
|
+
|
|
84
|
+
The cross-actor `/agent/governance/*` read routes exist ONLY when a gate is configured.
|
|
85
|
+
Omit it and every one of those paths answers 404 — by design, so an ungated read-model of
|
|
86
|
+
every actor's spend/threads/approvals can never be served by accident:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// config/agent.ts — typically an ADMIN check; runs AFTER the actor resolves.
|
|
90
|
+
export default defineConfig({
|
|
91
|
+
// ...
|
|
92
|
+
governanceAuthorize: (actor, _ctx) => actor.roles?.includes('ADMIN') ?? false,
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`evaluateGovernanceGate` applies it fail-closed: falsy → 403, thrown → 403 (message shown
|
|
97
|
+
only outside production). The SAME predicate feeds object-level ownership's `privileged`
|
|
98
|
+
flag, and the dashboard console refuses to mount without it.
|
|
99
|
+
|
|
100
|
+
Source: `packages/adonis/docs/governance/authorization.mdx` ("Governance route
|
|
101
|
+
authorization"), `packages/adonis/src/governance-gate.ts`.
|
|
102
|
+
|
|
103
|
+
### Pattern 3 — unit-test your gates router-free
|
|
104
|
+
|
|
105
|
+
Both decision cores are exported pure functions, so you can test them without booting an
|
|
106
|
+
app:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { evaluateOwnership, evaluateGovernanceGate } from '@adonis-agora/agent'
|
|
110
|
+
|
|
111
|
+
evaluateOwnership('u_1', 'u_1', false) // → { ok: true, status: 200 } owner
|
|
112
|
+
evaluateOwnership('u_2', 'u_1', false) // → { ok: false, status: 403 } not owner
|
|
113
|
+
evaluateOwnership('u_2', null, false) // → { ok: false, status: 404 } unknown id
|
|
114
|
+
evaluateOwnership('u_2', 'u_1', true) // → { ok: true, status: 200 } privileged
|
|
115
|
+
|
|
116
|
+
const verdict = await evaluateGovernanceGate(actor, ctx, config.governanceAuthorize, !app.inProduction)
|
|
117
|
+
if (!verdict.ok) return ctx.response.status(verdict.status).json({ error: verdict.error })
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Unknown ids deliberately reply 404, never 403, so a response never confirms to a caller
|
|
121
|
+
that an id they don't own exists.
|
|
122
|
+
|
|
123
|
+
Source: `packages/adonis/docs/governance/authorization.mdx` ("Object-level ownership"),
|
|
124
|
+
`packages/adonis/src/ownership.ts`.
|
|
125
|
+
|
|
126
|
+
## Common mistakes
|
|
127
|
+
|
|
128
|
+
### CRITICAL — assuming `HeaderActorResolver` is production-safe
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// Wrong — anyone can send x-actor-id: u_admin and become admin unless a gateway strips these.
|
|
132
|
+
actorResolver: new HeaderActorResolver()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// Correct — read a VERIFIED principal in production; headers are for dev/gateway use only.
|
|
137
|
+
actorResolver: new AuthActorResolver()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Mechanism: `HeaderActorResolver` trusts client-sent `x-actor-id` / `x-actor-role` /
|
|
141
|
+
`x-tenant-ref`; it is safe only behind a gateway that strips and re-sets them from an
|
|
142
|
+
authenticated principal. It does throw when `x-actor-id` is missing, but any caller who
|
|
143
|
+
sets the header IS that actor.
|
|
144
|
+
Source: `packages/adonis/src/actor-resolver.ts` (`HeaderActorResolver` security note),
|
|
145
|
+
`packages/adonis/docs/governance/authorization.mdx`.
|
|
146
|
+
|
|
147
|
+
### HIGH — expecting `/agent/governance/*` to exist after configuring only `governanceQueries`
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// Wrong — read-model resolves, but no gate → the 11 governance routes are NEVER mounted (all 404).
|
|
151
|
+
export default defineConfig({ store: 'lucid', stores: { lucid: stores.lucid() } })
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// Correct — set the gate to mount them (gated); or explicitly opt into open reads.
|
|
156
|
+
export default defineConfig({
|
|
157
|
+
...,
|
|
158
|
+
governanceAuthorize: (actor) => actor.roles?.includes('ADMIN') ?? false,
|
|
159
|
+
// governanceAuthorize: () => true, // deliberate legacy behaviour: ANY authenticated actor reads
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Mechanism: mounting is conditional on the gate because the data is cross-actor; a boot
|
|
164
|
+
warning names both ways forward when the read-model resolved without a gate. Note
|
|
165
|
+
`GET /agent/approvals/mine` is exempt — always mounted when the read-model resolves, and
|
|
166
|
+
always scoped to the calling actor.
|
|
167
|
+
Source: `packages/adonis/src/define_config.ts` (`AgentConfig.governanceAuthorize`),
|
|
168
|
+
`packages/adonis/docs/streaming-and-http.mdx` ("Five optional surfaces").
|
|
169
|
+
|
|
170
|
+
### HIGH — treating `dashboard.authorize` as the dashboard's data gate
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// Wrong — authorize gates only the SPA shell; panels still 403/404 without governanceAuthorize.
|
|
174
|
+
dashboard: { enabled: true, authorize: (actor) => actor.roles?.includes('ADMIN') ?? false }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// Correct — one predicate shape on BOTH knobs: shell gate + data gate.
|
|
179
|
+
governanceAuthorize: (actor) => actor.roles?.includes('ADMIN') ?? false,
|
|
180
|
+
dashboard: { enabled: true, authorize: (actor) => actor.roles?.includes('ADMIN') ?? false },
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Mechanism: the console is a pure consumer of `/agent/governance/*`; `authorize` decides
|
|
184
|
+
who may load the shell while `governanceAuthorize` decides whether the console mounts at
|
|
185
|
+
all and whether its data routes answer. With no gate the provider logs a boot warning and
|
|
186
|
+
refuses to mount rather than serve seven dead panels.
|
|
187
|
+
Source: `packages/adonis/src/dashboard/define_config.ts` (`decideDashboardMount`,
|
|
188
|
+
`NO_GOVERNANCE_GATE_WARNING`).
|
|
189
|
+
|
|
190
|
+
### MEDIUM — giving a per-agent resolver a different identity than the global one
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// Wrong — webhook agents resolve actor 'body:svc-1'; the global resolver would say 'user_9'.
|
|
194
|
+
agents: [{ name: 'webhook', actorResolver: new BodyActorResolver() }]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
// Correct — a per-agent resolver must yield an id the GLOBAL resolver could also produce.
|
|
199
|
+
agents: [
|
|
200
|
+
{
|
|
201
|
+
name: 'webhook',
|
|
202
|
+
actorResolver: new AuthActorResolver({ toActor: mapServicePrincipal }), // same id space
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Mechanism: the override applies ONLY to `POST /agent/chat`; stream re-attach, cancel,
|
|
208
|
+
approve/reject, threads, quota, and all governance routes resolve the GLOBAL resolver —
|
|
209
|
+
a mismatched `actor.id` means the caller starts a run it cannot stream, cancel, or
|
|
210
|
+
approve (ownership compares ids).
|
|
211
|
+
Source: `packages/adonis/docs/authoring/personas-and-agents.mdx` ("The override applies
|
|
212
|
+
to POST /agent/chat only").
|
|
213
|
+
|
|
214
|
+
### MEDIUM — designing a client around a thrown gate's message
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// Wrong — reading error.message from a 403 to branch UI logic; prod never carries it.
|
|
218
|
+
if (err.message.includes('missing tenant')) showTenantPicker()
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
// Correct — treat 403 as opaque; debug details exist only outside production.
|
|
223
|
+
if (err.status === 403) showForbidden() // body is exactly { "error": "forbidden" } in prod
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Mechanism: `evaluateGovernanceGate` passes the thrown message through only when `debug`
|
|
227
|
+
is true (the provider passes `!app.inProduction`); in production the body is the generic
|
|
228
|
+
string, because authorization predicates routinely throw with sensitive detail.
|
|
229
|
+
Source: `packages/adonis/src/governance-gate.ts`, `packages/adonis/docs/governance/authorization.mdx`
|
|
230
|
+
("A thrown message never reaches production clients").
|
|
231
|
+
|
|
232
|
+
See also: `agent-setup/SKILL.md` — wiring `AuthActorResolver` at setup time;
|
|
233
|
+
`agent-console-client/SKILL.md` — how the console consumes these gated routes.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-personas-agents
|
|
3
|
+
description: >-
|
|
4
|
+
Shape @adonis-agora/agent behavior per request: personas (Persona { id, label,
|
|
5
|
+
systemPrompt string | PromptBuilder, allowedTools }, defaultPersona, the
|
|
6
|
+
/agent/threads/personas/catalog route), named agents (agents: AgentDefinition[] with
|
|
7
|
+
tools/maxSteps/actorResolver overrides), delegatesTo multi-agent delegation via
|
|
8
|
+
synthesized ask_<target> agent-kind tools and DelegateEdge { agent, roles, ability },
|
|
9
|
+
HITL approval flow over POST /agent/tool-call/approve|reject with pending_approval
|
|
10
|
+
status, maxSteps budgeting, and PromptContext/basePrompt composition. Use for
|
|
11
|
+
"persona picker", "orchestrator delegates to specialists", "ask_researcher denied",
|
|
12
|
+
"action tool hangs waiting for approval", "prompt builder".
|
|
13
|
+
metadata:
|
|
14
|
+
type: core
|
|
15
|
+
library: "@adonis-agora/agent"
|
|
16
|
+
library_version: "0.25.2"
|
|
17
|
+
framework: adonisjs
|
|
18
|
+
sources:
|
|
19
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/authoring/personas-and-agents.mdx"
|
|
20
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/streaming-and-http.mdx"
|
|
21
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/types.ts"
|
|
22
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/agent-loop.ts"
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Personas, named agents & delegation
|
|
26
|
+
|
|
27
|
+
The default setup is one agent. Two features extend it: **personas** reshape a single
|
|
28
|
+
agent per request (prompt + optional tool allow-list), and **named agents** with
|
|
29
|
+
`delegatesTo` turn one assistant into a small team that hands work around through
|
|
30
|
+
synthesized `ask_<target>` tools.
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
Personas on the implicit default agent; the caller selects one via `POST /agent/chat`'s
|
|
35
|
+
`persona` field, and it pins to the thread:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// config/agent.ts
|
|
39
|
+
export default defineConfig({
|
|
40
|
+
model: () => aiSdkModel(openai('gpt-4o-mini')),
|
|
41
|
+
store: 'lucid',
|
|
42
|
+
stores: { lucid: stores.lucid() },
|
|
43
|
+
actorResolver: new AuthActorResolver(),
|
|
44
|
+
defaultAgent: {
|
|
45
|
+
systemPrompt: 'You are the assistant for our store.',
|
|
46
|
+
defaultPersona: 'shopper',
|
|
47
|
+
personas: [
|
|
48
|
+
{
|
|
49
|
+
id: 'shopper',
|
|
50
|
+
label: 'Shopping assistant',
|
|
51
|
+
systemPrompt: 'Help the customer find and track products. Be concise.',
|
|
52
|
+
allowedTools: ['getOrder', 'searchProducts'],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'support',
|
|
56
|
+
label: 'Support agent',
|
|
57
|
+
systemPrompt: 'Help resolve issues. You may issue refunds with approval.',
|
|
58
|
+
allowedTools: ['getOrder', 'issueRefund'],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A persona's `systemPrompt` resolves WITH the agent's base prompt available as
|
|
66
|
+
`basePrompt`, so it can wrap rather than discard; it may be a `PromptBuilder`
|
|
67
|
+
(`(ctx: PromptContext) => string | Promise<string>`) composed from actor/persona/
|
|
68
|
+
pageContext. Render a picker from `GET /agent/threads/personas/catalog`.
|
|
69
|
+
|
|
70
|
+
Source: `packages/adonis/docs/authoring/personas-and-agents.mdx`.
|
|
71
|
+
|
|
72
|
+
## Core patterns
|
|
73
|
+
|
|
74
|
+
### Pattern 1 — orchestrator delegating to named specialists
|
|
75
|
+
|
|
76
|
+
Each `delegatesTo` edge auto-registers an `agent`-kind tool named `ask_<target>`
|
|
77
|
+
(non-alphanumerics become underscores) whose input is `{ task }`. You never write its
|
|
78
|
+
handler — the loop runs the target and feeds the answer back:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// config/agent.ts
|
|
82
|
+
export default defineConfig({
|
|
83
|
+
// ...
|
|
84
|
+
defaultAgent: {
|
|
85
|
+
name: 'orchestrator',
|
|
86
|
+
systemPrompt: 'You coordinate specialists. Delegate work, then summarize.',
|
|
87
|
+
delegatesTo: [
|
|
88
|
+
{ agent: 'researcher', roles: ['ANALYST', 'ADMIN'] }, // role-based gate
|
|
89
|
+
{ agent: 'billing', ability: 'agent.delegate.billing' }, // ability-based gate (authz)
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
agents: [
|
|
93
|
+
{ name: 'researcher', systemPrompt: 'You research questions using read tools.', tools: ['searchDocs'] },
|
|
94
|
+
{ name: 'billing', systemPrompt: 'You handle billing. Refunds require approval.', tools: ['getInvoice', 'issueRefund'] },
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
When the target has a flat-string base prompt, it is appended to the delegate tool's
|
|
100
|
+
generated description so the orchestrator's model knows what the specialist is for.
|
|
101
|
+
|
|
102
|
+
Source: `packages/adonis/docs/authoring/personas-and-agents.mdx` ("Multi-agent
|
|
103
|
+
delegation", "How a delegate tool is named").
|
|
104
|
+
|
|
105
|
+
### Pattern 2 — approving a paused `action` tool (HITL)
|
|
106
|
+
|
|
107
|
+
An `action` call records `pending_approval` and suspends the run; the client reads the
|
|
108
|
+
pending `toolCallId` from thread detail, then decides. The decision is scoped to one
|
|
109
|
+
`(runId, toolCallId)` pair, so one run can never approve another's call:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
curl http://localhost:3333/agent/tool-call/approve \
|
|
113
|
+
-H 'content-type: application/json' \
|
|
114
|
+
-d '{"runId":"<runId>","toolCallId":"<toolCallId>"}'
|
|
115
|
+
|
|
116
|
+
curl http://localhost:3333/agent/tool-call/reject \
|
|
117
|
+
-H 'content-type: application/json' \
|
|
118
|
+
-d '{"runId":"<runId>","toolCallId":"<toolCallId>","reason":"not this account"}'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
On reject, the reason is fed back to the model and the turn continues. A UI can also poll
|
|
122
|
+
`GET /agent/approvals/mine` — the calling actor's own pending approvals.
|
|
123
|
+
|
|
124
|
+
Source: `packages/adonis/docs/streaming-and-http.mdx` ("Human-in-the-loop").
|
|
125
|
+
|
|
126
|
+
### Pattern 3 — cap runaway loops with `maxSteps`
|
|
127
|
+
|
|
128
|
+
`maxSteps` bounds model↔tool iterations per turn (default 8). Give a shallow specialist a
|
|
129
|
+
smaller budget than the orchestrator:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
agents: [
|
|
133
|
+
{ name: 'summarizer', systemPrompt: 'Summarize in one pass.', maxSteps: 2 },
|
|
134
|
+
]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Source: `packages/adonis/src/types.ts` (`AgentDefinition.maxSteps`),
|
|
138
|
+
`packages/adonis/src/agent-loop.ts` (`const maxSteps = deps.maxSteps ?? 8`).
|
|
139
|
+
|
|
140
|
+
## Common mistakes
|
|
141
|
+
|
|
142
|
+
### HIGH — bare-string `delegatesTo` edges under non-admin callers or authz
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
// Wrong — declares NO roles and NO ability.
|
|
146
|
+
delegatesTo: ['researcher']
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// Correct — say who may delegate.
|
|
151
|
+
delegatesTo: [{ agent: 'researcher', roles: ['ANALYST', 'ADMIN'] }]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Mechanism: a delegate tool goes through the SAME gate as every other tool. A bare string
|
|
155
|
+
carries nothing, so under `DefaultToolAuthorizer` it is ADMIN-only, and under the authz
|
|
156
|
+
Bouncer adapter a spec without `ability` is denied outright — the edge becomes uncalled
|
|
157
|
+
for everyone but admins.
|
|
158
|
+
Source: `packages/adonis/docs/authoring/personas-and-agents.mdx` ("Authorizing a
|
|
159
|
+
delegation"), `packages/adonis/src/types.ts` (`DelegateEdge`).
|
|
160
|
+
|
|
161
|
+
### MEDIUM — debugging "the orchestrator ignored the specialist" as a prompt problem
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// Symptom — the model apologizes and answers alone; no visible error anywhere.
|
|
165
|
+
// The delegation was DENIED and recorded failed:
|
|
166
|
+
// { toolName: 'ask_researcher', status: 'failed', error: 'Tool "ask_researcher" is not allowed...' }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// Correct move — check the tool-call feed (governance read-model / dashboard Tool calls panel).
|
|
171
|
+
// A denied delegation persists as failed — never auto_executed — and the error text goes back
|
|
172
|
+
// to the model as a tool result, which it paraphrases away silently.
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Mechanism: the loop re-applies all three gates before delegating (offered-tools check,
|
|
176
|
+
role check, input validation); on denial it records the call `failed`, skips
|
|
177
|
+
`agent.delegated`, and pushes `{ output: null, error }` into the transcript — so the run
|
|
178
|
+
completes normally while the delegation quietly never happened.
|
|
179
|
+
Source: `packages/adonis/src/agent-loop.ts` (agent-kind branch), `packages/adonis/docs/
|
|
180
|
+
authoring/personas-and-agents.mdx` ("A denied delegation is recorded, not retried").
|
|
181
|
+
|
|
182
|
+
### MEDIUM — expecting a delegated sub-agent's action tools to wait for a human
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// Wrong assumption — nested researcher asks for approval; the run should pause.
|
|
186
|
+
defaultAgent: { delegatesTo: [{ agent: 'researcher' }] }
|
|
187
|
+
agents: [{ name: 'researcher', tools: ['issueRefund' /* action */] }]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// Correct — keep human-approval actions on the TOP-LEVEL agent only.
|
|
192
|
+
agents: [
|
|
193
|
+
{ name: 'researcher', tools: ['searchDocs', 'getInvoice'] }, // read tools only
|
|
194
|
+
]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Mechanism: under the inline runner a sub-agent runs as a nested in-process loop with no
|
|
198
|
+
human attached, so its `action` tools are AUTO-DECLINED rather than left hanging; under
|
|
199
|
+
the durable runner the same delegation maps to a tracked child workflow instead.
|
|
200
|
+
Source: `packages/adonis/docs/authoring/personas-and-agents.mdx` (final paragraph).
|
|
201
|
+
|
|
202
|
+
### LOW — using wall-clock time or randomness inside a PromptBuilder
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// Wrong — non-deterministic prompt; a durable replay recomputes a DIFFERENT prompt.
|
|
206
|
+
const builder: PromptBuilder = async (ctx) => `${ctx.basePrompt}\nNow: ${new Date().toISOString()}`
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// Correct — derive from stable inputs (actor/persona/pageContext) resolved once per turn.
|
|
211
|
+
const builder: PromptBuilder = (ctx) => `${ctx.basePrompt}\nAssist ${ctx.actor.id} in ${ctx.actor.tenantRef ?? 'the org'}.`
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Mechanism: the loop resolves the effective system prompt exactly once per turn from
|
|
215
|
+
stable context; injecting entropy breaks replay determinism under the durable runner.
|
|
216
|
+
Source: `packages/adonis/docs/authoring/personas-and-agents.mdx` (replay-safety note),
|
|
217
|
+
`packages/adonis/src/agent-loop.ts` (`resolveSystemPrompt`).
|
|
218
|
+
|
|
219
|
+
See also: `agent-governance/SKILL.md` — the policy behind `roles`/`ability` gates;
|
|
220
|
+
`agent-testing/SKILL.md` — scripting multi-turn runs offline.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-setup
|
|
3
|
+
description: >-
|
|
4
|
+
Set up @adonis-agora/agent in an AdonisJS app: node ace configure @adonis-agora/agent,
|
|
5
|
+
defineConfig in config/agent.ts (model via aiSdkModel from @adonis-agora/agent/ai-sdk,
|
|
6
|
+
stores.lucid()/stores.memory(), quotas.ledger/quotas.memory, pricingStores +
|
|
7
|
+
seedModelPrices + estimateCost, tokenSinks.redis multi-replica SSE sink,
|
|
8
|
+
AuthActorResolver identity seam), auto-created agent tables vs the published migration,
|
|
9
|
+
the cost fold (null vs $0.00), and route mounting under config path. Use for "set up
|
|
10
|
+
the agent", "config/agent.ts", "agent tables / migration", "costUsd is null or zero",
|
|
11
|
+
"quota not enforced", "401 on every agent route".
|
|
12
|
+
metadata:
|
|
13
|
+
type: core
|
|
14
|
+
library: "@adonis-agora/agent"
|
|
15
|
+
library_version: "0.25.2"
|
|
16
|
+
framework: adonisjs
|
|
17
|
+
sources:
|
|
18
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/getting-started.mdx"
|
|
19
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/config-reference.mdx"
|
|
20
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/define_config.ts"
|
|
21
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/stores/factory.ts"
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# Setting up @adonis-agora/agent
|
|
25
|
+
|
|
26
|
+
`@adonis-agora/agent` is a governed AI agent module for AdonisJS: one `defineConfig`
|
|
27
|
+
call in `config/agent.ts` wires a provider-agnostic agent loop, persistence, budgeting,
|
|
28
|
+
and the `/agent/*` HTTP+SSE routes. Only `model` is required — everything else is a
|
|
29
|
+
fail-closed default that this skill walks through.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
Install and let the configure hook wire everything:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnpm add @adonis-agora/agent
|
|
37
|
+
node ace configure @adonis-agora/agent
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`configure` registers `@adonis-agora/agent/agent_provider` and
|
|
41
|
+
`@adonis-agora/agent/dashboard_provider` in `adonisrc.ts`, registers the Assembler init
|
|
42
|
+
hook that generates the typed `app/agent_tools` barrel, publishes `config/agent.ts`,
|
|
43
|
+
`config/mcp.ts`, and two migrations (the agent tables; a Postgres-only pgvector RAG chunk
|
|
44
|
+
table you can delete). Then add the model peer and write your config:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pnpm add ai zod @ai-sdk/openai
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// config/agent.ts
|
|
52
|
+
import { defineConfig, stores, AuthActorResolver } from '@adonis-agora/agent'
|
|
53
|
+
import { aiSdkModel } from '@adonis-agora/agent/ai-sdk'
|
|
54
|
+
import { openai } from '@ai-sdk/openai'
|
|
55
|
+
|
|
56
|
+
export default defineConfig({
|
|
57
|
+
// A lazy thunk keeps the provider SDK peer imported only at boot.
|
|
58
|
+
model: () => aiSdkModel(openai('gpt-4o-mini')),
|
|
59
|
+
|
|
60
|
+
store: 'lucid',
|
|
61
|
+
stores: {
|
|
62
|
+
memory: stores.memory(), // single-process; tests and scratch apps
|
|
63
|
+
lucid: stores.lucid(), // persists to SQL via @adonisjs/lucid
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// Identity seam — fail-closed. Without a resolver every request throws → 401.
|
|
67
|
+
actorResolver: new AuthActorResolver(),
|
|
68
|
+
|
|
69
|
+
defaultAgent: {
|
|
70
|
+
systemPrompt: 'You are a helpful assistant for our app.',
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run the migration for the six agent tables (`agent_thread`, `agent_message`,
|
|
76
|
+
`agent_tool_call`, `agent_token_usage`, `agent_model_pricing`, plus the run-lifecycle
|
|
77
|
+
columns):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
node ace migration:run
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You can skip it: the Lucid store provisions its own tables on first use
|
|
84
|
+
(`autoCreateTables` defaults to true), and the published migration delegates to the same
|
|
85
|
+
`createAgentTables` helper, so the two schemas can never drift.
|
|
86
|
+
|
|
87
|
+
Source: `packages/adonis/docs/getting-started.mdx`,
|
|
88
|
+
`packages/adonis/docs/stores/lucid.mdx`.
|
|
89
|
+
|
|
90
|
+
## Core patterns
|
|
91
|
+
|
|
92
|
+
### Pattern 1 — enforce a daily spend budget with `quotas.ledger`
|
|
93
|
+
|
|
94
|
+
Quotas are **opt-in**: omitting `quota` disables budgeting entirely (fail-open).
|
|
95
|
+
Configuring one makes the gate fail-closed — `check()` runs before the first model call
|
|
96
|
+
and over-budget turns throw `QuotaExceededError` before any tokens are spent.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { defineConfig, quotas } from '@adonis-agora/agent'
|
|
100
|
+
|
|
101
|
+
export default defineConfig({
|
|
102
|
+
// ...
|
|
103
|
+
quota: quotas.ledger({ limitTokens: 1_000_000 }), // enforced off the persisted usage ledger
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`quotas.memory({ limitTokens })` is the single-process variant for tests. The `day`
|
|
108
|
+
(`YYYY-MM-DD`, UTC) is stamped once by the runner, so day-bucketing stays deterministic
|
|
109
|
+
under durable replay.
|
|
110
|
+
|
|
111
|
+
Source: `packages/adonis/docs/governance/quota-and-cost.mdx`.
|
|
112
|
+
|
|
113
|
+
### Pattern 2 — price turns with a `pricingStore` + `seedModelPrices`
|
|
114
|
+
|
|
115
|
+
With a Lucid main store, pricing mirrors the same connection automatically (table
|
|
116
|
+
auto-created) — no extra config. Cost per turn resolves in order: provider-reported
|
|
117
|
+
(a gateway) wins, else an estimate from the current price rows fetched once per run,
|
|
118
|
+
else `null` — never a fabricated `0`. Seed prices once (e.g. from an Ace command):
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import db from '@adonisjs/lucid/services/db'
|
|
122
|
+
import { seedModelPrices, LucidPricingStore } from '@adonis-agora/agent'
|
|
123
|
+
|
|
124
|
+
const pricing = new LucidPricingStore(db.connection())
|
|
125
|
+
await seedModelPrices(pricing, [
|
|
126
|
+
{
|
|
127
|
+
modelId: 'gpt-4o-mini',
|
|
128
|
+
inputPricePer1m: 0.15,
|
|
129
|
+
outputPricePer1m: 0.6,
|
|
130
|
+
cacheWritePricePer1m: 0.19, // optional — falls back to the input rate
|
|
131
|
+
cacheReadPricePer1m: 0.015, // optional — falls back to the input rate
|
|
132
|
+
},
|
|
133
|
+
])
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Cache tokens are subsets of `inputTokens`, subtracted before the input rate applies.
|
|
137
|
+
Rollups (`spendByModel`/`spendByActor`) are sums, so an unpriced row contributes `$0.00`
|
|
138
|
+
even though the ledger row keeps `cost_usd: null` — if a total looks implausibly low,
|
|
139
|
+
check the pricing table first.
|
|
140
|
+
|
|
141
|
+
Source: `packages/adonis/docs/governance/quota-and-cost.mdx`,
|
|
142
|
+
`packages/adonis/src/spi/pricing-store.ts` (`seedModelPrices`, `estimateCost`).
|
|
143
|
+
|
|
144
|
+
### Pattern 3 — multi-replica SSE with `tokenSinks.redis`
|
|
145
|
+
|
|
146
|
+
The default sink buffers each run's stream in process memory, which is single-replica: a
|
|
147
|
+
run started on pod A can only be re-attached on pod A. Switching sinks keeps the whole
|
|
148
|
+
SSE envelope identical.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { defineConfig, tokenSinks } from '@adonis-agora/agent'
|
|
152
|
+
|
|
153
|
+
export default defineConfig({
|
|
154
|
+
// ...
|
|
155
|
+
sink: tokenSinks.redis({ ttlSeconds: 3600 }), // replay keys self-expire (default 1h)
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Requires `@adonisjs/redis` installed and configured unless you pass `client:` (a
|
|
160
|
+
bring-your-own `RedisStreamClient`). Pair with `durable: true` for cross-instance resume.
|
|
161
|
+
|
|
162
|
+
Source: `packages/adonis/src/stores/factory.ts` (`tokenSinks`),
|
|
163
|
+
`packages/adonis/docs/streaming-and-http.mdx` ("Single-replica by default" callout).
|
|
164
|
+
|
|
165
|
+
## Common mistakes
|
|
166
|
+
|
|
167
|
+
### HIGH — omitting `actorResolver` and shipping 401s everywhere
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// Wrong — UnconfiguredActorResolver THROWS on every request; all routes answer 401.
|
|
171
|
+
export default defineConfig({ model: () => aiSdkModel(openai('gpt-4o-mini')) })
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// Correct — read the authenticated principal; no identity is ever fabricated.
|
|
176
|
+
import { AuthActorResolver } from '@adonis-agora/agent'
|
|
177
|
+
export default defineConfig({
|
|
178
|
+
model: () => aiSdkModel(openai('gpt-4o-mini')),
|
|
179
|
+
actorResolver: new AuthActorResolver(),
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Mechanism: the provider installs `UnconfiguredActorResolver` by default, whose
|
|
184
|
+
`resolve()` unconditionally throws rather than invent a caller — the route replies 401
|
|
185
|
+
before the model ever runs.
|
|
186
|
+
Source: `packages/adonis/src/actor-resolver.ts` (`UnconfiguredActorResolver`),
|
|
187
|
+
`packages/adonis/docs/governance/authorization.mdx` ("No resolver, no service").
|
|
188
|
+
|
|
189
|
+
### MEDIUM — expecting quota enforcement after configuring only a store
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// Wrong — no `quota` key means budgets are DISABLED (fail-open), even with a ledger full of usage.
|
|
193
|
+
export default defineConfig({ model, store: 'lucid', stores: { lucid: stores.lucid() } })
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// Correct — opt in; the check then runs fail-closed before the first model call.
|
|
198
|
+
export default defineConfig({
|
|
199
|
+
...,
|
|
200
|
+
quota: quotas.ledger({ limitTokens: 500_000 }),
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Mechanism: the loop skips both `check` and `bump` entirely when `deps.quota` is
|
|
205
|
+
undefined — there is no implicit limit.
|
|
206
|
+
Source: `packages/adonis/docs/governance/quota-and-cost.mdx` ("Quota is opt-in, and off
|
|
207
|
+
means open"), `packages/adonis/src/agent-loop.ts` (`if (deps.quota !== undefined)`).
|
|
208
|
+
|
|
209
|
+
### HIGH — incrementing counters in a custom QuotaStore's `bump`
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// Wrong — double-counts every turn when paired with the persisted usage ledger.
|
|
213
|
+
const store: QuotaStore = {
|
|
214
|
+
async check(actorRef, day) { /* read my counter */ },
|
|
215
|
+
async bump(actorRef, day, tokens) { await redis.incrBy(key, tokens) },
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// Correct — a ledger-backed store leaves bump empty: recordUsage already wrote the tokens.
|
|
221
|
+
class LedgerQuota implements QuotaStore {
|
|
222
|
+
async check(actorRef: string, day: string) { /* sum agent_token_usage */ }
|
|
223
|
+
async bump(): Promise<void> {} // deliberate no-op — the ledger is the source of truth
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Mechanism: `store.recordUsage(...)` has already persisted each turn's tokens;
|
|
228
|
+
`quotas.ledger.check` reads that ledger, so adding again in `bump` counts each turn twice.
|
|
229
|
+
Only a store keeping its OWN tally (`quotas.memory`, a bespoke Redis counter) should add.
|
|
230
|
+
Source: `packages/adonis/docs/governance/quota-and-cost.mdx` ("`bump` is a notification,
|
|
231
|
+
not necessarily an increment"), `packages/adonis/src/stores/ledger-quota.ts`.
|
|
232
|
+
|
|
233
|
+
### MEDIUM — reading `$0.00` rollups as "this model is free"
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// Wrong — no prices seeded: usage rows persist costUsd null, but spendByModel sums them as 0.
|
|
237
|
+
await client.spendByModel({ fromDay: '2026-01-01', toDay: '2026-01-31' })
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// Correct — seed current prices so estimates fold into costUsd.
|
|
242
|
+
await seedModelPrices(pricingStore, [{ modelId: 'gpt-4o-mini', inputPricePer1m: 0.15, outputPricePer1m: 0.6 }])
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Mechanism: an unpriced turn is deliberately `null` ("unknown") in the ledger, but SUM
|
|
246
|
+
rollups cannot preserve null — every unpriced row adds `$0.00`, which reads exactly like
|
|
247
|
+
a free model. Per-run/per-usage rows keep their nulls.
|
|
248
|
+
Source: `packages/adonis/docs/governance/quota-and-cost.mdx` ("`null` in the ledger,
|
|
249
|
+
`0` in the rollups").
|
|
250
|
+
|
|
251
|
+
### MEDIUM — replacing the allowed attachment content types instead of extending
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
// Wrong — REPLACES the default list: png/jpeg/pdf/csv now get 415.
|
|
255
|
+
attachmentAllowedContentTypes: ['application/json']
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// Types match EXACTLY (no wildcards); spread the defaults you still want.
|
|
260
|
+
attachmentAllowedContentTypes: [
|
|
261
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
|
|
262
|
+
'application/pdf', 'text/plain', 'text/csv',
|
|
263
|
+
'text/markdown', // your addition
|
|
264
|
+
],
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Mechanism: passing `attachmentAllowedContentTypes` replaces the default list rather than
|
|
268
|
+
merging, and matching is exact — no `text/*` prefix rule — so anything absent is rejected
|
|
269
|
+
with 415.
|
|
270
|
+
Source: `packages/adonis/src/define_config.ts` (`AgentConfig.attachmentAllowedContentTypes`).
|
|
271
|
+
|
|
272
|
+
See also: `agent-tools/SKILL.md` — registering tools the model can actually call;
|
|
273
|
+
`agent-governance/SKILL.md` — what `AuthActorResolver` feeds into.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-testing
|
|
3
|
+
description: >-
|
|
4
|
+
Test @adonis-agora/agent offline: FakeModelProvider with a pure FakeScript
|
|
5
|
+
(args, turnIndex) and echoScript, the in-memory SPI doubles from
|
|
6
|
+
@adonis-agora/agent/testing (InMemoryAgentStore, InMemoryTokenStreamSink.subscribe,
|
|
7
|
+
InMemoryQuotaStore, InMemoryPricingStore, InMemoryGovernanceQueries,
|
|
8
|
+
InMemoryActorDirectory, InMemoryAttachmentStagingStore, FakeEmbeddingProvider,
|
|
9
|
+
FakeReranker, inMemoryRetriever), driving runAgentLoop directly with the
|
|
10
|
+
step/awaitApproval/openSink hooks seam, or AgentDepsFactory + InlineAgentRunner, and
|
|
11
|
+
asserting on typed StreamFrames. Use for "test the agent without an API key",
|
|
12
|
+
"script a tool-call turn", "assert streamed text", "HITL approve in tests".
|
|
13
|
+
metadata:
|
|
14
|
+
type: core
|
|
15
|
+
library: "@adonis-agora/agent"
|
|
16
|
+
library_version: "0.25.2"
|
|
17
|
+
framework: adonisjs
|
|
18
|
+
sources:
|
|
19
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/testing.mdx"
|
|
20
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/testing/index.ts"
|
|
21
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/testing/fake-model-provider.ts"
|
|
22
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/agent-loop.ts"
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Testing @adonis-agora/agent offline
|
|
26
|
+
|
|
27
|
+
Everything the agent depends on is a seam, so a test can run a full turn — model call,
|
|
28
|
+
tool execution, governance, cost accounting — with **no API key and no database**. The
|
|
29
|
+
in-memory doubles implement the SAME SPIs as the production adapters, so a green test
|
|
30
|
+
exercises the real loop code paths.
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
A minimal turn through the full loop, straight from the testing kit:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import {
|
|
38
|
+
runAgentLoop,
|
|
39
|
+
ToolRegistry,
|
|
40
|
+
DefaultRolesPolicy,
|
|
41
|
+
} from '@adonis-agora/agent'
|
|
42
|
+
import {
|
|
43
|
+
FakeModelProvider,
|
|
44
|
+
echoScript,
|
|
45
|
+
InMemoryAgentStore,
|
|
46
|
+
InMemoryTokenStreamSink,
|
|
47
|
+
} from '@adonis-agora/agent/testing'
|
|
48
|
+
|
|
49
|
+
const sink = new InMemoryTokenStreamSink()
|
|
50
|
+
const { text } = await runAgentLoop(
|
|
51
|
+
{
|
|
52
|
+
model: new FakeModelProvider(echoScript('hi there')),
|
|
53
|
+
store: new InMemoryAgentStore(),
|
|
54
|
+
registry: new ToolRegistry(),
|
|
55
|
+
rolesPolicy: new DefaultRolesPolicy(),
|
|
56
|
+
day: '2026-07-21',
|
|
57
|
+
systemPrompt: 'You are a helpful assistant.',
|
|
58
|
+
},
|
|
59
|
+
{ threadId: 'thread-1', actor: { id: 'user-1', roles: ['USER'] }, userText: 'hi' },
|
|
60
|
+
{
|
|
61
|
+
runId: crypto.randomUUID(),
|
|
62
|
+
openSink: () => sink.open('run-1'),
|
|
63
|
+
awaitApproval: async () => 'approve',
|
|
64
|
+
step: (_name, fn) => fn(),
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`hooks.step(name, fn)` is the checkpoint seam: inline it just calls `fn`; the durable
|
|
70
|
+
runner memoizes each named step. `hooks.awaitApproval` is where a HITL decision comes
|
|
71
|
+
from — return `{ approved: true }` (or a bare `'approve'`) to auto-approve in tests.
|
|
72
|
+
|
|
73
|
+
Source: `packages/adonis/README.md` ("Framework-agnostic core"),
|
|
74
|
+
`packages/adonis/src/agent-loop.ts` (`AgentLoopHooks`).
|
|
75
|
+
|
|
76
|
+
## Core patterns
|
|
77
|
+
|
|
78
|
+
### Pattern 1 — script a tool-call round-trip
|
|
79
|
+
|
|
80
|
+
`FakeModelProvider` takes a script — a pure function of `(args, turnIndex)`. `turnIndex`
|
|
81
|
+
counts assistant turns already in history, so there is no internal counter to reset:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { FakeModelProvider } from '@adonis-agora/agent/testing'
|
|
85
|
+
|
|
86
|
+
const model = new FakeModelProvider((args, turnIndex) => {
|
|
87
|
+
if (turnIndex === 0) {
|
|
88
|
+
// first turn: ask to call a tool
|
|
89
|
+
return { text: 'Let me check that.', toolCall: { name: 'getWeather', input: { city: 'Lisbon' } } }
|
|
90
|
+
}
|
|
91
|
+
// second turn: finish with the tool result in hand
|
|
92
|
+
return { text: 'It is 21°C in Lisbon.' }
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A scripted turn may also report `costUsd`, exactly as a gateway provider would, to
|
|
97
|
+
exercise the cost fold.
|
|
98
|
+
|
|
99
|
+
Source: `packages/adonis/docs/testing.mdx` ("The fake model"),
|
|
100
|
+
`packages/adonis/src/testing/fake-model-provider.ts`.
|
|
101
|
+
|
|
102
|
+
### Pattern 2 — run through the runner like production does
|
|
103
|
+
|
|
104
|
+
`AgentDepsFactory` + `InlineAgentRunner` is the same composition the provider builds at
|
|
105
|
+
boot, so the test covers run lifecycle persistence, not just the loop:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import {
|
|
109
|
+
AgentDepsFactory,
|
|
110
|
+
AgentRegistry,
|
|
111
|
+
ToolRegistry,
|
|
112
|
+
DefaultToolAuthorizer,
|
|
113
|
+
InlineAgentRunner,
|
|
114
|
+
} from '@adonis-agora/agent'
|
|
115
|
+
import {
|
|
116
|
+
FakeModelProvider,
|
|
117
|
+
echoScript,
|
|
118
|
+
InMemoryAgentStore,
|
|
119
|
+
InMemoryTokenStreamSink,
|
|
120
|
+
} from '@adonis-agora/agent/testing'
|
|
121
|
+
|
|
122
|
+
const store = new InMemoryAgentStore()
|
|
123
|
+
const sink = new InMemoryTokenStreamSink()
|
|
124
|
+
const registry = new ToolRegistry()
|
|
125
|
+
const agents = new AgentRegistry()
|
|
126
|
+
agents.register({ name: 'default' })
|
|
127
|
+
|
|
128
|
+
const factory = new AgentDepsFactory({
|
|
129
|
+
model: new FakeModelProvider(echoScript('hello')),
|
|
130
|
+
store,
|
|
131
|
+
sink,
|
|
132
|
+
rolesPolicy: new DefaultToolAuthorizer(['ADMIN']),
|
|
133
|
+
registry,
|
|
134
|
+
agents,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const runner = new InlineAgentRunner(factory, store)
|
|
138
|
+
const { runId } = await runner.start({
|
|
139
|
+
threadId: (await store.createThread({ actor: { id: 'u1', roles: ['ADMIN'] }, persona: 'default' })).id,
|
|
140
|
+
actor: { id: 'u1', roles: ['ADMIN'] },
|
|
141
|
+
userText: 'hi',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
let text = ''
|
|
145
|
+
for await (const frame of sink.subscribe(runId)) {
|
|
146
|
+
if (frame.t === 'text') text += frame.v
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Source: `packages/adonis/docs/testing.mdx` ("A minimal turn in a test").
|
|
151
|
+
|
|
152
|
+
### Pattern 3 — assert on typed `StreamFrame`s, not bytes
|
|
153
|
+
|
|
154
|
+
`sink.subscribe(runId)` yields discriminated frames — `{ t: 'text', v }` and
|
|
155
|
+
`{ t: 'component', name, data }` — so one loop can assert prose and Generative-UI
|
|
156
|
+
emissions separately. The SSE wire encoding happens later, in the provider's route
|
|
157
|
+
handler; a test never sees it:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const components: { name: string; data: unknown }[] = []
|
|
161
|
+
for await (const frame of sink.subscribe(runId)) {
|
|
162
|
+
if (frame.t === 'text') text += frame.v
|
|
163
|
+
if (frame.t === 'component') components.push({ name: frame.name, data: frame.data })
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Source: `packages/adonis/docs/testing.mdx` ("Because the fakes are behavioral twins"
|
|
168
|
+
note), `packages/adonis/src/in-process-sink.ts`.
|
|
169
|
+
|
|
170
|
+
## Common mistakes
|
|
171
|
+
|
|
172
|
+
### MEDIUM — keeping mutable state in a FakeModelProvider script
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// Wrong — a closure counter: the script is no longer a pure function of its inputs.
|
|
176
|
+
let calls = 0
|
|
177
|
+
const model = new FakeModelProvider(() => (calls++ === 0 ? { text: 'call tool' } : { text: 'done' }))
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// Correct — derive the turn from turnIndex (assistant turns already in history).
|
|
182
|
+
const model = new FakeModelProvider((_args, turnIndex) =>
|
|
183
|
+
turnIndex === 0 ? { text: 'call tool' } : { text: 'done' },
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Mechanism: `turnIndex` is computed from the message history each call
|
|
188
|
+
(`messages.filter(m => m.role === 'assistant').length`), so history-derived scripts stay
|
|
189
|
+
deterministic and replay-safe; a closure counter diverges the moment a run is retried or
|
|
190
|
+
replayed.
|
|
191
|
+
Source: `packages/adonis/src/testing/fake-model-provider.ts` (`turnIndex` derivation).
|
|
192
|
+
|
|
193
|
+
### MEDIUM — registering a tool the test actor's roles cannot reach, then asserting it ran
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// Wrong — DefaultRolesPolicy(['ADMIN']) + actor roles: ['USER'] → the tool is never OFFERED.
|
|
197
|
+
registry.register({ name: 'get_weather', kind: 'read', description: '', inputSchema: zodSchema }, handler)
|
|
198
|
+
const { text } = await runAgentLoop({ ..., rolesPolicy: new DefaultRolesPolicy() },
|
|
199
|
+
{ actor: { id: 'u1', roles: ['USER'] }, ... }, hooks)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
// Correct — align the actor's roles with the tool spec (or the policy's defaults).
|
|
204
|
+
await runAgentLoop({ ..., rolesPolicy: new DefaultRolesPolicy() },
|
|
205
|
+
{ actor: { id: 'u1', roles: ['ADMIN'] }, ... }, hooks)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Mechanism: the offered-tools filter drops role-forbidden tools BEFORE the model turn, so
|
|
209
|
+
the fake model's `toolCall` for a hidden tool fails with `ToolForbiddenError` at invoke —
|
|
210
|
+
the loop never auto-executes what was never offered.
|
|
211
|
+
Source: `packages/adonis/src/tool-registry.ts` (`definitionsFor`, `invoke`),
|
|
212
|
+
`packages/adonis/docs/governance/authorization.mdx` ("The double check").
|
|
213
|
+
|
|
214
|
+
### LOW — asserting on the in-memory sink's frames as if they were SSE text
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// Wrong — frames are typed objects, not an SSE byte stream.
|
|
218
|
+
const raw = await res.text() // 'data: {"delta":"..."}\n\n'
|
|
219
|
+
expect(raw).toContain('delta')
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// Correct — narrow on t and assert the payload.
|
|
224
|
+
for await (const frame of sink.subscribe(runId)) {
|
|
225
|
+
if (frame.t === 'text') expect(frame.v).toBeDefined()
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Mechanism: `InMemoryTokenStreamSink.subscribe` yields `StreamFrame` objects; the SSE
|
|
230
|
+
envelope (`event: meta` / `data: {"delta":...}` / `event: done`) is produced by the
|
|
231
|
+
provider's route handler via `frameToSse`, a layer tests never cross.
|
|
232
|
+
Source: `packages/adonis/docs/testing.mdx` (StreamFrame note),
|
|
233
|
+
`packages/adonis/src/sse.ts` (`frameToSse`).
|
|
234
|
+
|
|
235
|
+
See also: `agent-tools/SKILL.md` — registering real tools into the test registry;
|
|
236
|
+
`agent-setup/SKILL.md` — why `day` is a required, runner-stamped loop dep.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-tools
|
|
3
|
+
description: >-
|
|
4
|
+
Author and register governed tools for @adonis-agora/agent: @AiTool decorated classes,
|
|
5
|
+
BaseTool/ReadTool/ActionTool static-tool subclasses, defineTool functional tools,
|
|
6
|
+
discovery from app/agent_tools via the .adonisjs/agent/tools.js barrel (toolsHook with
|
|
7
|
+
source/importAlias/output) vs the runtime scan fallback, Standard Schema inputs
|
|
8
|
+
(Zod/Valibot/ArkType), AiToolCtx actor scoping, constructor DI (@inject, lazy + cached),
|
|
9
|
+
roles/ability fail-closed gating under defaultRoles, and the dataTool governed read-only
|
|
10
|
+
SQL satellite. Use for "write a tool", "my tool is never called", "tool forbidden",
|
|
11
|
+
"agent_tools barrel not generating", or "let the agent query the database".
|
|
12
|
+
metadata:
|
|
13
|
+
type: core
|
|
14
|
+
library: "@adonis-agora/agent"
|
|
15
|
+
library_version: "0.25.2"
|
|
16
|
+
framework: adonisjs
|
|
17
|
+
sources:
|
|
18
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/authoring/tools.mdx"
|
|
19
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/docs/authoring/data-satellite.mdx"
|
|
20
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/ai-tool-ref.ts"
|
|
21
|
+
- "DavideCarvalho/adonis-agent:packages/adonis/src/tool-discovery.ts"
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# Writing governed tools for @adonis-agora/agent
|
|
25
|
+
|
|
26
|
+
A tool is a named capability with a typed input schema; the model decides *when* to call
|
|
27
|
+
it, the loop decides *whether it may* (role gate) and *how* (auto-execute vs human
|
|
28
|
+
approval). `read` auto-executes, `action` pauses for HITL approval. There are two ways to
|
|
29
|
+
author one — a class or a function — and both register the same way.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
Create `app/agent_tools/get_weather.ts`. The Assembler init hook registered by
|
|
34
|
+
`node ace configure @adonis-agora/agent` generates the `.adonisjs/agent/tools.js` barrel
|
|
35
|
+
at build/dev time, and the provider registers every export at boot:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// app/agent_tools/get_weather.ts
|
|
39
|
+
import { z } from 'zod'
|
|
40
|
+
import { ReadTool } from '@adonis-agora/agent'
|
|
41
|
+
import type { AiToolCtx } from '@adonis-agora/agent'
|
|
42
|
+
|
|
43
|
+
const input = z.object({ city: z.string() })
|
|
44
|
+
type Input = z.infer<typeof input>
|
|
45
|
+
|
|
46
|
+
export default class GetWeather extends ReadTool<Input, { tempC: number }> {
|
|
47
|
+
// No `kind` — the base pins 'read'. Type-checks bare, no satisfies needed.
|
|
48
|
+
static tool = {
|
|
49
|
+
name: 'get_weather',
|
|
50
|
+
description: 'Current weather for a city.',
|
|
51
|
+
input,
|
|
52
|
+
roles: ['MEMBER'], // omit → config.defaultRoles (['ADMIN'] by default)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async execute({ city }: Input, _ctx: AiToolCtx) {
|
|
56
|
+
return { tempC: 21 }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Prefer a function? `defineTool(options, execute)` returns a branded `{ spec, handler }`
|
|
62
|
+
discovery picks up identically — or pass it straight to `defineConfig({ tools: [...] })`.
|
|
63
|
+
|
|
64
|
+
## Core patterns
|
|
65
|
+
|
|
66
|
+
### Pattern 1 — mutating tools as `ActionTool` (HITL approval)
|
|
67
|
+
|
|
68
|
+
`kind: 'action'` records the call `pending_approval` and suspends the run until a client
|
|
69
|
+
posts approve/reject to `/agent/tool-call/approve|reject`. Dependencies come from the IoC
|
|
70
|
+
container — declare them in the constructor with `@inject()`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
// app/agent_tools/purge_cache.ts
|
|
74
|
+
import { inject } from '@adonisjs/core'
|
|
75
|
+
import { z } from 'zod'
|
|
76
|
+
import { ActionTool } from '@adonis-agora/agent'
|
|
77
|
+
import type { AiToolCtx } from '@adonis-agora/agent'
|
|
78
|
+
|
|
79
|
+
const input = z.object({ key: z.string() })
|
|
80
|
+
type Input = z.infer<typeof input>
|
|
81
|
+
|
|
82
|
+
@inject()
|
|
83
|
+
export default class PurgeCache extends ActionTool<Input, { purged: string }> {
|
|
84
|
+
constructor(private readonly cache: CacheService) {
|
|
85
|
+
super()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static tool = {
|
|
89
|
+
name: 'purge_cache',
|
|
90
|
+
description: 'Purge one cache key.',
|
|
91
|
+
input,
|
|
92
|
+
ability: 'cache.purge', // consumed by an ability-aware RolesPolicy (authz adapter)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async execute({ key }: Input, _ctx: AiToolCtx) {
|
|
96
|
+
await this.cache.forget(key)
|
|
97
|
+
return { purged: key }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Class tools resolve lazily through the container on first invocation, then cached — so a
|
|
103
|
+
constructor needing a peer service fails nothing at boot.
|
|
104
|
+
|
|
105
|
+
Source: `packages/adonis/docs/authoring/tools.mdx` ("Constructor DI").
|
|
106
|
+
|
|
107
|
+
### Pattern 2 — let the agent query SQL with `dataTool` (fail-closed)
|
|
108
|
+
|
|
109
|
+
`dataTool` validates a single SELECT, enforces a REQUIRED role→group→table allow-list
|
|
110
|
+
(there is no allow-all), rewrites in an optional tenant scope, injects LIMIT, and
|
|
111
|
+
truncates oversized results before they hit the model's context:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// config/agent.ts
|
|
115
|
+
import { defineConfig, dataTool } from '@adonis-agora/agent'
|
|
116
|
+
import db from '@adonisjs/lucid/services/db'
|
|
117
|
+
|
|
118
|
+
export default defineConfig({
|
|
119
|
+
model: () => aiSdkModel(openai('gpt-4o-mini')),
|
|
120
|
+
tools: [
|
|
121
|
+
dataTool({
|
|
122
|
+
db: db.connection('readonly'), // point at a read-only replica
|
|
123
|
+
tableAccess: { // REQUIRED — fail-closed, no implicit allow-all
|
|
124
|
+
roleGroups: { MEMBER: ['sales'], ADMIN: ['sales', 'ops'] },
|
|
125
|
+
tablesByGroup: { sales: ['orders', 'products'], ops: ['audit_log'] },
|
|
126
|
+
},
|
|
127
|
+
tenant: { tenantColumn: 'tenant_id', scopedTables: ['orders'] },
|
|
128
|
+
maxRows: 100, // LIMIT injected when the query has none
|
|
129
|
+
}),
|
|
130
|
+
],
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The model sees a tool named `executeSql` taking `{ sql: string }`. Two governance layers
|
|
135
|
+
stack: `roles`/`ability` gate whether the tool may be called at all; `tableAccess` +
|
|
136
|
+
`tenant` gate what a permitted call may read.
|
|
137
|
+
|
|
138
|
+
Source: `packages/adonis/docs/authoring/data-satellite.mdx`.
|
|
139
|
+
|
|
140
|
+
### Pattern 3 — move the scan directory with `toolsHook`
|
|
141
|
+
|
|
142
|
+
To keep tools in a shared package or differently-named directory, call the hook factory
|
|
143
|
+
yourself in `adonisrc.ts`:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// adonisrc.ts
|
|
147
|
+
import { defineConfig } from '@adonisjs/core'
|
|
148
|
+
import { toolsHook } from '@adonis-agora/agent/hooks/tools'
|
|
149
|
+
|
|
150
|
+
export default defineConfig({
|
|
151
|
+
hooks: {
|
|
152
|
+
init: [
|
|
153
|
+
async () => ({
|
|
154
|
+
default: toolsHook({ source: 'app/ai/tools', importAlias: '#ai_tools' }),
|
|
155
|
+
}),
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Change `source` and `importAlias` together; leave `output` alone (default
|
|
162
|
+
`.adonisjs/agent/tools.ts`) — moving the output makes the provider silently fall back to
|
|
163
|
+
the runtime scan you were replacing.
|
|
164
|
+
|
|
165
|
+
Source: `packages/adonis/docs/authoring/tools.mdx` ("Configuring the generator"),
|
|
166
|
+
`packages/adonis/src/hooks/tools.ts` (`ToolsHookOptions`).
|
|
167
|
+
|
|
168
|
+
## Common mistakes
|
|
169
|
+
|
|
170
|
+
### CRITICAL — shipping tools without `roles` and calling them unreachable
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// Wrong — no roles → inherits defaultRoles ['ADMIN']; MEMBER actors never see this tool.
|
|
174
|
+
static tool = { name: 'get_order', description: '...', input }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// Correct — declare who may invoke.
|
|
179
|
+
static tool = { name: 'get_order', description: '...', input, roles: ['MEMBER'] }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Mechanism: `definitionsFor` filters the offered tool list through the role gate BEFORE
|
|
183
|
+
each model turn, so a tool the actor cannot invoke is never described to the model — it
|
|
184
|
+
cannot call what it was never shown. This is why "the model didn't call my tool" is
|
|
185
|
+
usually an authorization default, not a prompt problem.
|
|
186
|
+
Source: `packages/adonis/docs/authoring/tools.mdx` (fail-closed callout),
|
|
187
|
+
`packages/adonis/src/tool-registry.ts` (`DefaultRolesPolicy.can`).
|
|
188
|
+
|
|
189
|
+
### HIGH — trusting an id from the model's arguments instead of `ctx.actor`
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// Wrong — the MODEL chose this customerId; any caller can read anyone's orders.
|
|
193
|
+
async execute({ customerId }: Input) {
|
|
194
|
+
return Order.query().where('customer_id', customerId)
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
// Correct — scope to the acting identity the resolver authenticated.
|
|
200
|
+
async execute(_input: Input, ctx: AiToolCtx) {
|
|
201
|
+
return Order.query().where('customer_ref', ctx.actor.id)
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Mechanism: tool arguments are untrusted model output; identity is single-sourced on
|
|
206
|
+
`ctx.actor` (`id`, `roles`, `tenantRef`) populated from the server-side actor resolver.
|
|
207
|
+
Source: `packages/adonis/docs/authoring/tools.mdx` ("The tool context"),
|
|
208
|
+
`packages/adonis/src/spi/tool.ts`.
|
|
209
|
+
|
|
210
|
+
### MEDIUM — renaming the generated barrel's output path
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// Wrong — the provider imports .adonisjs/agent/tools.js; elsewhere = silently ignored.
|
|
214
|
+
toolsHook({ source: 'app/ai/tools', importAlias: '#ai_tools', output: '.adonisjs/mytools.ts' })
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// Correct — change source + importAlias together; leave output at its default.
|
|
219
|
+
toolsHook({ source: 'app/ai/tools', importAlias: '#ai_tools' })
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Mechanism: the provider imports the compiled barrel from the fixed
|
|
223
|
+
`.adonisjs/agent/tools.js`; a moved file is absent there, so boot falls back to scanning
|
|
224
|
+
`app/agent_tools` — which no longer holds your tools — and the agent runs with none of
|
|
225
|
+
them, logging only a quiet fallback.
|
|
226
|
+
Source: `packages/adonis/docs/authoring/tools.mdx` ("Point `output` elsewhere…"),
|
|
227
|
+
`packages/adonis/src/hooks/tools.ts` (`GENERATED_TOOLS_OUTPUT`).
|
|
228
|
+
|
|
229
|
+
### MEDIUM — debugging "zero tools" after adding one that throws on import
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
// Wrong assumption — a throwing tool file aborts discovery, so NOTHING registers.
|
|
233
|
+
// app/agent_tools/broken.ts references a service whose `app` is undefined during boot.
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// Correct mental model — the bad file is skipped loudly; every OTHER tool still registers.
|
|
238
|
+
// Fix the import error surfaced in the logs; the rest of agent_tools keeps working meanwhile.
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Mechanism: `discoverTools` wraps each dynamic import in its own try/catch, logs the
|
|
242
|
+
failure, and continues — so the symptom is one missing tool (the model narrating calls it
|
|
243
|
+
was never given), not a dead registry.
|
|
244
|
+
Source: `packages/adonis/src/tool-discovery.ts` (`discoverTools` import try/catch and log
|
|
245
|
+
message).
|
|
246
|
+
|
|
247
|
+
### LOW — declaring `kind` on a ReadTool/ActionTool subclass
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// Wrong — the base already pins kind; the literal widens and fights the inherited static type.
|
|
251
|
+
export default class CloseTicket extends ActionTool<Input, Row> {
|
|
252
|
+
static tool = { name: 'close_ticket', kind: 'action', description: '...', input }
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
// Correct — truly bare static; the base carries kind.
|
|
258
|
+
export default class CloseTicket extends ActionTool<Input, Row> {
|
|
259
|
+
static tool = { name: 'close_ticket', description: '...', input }
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Mechanism: `ReadTool`/`ActionTool` pin `static readonly kind`; redeclaring it adds a union
|
|
264
|
+
field the inherited `static tool?: BaseToolOptions` doesn't contextualize, so the literal's
|
|
265
|
+
`kind: string` widens and type-checking degrades instead of failing.
|
|
266
|
+
Source: `packages/adonis/src/base-tool.ts` (`BaseToolOptions`, kind-specific bases),
|
|
267
|
+
`packages/adonis/docs/authoring/tools.mdx` ("truly bare").
|
|
268
|
+
|
|
269
|
+
See also: `agent-governance/SKILL.md` — swapping `rolesPolicy` for ability checks;
|
|
270
|
+
`agent-personas-agents/SKILL.md` — narrowing tools per persona/agent.
|