@empyria/restate 0.4.0 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
package/README.md CHANGED
@@ -117,6 +117,13 @@ export const triage = defineAgent({
117
117
  | `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
118
  | `limits.maxInputBytes` | Largest request (JSON bytes, default 64 KB); a larger one is refused terminally (413). |
119
119
 
120
+ **Keying block tools.** A block tool calls another Restate building block. Its `key(args, meta)` and `input(args, meta)`
121
+ hooks decide the target key and the parameter from the model's arguments and a per-call idempotency key
122
+ (`meta.idempotencyKey`, unique for every tool call). A key that is the same for several calls (e.g. `lock:<resource>`)
123
+ makes a virtual object run those calls one at a time and queue the rest durably; a key built from the idempotency key
124
+ makes every call independent. `input` adds what the model must not control, such as the caller or the allowed folder.
125
+ Both hooks must not throw: validate before the run, not inside them.
126
+
120
127
  **Size caps on tool calls.** Tool arguments and results are journaled and replayed, so each tool has
121
128
  `maxInputBytes` (default 16 KB) and `maxOutputBytes` (default 32 KB). Oversized arguments are not run and
122
129
  the model is told why; an oversized result is replaced by an error the model sees. For an `execute` tool the
@@ -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 the arguments.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.0",
3
+ "version": "0.4.5",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -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
+ })