@empyria/restate 0.4.0 → 0.4.6
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/AGENTS.md +10 -0
- package/README.md +22 -0
- package/lib/Cron.js +64 -0
- package/lib/agent/Tools.js +16 -7
- package/package.json +1 -1
- package/test/Cron.test.js +144 -2
- package/test/agent/Tools.test.js +61 -0
package/AGENTS.md
CHANGED
|
@@ -45,6 +45,9 @@ Journal discipline is enforced by the library, not left to each agent: step meta
|
|
|
45
45
|
only (never prompt or reply text), and tool arguments/results have byte caps (`Tools.js`). Keep both
|
|
46
46
|
true when touching `Loop.js` or `Tools.js`.
|
|
47
47
|
|
|
48
|
+
Block tools take `key(args, meta)` and `input(args, meta)` hooks (`meta.idempotencyKey` is unique per tool call); both must be
|
|
49
|
+
total, because an exception there is a non-terminal error that Restate would retry forever.
|
|
50
|
+
|
|
48
51
|
`defineAgent`'s `tools` may be a function of the request; it is evaluated outside `ctx.run` on every
|
|
49
52
|
replay, so it must stay pure. `test/agent/fakes.js`'s result helpers use the AI SDK's v4 result shape
|
|
50
53
|
(`finishReason: {unified, raw}`); the older plain-string form makes `generateText` report no finish reason.
|
|
@@ -75,6 +78,13 @@ may observe an already-mocked value. See `test/Admin.test.js`.
|
|
|
75
78
|
ports, installs process-wide `SIGTERM`/`SIGINT` handlers, and its returned `forceClose` calls
|
|
76
79
|
`process.exit()` — none of which are safe to exercise in-process in a test run.
|
|
77
80
|
|
|
81
|
+
## Cron jobs
|
|
82
|
+
|
|
83
|
+
`CronJob.ensure` is the idempotent entry point (`ensureCronJob` is its ingress client). Never make a boot-time scheduler
|
|
84
|
+
with `CronJobInitiator.create`: it generates a new random job ID on every call, so each restart would add another copy.
|
|
85
|
+
`ensure` compares the normalised request (`cronExpression`, `service`, `method`, `key`, `payload`) and only reschedules
|
|
86
|
+
when it changed. A change here must keep `initiate`, `execute` and `cancel` behaving as before.
|
|
87
|
+
|
|
78
88
|
## Zero-dependency preference
|
|
79
89
|
|
|
80
90
|
Cron parsing uses `croner` (zero dependencies), not `cron-parser` (pulls in `luxon`). If you
|
package/README.md
CHANGED
|
@@ -106,6 +106,21 @@ export const triage = defineAgent({
|
|
|
106
106
|
| [lib/agent/Errors.js](./lib/agent/Errors.js) | `toRestateLLMError` (AI SDK error → terminal / `RetryableError` / transient) and the default retry policies for LLM steps, tool steps and the agent invocation. |
|
|
107
107
|
| [lib/agent/Model.js](./lib/agent/Model.js) | `createModel` — AI SDK model for an OpenAI-compatible endpoint (e.g. Bifrost). |
|
|
108
108
|
|
|
109
|
+
### Recurring jobs
|
|
110
|
+
|
|
111
|
+
`cronJob` (a virtual object) runs a target handler on a cron schedule: each run sends the target, then schedules the
|
|
112
|
+
next one from the current time, so a restart or an outage never causes a backlog of runs. Register `cronJob` on your
|
|
113
|
+
endpoint, then create jobs with either:
|
|
114
|
+
|
|
115
|
+
- `cronJobInitiator.create(request)`: a new job with a random ID each call (good for jobs created by users);
|
|
116
|
+
- `ensureCronJob({restateURL, id, cronExpression, service, method, key?, payload?})`: **idempotent, for code that
|
|
117
|
+
starts with the application.** The job has the stable `id` you give it, so calling it on every start never adds a second
|
|
118
|
+
job: the same request changes nothing, and a changed request (another schedule, target or payload) replaces the pending
|
|
119
|
+
run under the same ID. Call it once the endpoint has registered, e.g. from `setupRestate`'s `registrationCallbackFn`.
|
|
120
|
+
|
|
121
|
+
Cron expressions are `croner` patterns (five fields, or six with seconds for tests). The target handler is sent the
|
|
122
|
+
`payload` as its input, or no input when there is none.
|
|
123
|
+
|
|
109
124
|
### Shaping an agent
|
|
110
125
|
|
|
111
126
|
`defineAgent` options beyond the basics:
|
|
@@ -117,6 +132,13 @@ export const triage = defineAgent({
|
|
|
117
132
|
| `label(input)` | A terminal failure then reads `[label] message`, so a failed run says what it was working on. Code and metadata are kept; cancellations, timeouts and retryable errors are untouched. |
|
|
118
133
|
| `limits.maxInputBytes` | Largest request (JSON bytes, default 64 KB); a larger one is refused terminally (413). |
|
|
119
134
|
|
|
135
|
+
**Keying block tools.** A block tool calls another Restate building block. Its `key(args, meta)` and `input(args, meta)`
|
|
136
|
+
hooks decide the target key and the parameter from the model's arguments and a per-call idempotency key
|
|
137
|
+
(`meta.idempotencyKey`, unique for every tool call). A key that is the same for several calls (e.g. `lock:<resource>`)
|
|
138
|
+
makes a virtual object run those calls one at a time and queue the rest durably; a key built from the idempotency key
|
|
139
|
+
makes every call independent. `input` adds what the model must not control, such as the caller or the allowed folder.
|
|
140
|
+
Both hooks must not throw: validate before the run, not inside them.
|
|
141
|
+
|
|
120
142
|
**Size caps on tool calls.** Tool arguments and results are journaled and replayed, so each tool has
|
|
121
143
|
`maxInputBytes` (default 16 KB) and `maxOutputBytes` (default 32 KB). Oversized arguments are not run and
|
|
122
144
|
the model is told why; an oversized result is replaced by an error the model sees. For an `execute` tool the
|
package/lib/Cron.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as restate from '@restatedev/restate-sdk'
|
|
2
2
|
import { TerminalError, serde } from '@restatedev/restate-sdk'
|
|
3
3
|
import { Cron } from 'croner'
|
|
4
|
+
import { sendMessage } from './Admin.js'
|
|
4
5
|
|
|
5
6
|
let JOB_STATE_NAME = 'empyria-job-state'
|
|
6
7
|
|
|
@@ -32,6 +33,24 @@ export const cronJob = restate.object({
|
|
|
32
33
|
|
|
33
34
|
return await scheduleNextExecution(ctx, request)
|
|
34
35
|
},
|
|
36
|
+
/**
|
|
37
|
+
* Makes sure this job exists and runs the given request: an idempotent `initiate`, meant for
|
|
38
|
+
* code that starts with the application and cannot know whether the job was created before.
|
|
39
|
+
*
|
|
40
|
+
* - no job yet: schedules it, as `initiate` does;
|
|
41
|
+
* - the same request already scheduled: changes nothing and returns the current state;
|
|
42
|
+
* - a different request (another schedule, target or payload): cancels the pending run and
|
|
43
|
+
* schedules the new request under the same job ID.
|
|
44
|
+
*/
|
|
45
|
+
ensure: async (ctx, request) => {
|
|
46
|
+
const wanted = normalizeRequest(request)
|
|
47
|
+
const jobState = await ctx.get(JOB_STATE_NAME)
|
|
48
|
+
|
|
49
|
+
if (jobState && sameRequest(jobState.request, wanted)) return jobState
|
|
50
|
+
if (jobState) ctx.cancel(jobState.next_execution_id)
|
|
51
|
+
|
|
52
|
+
return await scheduleNextExecution(ctx, wanted)
|
|
53
|
+
},
|
|
35
54
|
execute: async (ctx) => {
|
|
36
55
|
const jobState = await ctx.get(JOB_STATE_NAME)
|
|
37
56
|
if (!jobState) {
|
|
@@ -76,6 +95,25 @@ export const cronJob = restate.object({
|
|
|
76
95
|
},
|
|
77
96
|
})
|
|
78
97
|
|
|
98
|
+
const REQUEST_FIELDS = ['cronExpression', 'service', 'method', 'key', 'payload']
|
|
99
|
+
|
|
100
|
+
/** Keeps only the fields a job request has, and checks the required ones. */
|
|
101
|
+
function normalizeRequest(request) {
|
|
102
|
+
const out = {}
|
|
103
|
+
for (const field of REQUEST_FIELDS)
|
|
104
|
+
if (request?.[field] !== undefined) out[field] = request[field]
|
|
105
|
+
for (const field of ['cronExpression', 'service', 'method']) {
|
|
106
|
+
if (typeof out[field] !== 'string' || out[field].trim() === '') {
|
|
107
|
+
throw new TerminalError(`A cron job needs a non-empty "${field}".`, { errorCode: 400 })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sameRequest = (a, b) =>
|
|
114
|
+
JSON.stringify(REQUEST_FIELDS.map((f) => a?.[f] ?? null)) ===
|
|
115
|
+
JSON.stringify(REQUEST_FIELDS.map((f) => b?.[f] ?? null))
|
|
116
|
+
|
|
79
117
|
const scheduleNextExecution = async (ctx, request) => {
|
|
80
118
|
// Parse cron expression
|
|
81
119
|
// Persist current date in Restate for deterministic replay
|
|
@@ -106,4 +144,30 @@ const scheduleNextExecution = async (ctx, request) => {
|
|
|
106
144
|
return jobState
|
|
107
145
|
}
|
|
108
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Makes sure a recurring job exists, from outside Restate (typically right after the service registered).
|
|
149
|
+
* Safe to call on every start: the job has a stable ID, so a repeat call changes nothing and a changed
|
|
150
|
+
* request replaces the old schedule instead of adding a second job.
|
|
151
|
+
*
|
|
152
|
+
* Needs the `CronJob` object registered on an endpoint.
|
|
153
|
+
* @param {{restateURL: string, id: string, cronExpression: string, service: string, method: string, key?: string, payload?: any}} params
|
|
154
|
+
* `id` names the job (letters, digits, `.`, `_`, `:`, `-`); `service`/`method`/`key` are the target the
|
|
155
|
+
* job calls on each run, with `payload` as its input (none when omitted).
|
|
156
|
+
* @returns {Promise<{request: object, next_execution_time: string, next_execution_id: string}>}
|
|
157
|
+
*/
|
|
158
|
+
export async function ensureCronJob({ restateURL, id, ...request }) {
|
|
159
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9._:-]+$/.test(id)) {
|
|
160
|
+
throw new TypeError(
|
|
161
|
+
'ensureCronJob needs an id made of letters, digits, ".", "_", ":" and "-"',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return sendMessage({
|
|
165
|
+
restateURL,
|
|
166
|
+
name: 'CronJob',
|
|
167
|
+
message: 'ensure',
|
|
168
|
+
key: id,
|
|
169
|
+
payload: request,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
109
173
|
export { JOB_STATE_NAME }
|
package/lib/agent/Tools.js
CHANGED
|
@@ -40,7 +40,13 @@ export const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value) ?? '
|
|
|
40
40
|
* @property {object} inputSchema JSON Schema of the tool's arguments, shown to the model.
|
|
41
41
|
* @property {string|{name: string}} block Target service/object/workflow, by name or definition.
|
|
42
42
|
* @property {string} handler Target handler.
|
|
43
|
-
* @property {(args: any) => string} [key] Virtual object / workflow key, derived from
|
|
43
|
+
* @property {(args: any, meta: {idempotencyKey: string}) => string} [key] Virtual object / workflow key, derived from
|
|
44
|
+
* the model's arguments and a per-call idempotency key (`<invocation id>:<step>.<index>`, unique for every tool
|
|
45
|
+
* call). A key that is the same for several calls makes those calls run one at a time; a key built from
|
|
46
|
+
* `meta.idempotencyKey` makes every call its own key, so they run in parallel. Must not throw.
|
|
47
|
+
* @property {(args: any, meta: {idempotencyKey: string}) => any} [input] Builds the parameter sent to the target
|
|
48
|
+
* handler from the model's arguments, for adding what the model must not control (who the caller is, which
|
|
49
|
+
* folder is allowed) or the idempotency key. Default: the arguments as they are. Must not throw.
|
|
44
50
|
* @property {ApprovalSpec} [approval]
|
|
45
51
|
* @property {number} [maxInputBytes] Largest accepted arguments (JSON bytes). Default
|
|
46
52
|
* {@link DEFAULT_TOOL_MAX_INPUT_BYTES}. A larger call is not made; the model is told why.
|
|
@@ -88,6 +94,11 @@ export function assertToolSpecs(tools) {
|
|
|
88
94
|
if (isBlock && typeof spec.handler !== 'string') {
|
|
89
95
|
throw new TypeError(`Block tool '${name}' needs a 'handler'`)
|
|
90
96
|
}
|
|
97
|
+
for (const hook of ['key', 'input']) {
|
|
98
|
+
if (spec[hook] !== undefined && typeof spec[hook] !== 'function') {
|
|
99
|
+
throw new TypeError(`Tool '${name}': ${hook} must be a function`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
91
102
|
for (const cap of ['maxInputBytes', 'maxOutputBytes']) {
|
|
92
103
|
if (spec[cap] !== undefined && !(Number.isInteger(spec[cap]) && spec[cap] > 0)) {
|
|
93
104
|
throw new TypeError(`Tool '${name}': ${cap} must be a positive integer`)
|
|
@@ -187,14 +198,12 @@ export async function executeTool(ctx, call, { tools, loadSkill, step, index })
|
|
|
187
198
|
}
|
|
188
199
|
}
|
|
189
200
|
|
|
201
|
+
const meta = { idempotencyKey: `${ctx.request().id}:${stepId}` }
|
|
190
202
|
const value = spec.execute
|
|
191
203
|
? await ctx.run(
|
|
192
204
|
`tool:${call.toolName}-${stepId}`,
|
|
193
205
|
async () => {
|
|
194
|
-
const result =
|
|
195
|
-
(await spec.execute(call.input, {
|
|
196
|
-
idempotencyKey: `${ctx.request().id}:${stepId}`,
|
|
197
|
-
})) ?? null
|
|
206
|
+
const result = (await spec.execute(call.input, meta)) ?? null
|
|
198
207
|
assertOutputSize(call.toolName, result, maxOut)
|
|
199
208
|
return result
|
|
200
209
|
},
|
|
@@ -203,8 +212,8 @@ export async function executeTool(ctx, call, { tools, loadSkill, step, index })
|
|
|
203
212
|
: await ctx.genericCall({
|
|
204
213
|
service: targetName(spec.block),
|
|
205
214
|
method: spec.handler,
|
|
206
|
-
key: spec.key?.(call.input),
|
|
207
|
-
parameter: call.input,
|
|
215
|
+
key: spec.key?.(call.input, meta),
|
|
216
|
+
parameter: spec.input ? spec.input(call.input, meta) : call.input,
|
|
208
217
|
inputSerde: serde.json,
|
|
209
218
|
outputSerde: serde.json,
|
|
210
219
|
})
|
package/package.json
CHANGED
package/test/Cron.test.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { describe, test, expect } from 'bun:test'
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
cronJobInitiator,
|
|
4
|
+
cronJob,
|
|
5
|
+
ensureCronJob,
|
|
6
|
+
setJobStateName,
|
|
7
|
+
JOB_STATE_NAME,
|
|
8
|
+
} from '../lib/Cron.js'
|
|
3
9
|
import { TerminalError } from '@restatedev/restate-sdk'
|
|
4
10
|
|
|
5
11
|
describe('setJobStateName', () => {
|
|
@@ -30,8 +36,11 @@ describe('cronJobInitiator.create', () => {
|
|
|
30
36
|
// wiring; this factory keeps the mocked context consistent across tests.
|
|
31
37
|
function makeSchedulingCtx({ existing } = {}) {
|
|
32
38
|
const sets = []
|
|
39
|
+
const cancelled = []
|
|
33
40
|
return {
|
|
34
41
|
sets,
|
|
42
|
+
cancelled,
|
|
43
|
+
cancel: (invocationId) => cancelled.push(invocationId),
|
|
35
44
|
get: async () => existing,
|
|
36
45
|
date: { now: async () => Date.now() },
|
|
37
46
|
key: 'job-1',
|
|
@@ -149,3 +158,136 @@ describe('cronJob.getInfo', () => {
|
|
|
149
158
|
expect(await cronJob.object.getInfo(ctx)).toEqual({ request: {} })
|
|
150
159
|
})
|
|
151
160
|
})
|
|
161
|
+
|
|
162
|
+
describe('cronJob.ensure', () => {
|
|
163
|
+
const request = { cronExpression: '* * * * *', service: 'ReportService', method: 'refresh' }
|
|
164
|
+
|
|
165
|
+
test('schedules a job that does not exist yet', async () => {
|
|
166
|
+
const ctx = makeSchedulingCtx()
|
|
167
|
+
const state = await cronJob.object.ensure(ctx, request)
|
|
168
|
+
expect(state.request).toEqual(request)
|
|
169
|
+
expect(state.next_execution_id).toBe('exec-inv-1')
|
|
170
|
+
expect(ctx.sets).toEqual([[JOB_STATE_NAME, state]])
|
|
171
|
+
expect(ctx.cancelled).toEqual([])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('the same request again changes nothing: no second schedule, no cancel', async () => {
|
|
175
|
+
const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
|
|
176
|
+
const ctx = makeSchedulingCtx({ existing })
|
|
177
|
+
expect(await cronJob.object.ensure(ctx, request)).toBe(existing)
|
|
178
|
+
expect(ctx.sets).toEqual([])
|
|
179
|
+
expect(ctx.cancelled).toEqual([])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('unknown fields and field order do not make a request "different"', async () => {
|
|
183
|
+
const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
|
|
184
|
+
const ctx = makeSchedulingCtx({ existing })
|
|
185
|
+
const same = {
|
|
186
|
+
method: 'refresh',
|
|
187
|
+
extra: 'ignored',
|
|
188
|
+
service: 'ReportService',
|
|
189
|
+
cronExpression: '* * * * *',
|
|
190
|
+
}
|
|
191
|
+
expect(await cronJob.object.ensure(ctx, same)).toBe(existing)
|
|
192
|
+
expect(ctx.sets).toEqual([])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('a changed schedule cancels the pending run and schedules the new one under the same job', async () => {
|
|
196
|
+
const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
|
|
197
|
+
const ctx = makeSchedulingCtx({ existing })
|
|
198
|
+
const state = await cronJob.object.ensure(ctx, {
|
|
199
|
+
...request,
|
|
200
|
+
cronExpression: '*/5 * * * *',
|
|
201
|
+
})
|
|
202
|
+
expect(ctx.cancelled).toEqual(['inv-old'])
|
|
203
|
+
expect(state.request.cronExpression).toBe('*/5 * * * *')
|
|
204
|
+
expect(ctx.sets).toHaveLength(1)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('a changed target or payload also replaces the job', async () => {
|
|
208
|
+
const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
|
|
209
|
+
for (const change of [{ method: 'other' }, { payload: { a: 1 } }, { key: 'k' }]) {
|
|
210
|
+
const ctx = makeSchedulingCtx({ existing })
|
|
211
|
+
await cronJob.object.ensure(ctx, { ...request, ...change })
|
|
212
|
+
expect(ctx.cancelled).toEqual(['inv-old'])
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('a request without a schedule, service or method is refused (400)', async () => {
|
|
217
|
+
for (const bad of [
|
|
218
|
+
{},
|
|
219
|
+
{ ...request, cronExpression: '' },
|
|
220
|
+
{ ...request, service: undefined },
|
|
221
|
+
{ ...request, method: 3 },
|
|
222
|
+
]) {
|
|
223
|
+
const error = await cronJob.object.ensure(makeSchedulingCtx(), bad).catch((e) => e)
|
|
224
|
+
expect(error).toBeInstanceOf(TerminalError)
|
|
225
|
+
expect(error.code).toBe(400)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('an invalid cron expression is a terminal error and leaves an existing job alone', async () => {
|
|
230
|
+
const ctx = makeSchedulingCtx()
|
|
231
|
+
await expect(
|
|
232
|
+
cronJob.object.ensure(ctx, { ...request, cronExpression: 'not a cron' }),
|
|
233
|
+
).rejects.toThrow(TerminalError)
|
|
234
|
+
expect(ctx.sets).toEqual([])
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('ensureCronJob', () => {
|
|
239
|
+
const realFetch = globalThis.fetch
|
|
240
|
+
afterEach(() => {
|
|
241
|
+
globalThis.fetch = realFetch
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('calls CronJob/<id>/ensure through the ingress with the request as the body', async () => {
|
|
245
|
+
const calls = []
|
|
246
|
+
globalThis.fetch = async (url, init) => {
|
|
247
|
+
calls.push([url, JSON.parse(init.body)])
|
|
248
|
+
return new Response(JSON.stringify({ next_execution_time: 'T' }), { status: 200 })
|
|
249
|
+
}
|
|
250
|
+
const state = await ensureCronJob({
|
|
251
|
+
restateURL: 'http://restate:8080',
|
|
252
|
+
id: 'reports:refresh',
|
|
253
|
+
cronExpression: '* * * * *',
|
|
254
|
+
service: 'ReportService',
|
|
255
|
+
method: 'refresh',
|
|
256
|
+
})
|
|
257
|
+
expect(state).toEqual({ next_execution_time: 'T' })
|
|
258
|
+
expect(calls).toEqual([
|
|
259
|
+
[
|
|
260
|
+
'http://restate:8080/CronJob/reports:refresh/ensure',
|
|
261
|
+
{ cronExpression: '* * * * *', service: 'ReportService', method: 'refresh' },
|
|
262
|
+
],
|
|
263
|
+
])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('refuses an id that is not safe in a URL path', async () => {
|
|
267
|
+
for (const id of ['', 'a/b', 'a b', 'a?b', undefined]) {
|
|
268
|
+
await expect(
|
|
269
|
+
ensureCronJob({
|
|
270
|
+
restateURL: 'http://x',
|
|
271
|
+
id,
|
|
272
|
+
cronExpression: '* * * * *',
|
|
273
|
+
service: 's',
|
|
274
|
+
method: 'm',
|
|
275
|
+
}),
|
|
276
|
+
).rejects.toThrow(TypeError)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('a failed call is an error for the caller', async () => {
|
|
281
|
+
globalThis.fetch = async () =>
|
|
282
|
+
new Response('nope', { status: 500, statusText: 'Internal Server Error' })
|
|
283
|
+
await expect(
|
|
284
|
+
ensureCronJob({
|
|
285
|
+
restateURL: 'http://x',
|
|
286
|
+
id: 'j',
|
|
287
|
+
cronExpression: '* * * * *',
|
|
288
|
+
service: 's',
|
|
289
|
+
method: 'm',
|
|
290
|
+
}),
|
|
291
|
+
).rejects.toThrow('500')
|
|
292
|
+
})
|
|
293
|
+
})
|
package/test/agent/Tools.test.js
CHANGED
|
@@ -334,3 +334,64 @@ describe('tool size caps', () => {
|
|
|
334
334
|
})
|
|
335
335
|
})
|
|
336
336
|
})
|
|
337
|
+
|
|
338
|
+
describe('block tool key and input hooks', () => {
|
|
339
|
+
const base = { description: 'd', inputSchema: schema, block: 'Runner', handler: 'run' }
|
|
340
|
+
|
|
341
|
+
test('key and input receive the arguments and a per-call idempotency key', async () => {
|
|
342
|
+
const ctx = fakeCtx({ onCall: () => ({ ok: true }) })
|
|
343
|
+
const seen = []
|
|
344
|
+
const tools = {
|
|
345
|
+
t: {
|
|
346
|
+
...base,
|
|
347
|
+
key: (args, meta) => (seen.push(['key', args, meta]), `lock:${args.folder}`),
|
|
348
|
+
input: (args, meta) => (
|
|
349
|
+
seen.push(['input', args, meta]),
|
|
350
|
+
{ ...args, idempotencyKey: meta.idempotencyKey, who: 'caller' }
|
|
351
|
+
),
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await executeTool(ctx, call('t', { folder: '/a' }), { tools, ...at })
|
|
356
|
+
|
|
357
|
+
expect(ctx.calls[0].key).toBe('lock:/a')
|
|
358
|
+
expect(ctx.calls[0].parameter).toEqual({
|
|
359
|
+
folder: '/a',
|
|
360
|
+
idempotencyKey: 'inv-1:2.1',
|
|
361
|
+
who: 'caller',
|
|
362
|
+
})
|
|
363
|
+
expect(seen.map(([name]) => name)).toEqual(['key', 'input'])
|
|
364
|
+
expect(seen[0][2]).toEqual({ idempotencyKey: 'inv-1:2.1' })
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('a key built from the idempotency key is different for every call', async () => {
|
|
368
|
+
const ctx = fakeCtx({ onCall: () => null })
|
|
369
|
+
const tools = { t: { ...base, key: (_, meta) => `idem:${meta.idempotencyKey}` } }
|
|
370
|
+
await executeTool(ctx, call('t'), { tools, step: 0, index: 0 })
|
|
371
|
+
await executeTool(ctx, call('t'), { tools, step: 0, index: 1 })
|
|
372
|
+
await executeTool(ctx, call('t'), { tools, step: 1, index: 0 })
|
|
373
|
+
expect(new Set(ctx.calls.map((c) => c.key)).size).toBe(3)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('a key shared by several calls is the same key', async () => {
|
|
377
|
+
const ctx = fakeCtx({ onCall: () => null })
|
|
378
|
+
const tools = { t: { ...base, key: (args) => `skill:${args.skill}` } }
|
|
379
|
+
await executeTool(ctx, call('t', { skill: 'a' }), { tools, step: 0, index: 0 })
|
|
380
|
+
await executeTool(ctx, call('t', { skill: 'a' }), { tools, step: 1, index: 0 })
|
|
381
|
+
expect(ctx.calls[0].key).toBe(ctx.calls[1].key)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('without hooks, behaviour is unchanged: arguments are the parameter, no key', async () => {
|
|
385
|
+
const ctx = fakeCtx({ onCall: () => null })
|
|
386
|
+
await executeTool(ctx, call('t', { x: 1 }), { tools: { t: base }, ...at })
|
|
387
|
+
expect(ctx.calls[0]).toMatchObject({ key: undefined, parameter: { x: 1 } })
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('assertToolSpecs rejects hooks that are not functions', () => {
|
|
391
|
+
expect(() => assertToolSpecs({ t: { ...base, key: 'x' } })).toThrow(TypeError)
|
|
392
|
+
expect(() => assertToolSpecs({ t: { ...base, input: {} } })).toThrow(TypeError)
|
|
393
|
+
expect(() =>
|
|
394
|
+
assertToolSpecs({ t: { ...base, key: () => 'k', input: (a) => a } }),
|
|
395
|
+
).not.toThrow()
|
|
396
|
+
})
|
|
397
|
+
})
|