@empyria/restate 0.2.2 → 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
@@ -4,10 +4,9 @@ Restate.dev helpers for **Empyria**, a nanoservice framework built primarily on
4
4
  an Admin API client, a dynamic-dispatch service, cron-driven workflow scheduling, and
5
5
  schema validation for workflow handlers.
6
6
 
7
- Targets Restate server `1.7.x` and `@restatedev/restate-sdk` `1.6.x` — check both when
8
- touching version pins; the SDK's own version numbering runs well ahead of the server's
9
- (e.g. `1.16.x` was latest on npm when `1.6.x` was what this repo targets), so "latest"
10
- is not the right default here.
7
+ Targets Restate server `1.7.x` and `@restatedev/restate-sdk` `1.17.x` (pinned in `package.json`) —
8
+ check both when touching version pins; the SDK's own version numbering runs well ahead of the
9
+ server's, so "latest" is not the right default here.
11
10
 
12
11
  ## Runtime
13
12
 
@@ -32,14 +31,31 @@ SDK packages (`ai`, `@ai-sdk/*`) are **optional** peer dependencies (and devDepe
32
31
  the tests): a consumer that only uses Admin/Cron/Pubsub must be able to import the package
33
32
  root without them installed. Never import `lib/agent/` from a root-exported module.
34
33
 
35
- Agent tests use `test/agent/fakes.js`'s `fakeCtx`, which mimics the journal's JSON round trip
34
+ Agent tests use `fakeCtx` from `lib/testing/Fakes.js` (exported as `@empyria/restate/testing`; `test/agent/fakes.js` re-exports it), which mimics the journal's JSON round trip
36
35
  and a bounded `ctx.run` turning an exhausted retry into a `TerminalError` — keep it in step
37
36
  with the real SDK behaviour when touching either.
38
37
 
38
+ ## Keep this package generic
39
+
40
+ Only things that are generic to Restate or to LLM agents belong here. Anything specific to one
41
+ consumer (its folder layout, its service names, its prompts, its UI) stays in that consumer. A consumer
42
+ that finds itself writing a generic helper should add it here with tests, release it, then bump its pin.
43
+
44
+ Journal discipline is enforced by the library, not left to each agent: step metadata is small numbers
45
+ only (never prompt or reply text), and tool arguments/results have byte caps (`Tools.js`). Keep both
46
+ true when touching `Loop.js` or `Tools.js`.
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
+
51
+ `defineAgent`'s `tools` may be a function of the request; it is evaluated outside `ctx.run` on every
52
+ replay, so it must stay pure. `test/agent/fakes.js`'s result helpers use the AI SDK's v4 result shape
53
+ (`finishReason: {unified, raw}`); the older plain-string form makes `generateText` report no finish reason.
54
+
39
55
  ## No official Admin API client
40
56
 
41
57
  Verified directly against the published `@restatedev/restate-sdk`/`-clients` package
42
- internals (both the `1.6.x` line this repo targets and the latest available at the time):
58
+ internals (both the line this repo targets and the latest available at the time):
43
59
  zero Admin API exports. Only the ingress/invocation client and the service-authoring API
44
60
  (`restate.service`/`object`/`workflow`) are official. `lib/Admin.js` fetches the Admin API's
45
61
  REST endpoints directly — that's not a stopgap, it's the only way to do this today.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  primarily on Bun: an Admin API client, a generic dynamic-dispatch service, cron-driven
5
5
  workflow scheduling, and Restate-aware input/output schema validation.
6
6
 
7
- Targets Restate server `1.7.x` and `@restatedev/restate-sdk` `1.6.x`.
7
+ Targets Restate server `1.7.x` and `@restatedev/restate-sdk` `1.17.x`.
8
8
 
9
9
  ## Requirements
10
10
 
@@ -106,6 +106,41 @@ 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
+ ### Shaping an agent
110
+
111
+ `defineAgent` options beyond the basics:
112
+
113
+ | Option | Does |
114
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
115
+ | `tools` as a function | `tools: (input) => ({...})` builds the tools from the validated request, for tools that depend on it. It runs outside any `ctx.run` on every execution and replay, so it must be pure. An invalid map fails that request terminally (500). |
116
+ | `respond(result, input)` | Shapes what `ask` returns from the loop result `{text, output?, steps, totalTokens, trace}`. Pair it with `responseSchema`, which validates the shaped value before Restate records it. |
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
+ | `limits.maxInputBytes` | Largest request (JSON bytes, default 64 KB); a larger one is refused terminally (413). |
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
+
127
+ **Size caps on tool calls.** Tool arguments and results are journaled and replayed, so each tool has
128
+ `maxInputBytes` (default 16 KB) and `maxOutputBytes` (default 32 KB). Oversized arguments are not run and
129
+ the model is told why; an oversized result is replaced by an error the model sees. For an `execute` tool the
130
+ check happens inside its own `ctx.run`, so the big value is never recorded.
131
+
132
+ **What a run records.** Each `llm-step-N` journals a small `meta` (`model`, `finishReason`, `inputTokens`,
133
+ `outputTokens`, `durationMs`), never the prompt or the reply. The loop returns the same facts as `trace`
134
+ (one entry per step with the tool names asked for) and logs one `ctx.console` line per step, which stays
135
+ silent on replay.
136
+
137
+ **Configuration.** `readLlmEnv(process.env)` returns `{LLM_BASE_URL, LLM_API_KEY?, MODEL_ID}` and fails with a
138
+ terminal 500 that names the missing setting and never prints a value.
139
+
140
+ **Testing agents.** `@empyria/restate/testing` exports `fakeCtx` (a journaling `run`, `genericCall`,
141
+ `awakeable`, state and a capturing `console`) and the model-result helpers `textResult`, `toolCallResult`
142
+ and `sequence`, without loading the AI SDK.
143
+
109
144
  Error policy: a tool that fails terminally (rejected or timed-out approval, a callee's
110
145
  `TerminalError`, an execute tool out of retries) goes back to the model as an error result,
111
146
  so the agent can adapt. Transient errors are left to Restate to retry, and cancellation
@@ -4,7 +4,10 @@ import { withValidation } from '../Validation.js'
4
4
  import { DEFAULT_AGENT_RETRY_POLICY } from './Errors.js'
5
5
  import { runAgentLoop } from './Loop.js'
6
6
  import { createModel } from './Model.js'
7
- import { assertToolSpecs } from './Tools.js'
7
+ import { assertToolSpecs, jsonBytes } from './Tools.js'
8
+
9
+ /** Largest request an agent accepts, in JSON bytes: the input is journaled and sent to the model. */
10
+ export const DEFAULT_MAX_INPUT_BYTES = 64 * 1024
8
11
 
9
12
  const HISTORY = 'history'
10
13
  const HISTORY_REF = 'historyRef'
@@ -57,20 +60,35 @@ export const AskOutputSchema = {
57
60
  * body is fetched again before every model call. So it must return the same content
58
61
  * for the whole invocation (skills shipped with the deployment do), or the invocation
59
62
  * fails terminally rather than send the model changed instructions.
60
- * @property {Record<string, import('./Tools.js').ToolSpec>} [tools]
63
+ * @property {Record<string, import('./Tools.js').ToolSpec> | ((input: any) => Record<string, import('./Tools.js').ToolSpec> | Promise<Record<string, import('./Tools.js').ToolSpec>>)} [tools]
64
+ * A fixed map, or a function of the validated request for tools that depend on it (for example
65
+ * confined to a folder the request names). The function runs outside any `ctx.run`, on every
66
+ * execution and replay: it must be pure and deterministic, and it must not do I/O. The returned
67
+ * map is checked like a fixed one; an invalid map fails the request terminally (500).
61
68
  * @property {object} [input] Input JSON Schema. Defaults to {@link AskInputSchema}.
62
69
  * @property {(input: any) => string} [prompt] Builds the user prompt from the validated
63
70
  * input. Defaults to `input.prompt`.
64
71
  * @property {object} [output] Output JSON Schema. When given, the model must answer through
65
72
  * the `final_answer` tool and `ask` returns its arguments. Defaults to
66
73
  * {@link AskOutputSchema} (`{text, steps}`).
74
+ * @property {(result: {text: string, output?: unknown, steps: number, totalTokens: number, trace: Array<object>}, input: any) => any} [respond]
75
+ * Shapes what `ask` returns from the loop's result (`output` is set when an `output` schema is
76
+ * given; `trace` is the per-step summary). Without it, `ask` returns `output`, or `{text, steps}`.
77
+ * @property {object} [responseSchema] JSON Schema of what `respond` returns, checked before Restate
78
+ * records it (a mismatch is terminal, 500). Only used together with `respond`.
79
+ * @property {(input: any) => string} [label] A short name for this request, for example the item it
80
+ * works on. A terminal failure inside the agent then reads `[label] message`, so a failed run says
81
+ * what it was working on. The error's code and metadata are kept; cancellations and timeouts are
82
+ * left alone.
67
83
  * @property {'none'|'session'} [memory] `none` (default): a stateless service. `session`: a
68
84
  * virtual object keyed by session ID, so turns of one session run one at a time and see
69
85
  * the conversation so far.
70
86
  * @property {HistoryStore} [historyStore] Session memory only. Without it, the conversation
71
87
  * is kept in object state, trimmed to `limits.maxHistoryTurns` — fine for short
72
88
  * sessions; long ones should use a store.
73
- * @property {{maxSteps?: number, maxTokens?: number, maxHistoryTurns?: number}} [limits]
89
+ * @property {{maxSteps?: number, maxTokens?: number, maxHistoryTurns?: number, maxInputBytes?: number}} [limits]
90
+ * `maxInputBytes` caps the request (JSON bytes, default {@link DEFAULT_MAX_INPUT_BYTES}); a larger
91
+ * one is refused terminally (413).
74
92
  * @property {import('@restatedev/restate-sdk').RunOptions<any>} [llmRetry] Retry policy of
75
93
  * each LLM call step. Defaults to `DEFAULT_LLM_RETRY`.
76
94
  * @property {import('@restatedev/restate-sdk').RetryPolicy} [retryPolicy] Invocation-level
@@ -109,6 +127,9 @@ export function defineAgent(spec) {
109
127
  input = AskInputSchema,
110
128
  prompt = (validatedInput) => validatedInput.prompt,
111
129
  output,
130
+ respond,
131
+ responseSchema,
132
+ label,
112
133
  memory = 'none',
113
134
  historyStore,
114
135
  limits = {},
@@ -126,17 +147,51 @@ export function defineAgent(spec) {
126
147
  if (historyStore && memory !== 'session') {
127
148
  throw new TypeError(`Agent '${name}': historyStore needs memory: 'session'`)
128
149
  }
129
- assertToolSpecs(tools)
150
+ if (typeof tools !== 'function') assertToolSpecs(tools)
151
+ if (respond !== undefined && typeof respond !== 'function') {
152
+ throw new TypeError(`Agent '${name}': respond must be a function`)
153
+ }
154
+ if (label !== undefined && typeof label !== 'function') {
155
+ throw new TypeError(`Agent '${name}': label must be a function`)
156
+ }
157
+
158
+ const {
159
+ maxSteps,
160
+ maxTokens,
161
+ maxHistoryTurns = 20,
162
+ maxInputBytes = DEFAULT_MAX_INPUT_BYTES,
163
+ } = limits
130
164
 
131
- const { maxSteps, maxTokens, maxHistoryTurns = 20 } = limits
165
+ const outputSchema = respond ? (responseSchema ?? {}) : (output ?? AskOutputSchema)
132
166
 
133
- const ask = withValidation(input, output ?? AskOutputSchema, async (ctx, validatedInput) => {
167
+ const ask = withValidation(input, outputSchema, async (ctx, validatedInput) => {
168
+ const inputBytes = jsonBytes(validatedInput)
169
+ if (inputBytes > maxInputBytes) {
170
+ throw withLabel(
171
+ new TerminalError(
172
+ `Agent '${name}': the request is ${inputBytes} bytes, over the limit of ${maxInputBytes}`,
173
+ { errorCode: 413 },
174
+ ),
175
+ label,
176
+ validatedInput,
177
+ )
178
+ }
179
+ try {
180
+ return await runAsk(ctx, validatedInput)
181
+ } catch (error) {
182
+ throw withLabel(error, label, validatedInput)
183
+ }
184
+ })
185
+
186
+ async function runAsk(ctx, validatedInput) {
134
187
  const userPrompt = prompt(validatedInput)
135
188
  if (typeof userPrompt !== 'string' || userPrompt === '') {
136
189
  throw new TerminalError(`Agent '${name}': the input produced no prompt`, {
137
190
  errorCode: 400,
138
191
  })
139
192
  }
193
+ const requestTools =
194
+ typeof tools === 'function' ? await resolveTools(name, tools, validatedInput) : tools
140
195
 
141
196
  const context = loadContext
142
197
  ? await ctx.run('load-context', async () => {
@@ -158,7 +213,7 @@ export function defineAgent(spec) {
158
213
  model,
159
214
  system: [system, context.systemPrompt].filter(Boolean).join('\n\n'),
160
215
  prompt: userPrompt,
161
- tools,
216
+ tools: requestTools,
162
217
  skills: context.skills,
163
218
  loadSkill: skillLoader({ loadSkill, loadContext, input: validatedInput }),
164
219
  loadHistory: session?.load,
@@ -170,8 +225,9 @@ export function defineAgent(spec) {
170
225
 
171
226
  if (session) await session.save(result.messages, maxHistoryTurns)
172
227
 
228
+ if (respond) return respond(result, validatedInput)
173
229
  return output ? result.output : { text: result.text, steps: result.steps }
174
- })
230
+ }
175
231
 
176
232
  const options = { retryPolicy }
177
233
 
@@ -235,6 +291,34 @@ function skillLoader({ loadSkill, loadContext, input }) {
235
291
  }
236
292
  }
237
293
 
294
+ /** Resolves a request-dependent `tools` function, failing the request (not retrying) on a bad map. */
295
+ async function resolveTools(name, tools, input) {
296
+ try {
297
+ const resolved = await tools(input)
298
+ assertToolSpecs(resolved ?? {})
299
+ return resolved ?? {}
300
+ } catch (error) {
301
+ throw new TerminalError(
302
+ `Agent '${name}': invalid tools for this request: ${error.message}`,
303
+ {
304
+ errorCode: 500,
305
+ },
306
+ )
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Prefixes a plain `TerminalError` with the request's label. Anything else (a retryable error, a
312
+ * cancellation, a timeout) is returned untouched, so Restate's own handling of it is unchanged.
313
+ */
314
+ function withLabel(error, label, input) {
315
+ if (!label || error?.constructor !== TerminalError) return error
316
+ return new TerminalError(`[${label(input)}] ${error.message}`, {
317
+ errorCode: error.code,
318
+ metadata: error.metadata,
319
+ })
320
+ }
321
+
238
322
  /**
239
323
  * @deprecated Use {@link defineAgent} with an explicit `name`. Kept so callers migrating
240
324
  * from `@empyria/restate-llm` only change their import: defines a stateless agent named
package/lib/agent/Loop.js CHANGED
@@ -37,8 +37,19 @@ const FINAL_ANSWER_NUDGE = `Respond by calling the ${FINAL_ANSWER_TOOL} tool wit
37
37
  * }} params `skills` is the index only (name + description); `loadSkill` fetches a body
38
38
  * when the model asks for it. With `outputSchema`, the model must answer through the
39
39
  * `final_answer` tool and the loop returns that tool's input as `output`.
40
- * @returns {Promise<{text: string, output?: unknown, steps: number, totalTokens: number, messages: Array<object>}>}
40
+ * @returns {Promise<{text: string, output?: unknown, steps: number, totalTokens: number, trace: Array<StepTrace>, messages: Array<object>}>}
41
41
  * `messages` are the new messages of this turn only (prompt, responses, tool results).
42
+ * `trace` has one small entry per LLM step. The same facts are recorded in the journal with each
43
+ * `llm-step-N` (as `meta`) and logged through `ctx.console`, which stays silent on replay.
44
+ *
45
+ * @typedef {Object} StepTrace What one LLM step did, with nothing from the prompt or the reply.
46
+ * @property {number} step
47
+ * @property {string} [model] The model ID as the provider reports it.
48
+ * @property {string} [finishReason] `stop`, `tool-calls`, `length`, ...
49
+ * @property {number} [inputTokens]
50
+ * @property {number} [outputTokens]
51
+ * @property {number} [durationMs] Wall-clock time of the model call.
52
+ * @property {string[]} tools Names of the tools the model asked for in this step.
42
53
  */
43
54
  export async function runAgentLoop(
44
55
  ctx,
@@ -61,6 +72,7 @@ export async function runAgentLoop(
61
72
  const messages = [{ role: 'user', content: prompt }]
62
73
  let totalTokens = 0
63
74
  const fetchSkill = cachedSkillFetcher(loadSkill)
75
+ const trace = []
64
76
 
65
77
  for (let step = 0; step < maxSteps; step++) {
66
78
  // `ctx.run` JSON-serialises the callback's result for the journal and hands back that
@@ -69,6 +81,7 @@ export async function runAgentLoop(
69
81
  const result = await ctx.run(
70
82
  `llm-step-${step}`,
71
83
  async () => {
84
+ const startedAt = Date.now()
72
85
  try {
73
86
  // `maxRetries: 0`: `ctx.run`'s RunOptions are the single retry authority,
74
87
  // instead of AI SDK's own retries stacking invisibly underneath them.
@@ -94,6 +107,14 @@ export async function runAgentLoop(
94
107
  })),
95
108
  responseMessages: r.response.messages,
96
109
  totalTokens: r.usage?.totalTokens ?? 0,
110
+ // A few numbers: enough to see whether the step worked, and what it cost.
111
+ meta: {
112
+ model: model.modelId,
113
+ finishReason: r.finishReason,
114
+ inputTokens: r.usage?.inputTokens,
115
+ outputTokens: r.usage?.outputTokens,
116
+ durationMs: Date.now() - startedAt,
117
+ },
97
118
  }
98
119
  } catch (error) {
99
120
  throw toRestateLLMError(error)
@@ -102,6 +123,13 @@ export async function runAgentLoop(
102
123
  retry,
103
124
  )
104
125
 
126
+ const entry = { step, ...result.meta, tools: result.toolCalls.map((call) => call.toolName) }
127
+ trace.push(entry)
128
+ // `ctx.console` is replay-aware: one line per step, never repeated when Restate replays.
129
+ ctx.console?.log(
130
+ `llm-step-${step} model=${entry.model ?? '?'} finish=${entry.finishReason ?? '?'} in=${entry.inputTokens ?? '?'} out=${entry.outputTokens ?? '?'} ${entry.durationMs ?? '?'}ms tools=[${entry.tools.join(',')}]`,
131
+ )
132
+
105
133
  messages.push(...result.responseMessages)
106
134
  totalTokens += result.totalTokens
107
135
  if (maxTokens !== undefined && totalTokens > maxTokens) {
@@ -113,7 +141,7 @@ export async function runAgentLoop(
113
141
 
114
142
  if (result.toolCalls.length === 0) {
115
143
  if (!outputSchema) {
116
- return { text: result.text, steps: step + 1, totalTokens, messages }
144
+ return { text: result.text, steps: step + 1, totalTokens, trace, messages }
117
145
  }
118
146
  messages.push({ role: 'user', content: FINAL_ANSWER_NUDGE })
119
147
  continue
@@ -126,6 +154,7 @@ export async function runAgentLoop(
126
154
  output: call.input,
127
155
  steps: step + 1,
128
156
  totalTokens,
157
+ trace,
129
158
  messages,
130
159
  }
131
160
  }
@@ -1,5 +1,45 @@
1
+ import { TerminalError } from '@restatedev/restate-sdk'
2
+ import { createValidator } from '@empyria/common'
1
3
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
2
4
 
5
+ /** JSON Schema of the environment variables that describe an LLM connection. */
6
+ export const LlmEnvSchema = {
7
+ type: 'object',
8
+ required: ['LLM_BASE_URL', 'MODEL_ID'],
9
+ properties: {
10
+ LLM_BASE_URL: { type: 'string', minLength: 1 },
11
+ LLM_API_KEY: { type: 'string' },
12
+ MODEL_ID: { type: 'string', minLength: 1 },
13
+ },
14
+ }
15
+
16
+ const validateLlmEnv = createValidator(LlmEnvSchema)
17
+
18
+ /**
19
+ * Reads the LLM connection from the environment (or any object) and checks it. A missing setting is
20
+ * a deployment mistake that no retry can fix, so it fails as a `TerminalError` (code 500) that names
21
+ * the problem and never prints a value, in particular not the API key.
22
+ * @param {Record<string, string|undefined>} [source] Defaults to `process.env`.
23
+ * @returns {{LLM_BASE_URL: string, LLM_API_KEY?: string, MODEL_ID: string}} Only the three LLM
24
+ * settings, ready for {@link createModel}.
25
+ * @throws {TerminalError}
26
+ */
27
+ export function readLlmEnv(source = process.env) {
28
+ const picked = {}
29
+ for (const key of ['LLM_BASE_URL', 'LLM_API_KEY', 'MODEL_ID']) {
30
+ if (typeof source[key] === 'string' && source[key] !== '') picked[key] = source[key]
31
+ }
32
+ try {
33
+ return validateLlmEnv(picked)
34
+ } catch {
35
+ const missing = LlmEnvSchema.required.filter((key) => !(key in picked))
36
+ throw new TerminalError(
37
+ `LLM is not configured: set ${missing.join(' and ') || 'LLM_BASE_URL and MODEL_ID'} (and usually LLM_API_KEY)`,
38
+ { errorCode: 500 },
39
+ )
40
+ }
41
+ }
42
+
3
43
  /**
4
44
  * AI SDK Core model, pointed at an OpenAI-compatible endpoint — Bifrost
5
45
  * (llm-system's gateway) by default, fully overridable via env. `LLM_BASE_URL`
@@ -12,6 +12,19 @@ export const FINAL_ANSWER_TOOL = 'final_answer'
12
12
 
13
13
  const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/
14
14
 
15
+ /**
16
+ * Default size caps for a tool call, in bytes of its JSON form. Tool arguments are recorded in the
17
+ * journal as part of the model's response and tool results are recorded when the tool finishes, and
18
+ * Restate replays all of it on every retry: only small values belong there. Override per tool with
19
+ * `maxInputBytes` / `maxOutputBytes`; a tool that must handle bigger data should keep it outside the
20
+ * journal and pass a reference instead.
21
+ */
22
+ export const DEFAULT_TOOL_MAX_INPUT_BYTES = 16 * 1024
23
+ export const DEFAULT_TOOL_MAX_OUTPUT_BYTES = 32 * 1024
24
+
25
+ /** Size of a value's JSON form, in bytes. */
26
+ export const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value) ?? '')
27
+
15
28
  /**
16
29
  * @typedef {Object} ApprovalSpec Human (or system) approval required before a tool runs.
17
30
  * @property {number} timeout Milliseconds to wait for a decision. A timeout counts as a
@@ -27,8 +40,18 @@ const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/
27
40
  * @property {object} inputSchema JSON Schema of the tool's arguments, shown to the model.
28
41
  * @property {string|{name: string}} block Target service/object/workflow, by name or definition.
29
42
  * @property {string} handler Target handler.
30
- * @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.
31
50
  * @property {ApprovalSpec} [approval]
51
+ * @property {number} [maxInputBytes] Largest accepted arguments (JSON bytes). Default
52
+ * {@link DEFAULT_TOOL_MAX_INPUT_BYTES}. A larger call is not made; the model is told why.
53
+ * @property {number} [maxOutputBytes] Largest result handed to the model (JSON bytes). Default
54
+ * {@link DEFAULT_TOOL_MAX_OUTPUT_BYTES}. A larger result is replaced by an error the model sees.
32
55
  *
33
56
  * @typedef {Object} ExecuteToolSpec A tool executed as a direct side effect inside its own `ctx.run`.
34
57
  * @property {string} description
@@ -39,6 +62,9 @@ const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/
39
62
  * @property {import('@restatedev/restate-sdk').RunOptions<any>} [retry] Defaults to
40
63
  * {@link DEFAULT_TOOL_RETRY}.
41
64
  * @property {ApprovalSpec} [approval]
65
+ * @property {number} [maxInputBytes] As on {@link BlockToolSpec}.
66
+ * @property {number} [maxOutputBytes] As on {@link BlockToolSpec}. Checked inside the tool's own
67
+ * `ctx.run`, so an oversized result is never recorded in the journal.
42
68
  *
43
69
  * @typedef {BlockToolSpec|ExecuteToolSpec} ToolSpec
44
70
  */
@@ -68,6 +94,16 @@ export function assertToolSpecs(tools) {
68
94
  if (isBlock && typeof spec.handler !== 'string') {
69
95
  throw new TypeError(`Block tool '${name}' needs a 'handler'`)
70
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
+ }
102
+ for (const cap of ['maxInputBytes', 'maxOutputBytes']) {
103
+ if (spec[cap] !== undefined && !(Number.isInteger(spec[cap]) && spec[cap] > 0)) {
104
+ throw new TypeError(`Tool '${name}': ${cap} must be a positive integer`)
105
+ }
106
+ }
71
107
  if (spec.approval) {
72
108
  const { timeout, notify } = spec.approval
73
109
  if (!(timeout > 0) || !notify?.block || !notify?.handler) {
@@ -142,6 +178,16 @@ export async function executeTool(ctx, call, { tools, loadSkill, step, index })
142
178
  const spec = tools[call.toolName]
143
179
  if (!spec) return { type: 'error-text', value: `Unknown tool "${call.toolName}".` }
144
180
 
181
+ const maxIn = spec.maxInputBytes ?? DEFAULT_TOOL_MAX_INPUT_BYTES
182
+ const inBytes = jsonBytes(call.input)
183
+ if (inBytes > maxIn) {
184
+ return {
185
+ type: 'error-text',
186
+ value: `Tool "${call.toolName}" was not run: its arguments are ${inBytes} bytes, over the limit of ${maxIn}. Send less.`,
187
+ }
188
+ }
189
+ const maxOut = spec.maxOutputBytes ?? DEFAULT_TOOL_MAX_OUTPUT_BYTES
190
+
145
191
  if (spec.approval) {
146
192
  const decision = await requestApproval(ctx, call, spec.approval)
147
193
  if (!decision.approved) {
@@ -152,24 +198,27 @@ export async function executeTool(ctx, call, { tools, loadSkill, step, index })
152
198
  }
153
199
  }
154
200
 
201
+ const meta = { idempotencyKey: `${ctx.request().id}:${stepId}` }
155
202
  const value = spec.execute
156
203
  ? await ctx.run(
157
204
  `tool:${call.toolName}-${stepId}`,
158
- async () =>
159
- (await spec.execute(call.input, {
160
- idempotencyKey: `${ctx.request().id}:${stepId}`,
161
- })) ?? null,
205
+ async () => {
206
+ const result = (await spec.execute(call.input, meta)) ?? null
207
+ assertOutputSize(call.toolName, result, maxOut)
208
+ return result
209
+ },
162
210
  spec.retry ?? DEFAULT_TOOL_RETRY,
163
211
  )
164
212
  : await ctx.genericCall({
165
213
  service: targetName(spec.block),
166
214
  method: spec.handler,
167
- key: spec.key?.(call.input),
168
- parameter: call.input,
215
+ key: spec.key?.(call.input, meta),
216
+ parameter: spec.input ? spec.input(call.input, meta) : call.input,
169
217
  inputSerde: serde.json,
170
218
  outputSerde: serde.json,
171
219
  })
172
220
 
221
+ if (!spec.execute) assertOutputSize(call.toolName, value, maxOut)
173
222
  return { type: 'json', value: value ?? null }
174
223
  } catch (error) {
175
224
  if (error instanceof CancelledError || !(error instanceof TerminalError)) throw error
@@ -177,6 +226,17 @@ export async function executeTool(ctx, call, { tools, loadSkill, step, index })
177
226
  }
178
227
  }
179
228
 
229
+ /** @throws {TerminalError} when a tool result is too big to hand to the model or record. */
230
+ function assertOutputSize(toolName, value, max) {
231
+ const bytes = jsonBytes(value)
232
+ if (bytes > max) {
233
+ throw new TerminalError(
234
+ `Tool "${toolName}" returned ${bytes} bytes, over the limit of ${max}. Ask for less, or a part of it.`,
235
+ { errorCode: 413 },
236
+ )
237
+ }
238
+ }
239
+
180
240
  /**
181
241
  * Asks for approval through an awakeable and waits for the decision, durably.
182
242
  * @param {import('@restatedev/restate-sdk').Context} ctx
@@ -0,0 +1,92 @@
1
+ import { TerminalError } from '@restatedev/restate-sdk'
2
+
3
+ /**
4
+ * Test doubles for agent code, shipped as `@empyria/restate/testing`. No dependency on the AI SDK.
5
+ *
6
+ * A stand-in for Restate's `Context` / `ObjectContext`. Per this repo's testing convention,
7
+ * handlers are unit-tested against a mocked `ctx`, never a live Restate server.
8
+ *
9
+ * `run` mimics the parts of the real one the agent depends on: the result goes through a
10
+ * JSON round trip (as it does through the journal), and when a bounded `ctx.run` gives up
11
+ * it throws a `TerminalError` wrapping the original message. One attempt stands in for
12
+ * "all retries exhausted".
13
+ */
14
+ export function fakeCtx({ key, state = {}, onCall, onApproval } = {}) {
15
+ const steps = []
16
+ const calls = []
17
+ const sends = []
18
+ const logs = []
19
+ return {
20
+ key,
21
+ logs,
22
+ console: { log: (...args) => logs.push(args.join(' ')) },
23
+ state,
24
+ steps,
25
+ calls,
26
+ sends,
27
+ run: async (name, fn, options) => {
28
+ const step = { name, options }
29
+ steps.push(step)
30
+ let value
31
+ try {
32
+ value = await fn()
33
+ } catch (error) {
34
+ if (error instanceof TerminalError || !options?.maxRetryAttempts) throw error
35
+ throw new TerminalError(error.message)
36
+ }
37
+ step.result = value === undefined ? undefined : JSON.parse(JSON.stringify(value))
38
+ return step.result
39
+ },
40
+ request: () => ({ id: 'inv-1' }),
41
+ genericCall: async (call) => {
42
+ calls.push(call)
43
+ return onCall ? onCall(call) : null
44
+ },
45
+ genericSend: (send) => {
46
+ sends.push(send)
47
+ },
48
+ awakeable: () => ({
49
+ id: 'awk-1',
50
+ promise: { orTimeout: async (timeout) => onApproval?.(timeout) },
51
+ }),
52
+ get: async (name) => state[name],
53
+ set: (name, value) => {
54
+ state[name] = value
55
+ },
56
+ clear: (name) => {
57
+ delete state[name]
58
+ },
59
+ }
60
+ }
61
+
62
+ const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } }
63
+
64
+ export function textResult(text) {
65
+ return {
66
+ content: [{ type: 'text', text }],
67
+ finishReason: { unified: 'stop', raw: 'stop' },
68
+ usage,
69
+ warnings: [],
70
+ }
71
+ }
72
+
73
+ /** @param {Array<{toolName: string, input: object}>} calls */
74
+ export function toolCallResult(calls) {
75
+ return {
76
+ content: calls.map(({ toolName, input }, i) => ({
77
+ type: 'tool-call',
78
+ toolCallId: `call_${i}`,
79
+ toolName,
80
+ input: JSON.stringify(input),
81
+ })),
82
+ finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
83
+ usage,
84
+ warnings: [],
85
+ }
86
+ }
87
+
88
+ /** `doGenerate` that returns `results` one after another. */
89
+ export function sequence(...results) {
90
+ let i = 0
91
+ return async () => results[Math.min(i++, results.length - 1)]
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.2.2",
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>",
@@ -13,6 +13,7 @@
13
13
  "exports": {
14
14
  ".": "./index.js",
15
15
  "./agent": "./agent.js",
16
+ "./testing": "./testing.js",
16
17
  "./lib/*": "./lib/*.js",
17
18
  "./package.json": "./package.json"
18
19
  },
@@ -0,0 +1,16 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import * as testing from '../testing.js'
3
+
4
+ describe('@empyria/restate/testing', () => {
5
+ test('exports the fakes agent tests need, without loading the AI SDK', () => {
6
+ expect(Object.keys(testing).sort()).toEqual([
7
+ 'fakeCtx',
8
+ 'sequence',
9
+ 'textResult',
10
+ 'toolCallResult',
11
+ ])
12
+ const ctx = testing.fakeCtx()
13
+ ctx.console.log('a', 'b')
14
+ expect(ctx.logs).toEqual(['a b'])
15
+ })
16
+ })
@@ -193,3 +193,164 @@ describe('defineAgent: session memory', () => {
193
193
  expect(journaled).not.toContain('first question')
194
194
  })
195
195
  })
196
+
197
+ describe('defineAgent: tools that depend on the request', () => {
198
+ const ctxInput = {
199
+ type: 'object',
200
+ properties: { prompt: { type: 'string', minLength: 1 }, folder: { type: 'string' } },
201
+ required: ['prompt', 'folder'],
202
+ }
203
+
204
+ test('a tools function receives the validated request and its tools are used', async () => {
205
+ const seen = []
206
+ const model = new MockLanguageModelV4({
207
+ doGenerate: sequence(
208
+ toolCallResult([{ toolName: 'where', input: {} }]),
209
+ textResult('ok'),
210
+ ),
211
+ })
212
+ const agent = defineAgent({
213
+ name: 'A',
214
+ model,
215
+ input: ctxInput,
216
+ tools: (request) => {
217
+ seen.push(request.folder)
218
+ return {
219
+ where: {
220
+ description: 'Where am I',
221
+ inputSchema: { type: 'object' },
222
+ execute: async () => ({ folder: request.folder }),
223
+ },
224
+ }
225
+ },
226
+ })
227
+ const ctx = fakeCtx()
228
+ await agent.service.ask(ctx, { prompt: 'hi', folder: '/f' })
229
+ expect(seen.length).toBeGreaterThan(0)
230
+ expect(new Set(seen)).toEqual(new Set(['/f']))
231
+ expect(ctx.steps.find((s) => s.name.startsWith('tool:where')).result).toEqual({
232
+ folder: '/f',
233
+ })
234
+ })
235
+
236
+ test('an invalid tools map for one request fails that request terminally (500)', async () => {
237
+ const agent = defineAgent({
238
+ name: 'A',
239
+ model: okModel(),
240
+ tools: () => ({ load_skill: { description: 'x', inputSchema: {} } }),
241
+ })
242
+ const error = await agent.service.ask(fakeCtx(), { prompt: 'hi' }).catch((e) => e)
243
+ expect(error).toBeInstanceOf(TerminalError)
244
+ expect(error.code).toBe(500)
245
+ })
246
+ })
247
+
248
+ describe('defineAgent: respond and responseSchema', () => {
249
+ test('respond shapes the return value from the loop result', async () => {
250
+ const agent = defineAgent({
251
+ name: 'A',
252
+ model: okModel(),
253
+ respond: (r, input) => ({
254
+ answer: r.text,
255
+ steps: r.steps,
256
+ trace: r.trace.length,
257
+ echoed: input.prompt,
258
+ }),
259
+ responseSchema: { type: 'object', required: ['answer'] },
260
+ })
261
+ expect(await agent.service.ask(fakeCtx(), { prompt: 'hi' })).toEqual({
262
+ answer: 'ok',
263
+ steps: 1,
264
+ trace: 1,
265
+ echoed: 'hi',
266
+ })
267
+ })
268
+
269
+ test('a response that breaks responseSchema is terminal (500)', async () => {
270
+ const agent = defineAgent({
271
+ name: 'A',
272
+ model: okModel(),
273
+ respond: () => ({ nope: true }),
274
+ responseSchema: { type: 'object', required: ['answer'] },
275
+ })
276
+ const error = await agent.service.ask(fakeCtx(), { prompt: 'hi' }).catch((e) => e)
277
+ expect(error).toBeInstanceOf(TerminalError)
278
+ expect(error.code).toBe(500)
279
+ })
280
+
281
+ test('without respond the default result is unchanged', async () => {
282
+ expect(
283
+ await defineAgent({ name: 'A', model: okModel() }).service.ask(fakeCtx(), {
284
+ prompt: 'hi',
285
+ }),
286
+ ).toEqual({ text: 'ok', steps: 1 })
287
+ })
288
+
289
+ test('rejects respond or label that is not a function', () => {
290
+ expect(() => defineAgent({ name: 'A', model: okModel(), respond: 1 })).toThrow(TypeError)
291
+ expect(() => defineAgent({ name: 'A', model: okModel(), label: 'x' })).toThrow(TypeError)
292
+ })
293
+ })
294
+
295
+ describe('defineAgent: label', () => {
296
+ test('prefixes a terminal failure with the label and keeps its code', async () => {
297
+ const model = new MockLanguageModelV4({
298
+ doGenerate: toolCallResult([{ toolName: 'nope', input: {} }]),
299
+ })
300
+ const agent = defineAgent({
301
+ name: 'A',
302
+ model,
303
+ limits: { maxSteps: 2 },
304
+ label: (input) => `job ${input.prompt}`,
305
+ })
306
+ const error = await agent.service.ask(fakeCtx(), { prompt: 'J1' }).catch((e) => e)
307
+ expect(error.constructor).toBe(TerminalError)
308
+ expect(error.message).toStartWith('[job J1] ')
309
+ expect(error.message).toContain('maxSteps')
310
+ })
311
+
312
+ test('leaves cancellations and non-terminal errors untouched', async () => {
313
+ const { CancelledError } = await import('@restatedev/restate-sdk')
314
+ const cancelled = new CancelledError()
315
+ const plain = new Error('transient')
316
+ for (const thrown of [cancelled, plain]) {
317
+ const model = new MockLanguageModelV4({
318
+ doGenerate: () => {
319
+ throw thrown
320
+ },
321
+ })
322
+ const agent = defineAgent({ name: 'A', model, label: () => 'L' })
323
+ const ctx = fakeCtx()
324
+ ctx.run = async () => {
325
+ throw thrown
326
+ }
327
+ expect(await agent.service.ask(ctx, { prompt: 'hi' }).catch((e) => e)).toBe(thrown)
328
+ }
329
+ })
330
+ })
331
+
332
+ describe('defineAgent: request size cap', () => {
333
+ test('refuses a request over limits.maxInputBytes (413), labelled when a label is set', async () => {
334
+ const agent = defineAgent({
335
+ name: 'A',
336
+ model: okModel(),
337
+ limits: { maxInputBytes: 50 },
338
+ label: () => 'L',
339
+ })
340
+ const error = await agent.service
341
+ .ask(fakeCtx(), { prompt: 'x'.repeat(200) })
342
+ .catch((e) => e)
343
+ expect(error).toBeInstanceOf(TerminalError)
344
+ expect(error.code).toBe(413)
345
+ expect(error.message).toStartWith('[L] ')
346
+ })
347
+
348
+ test('has a generous default', async () => {
349
+ const agent = defineAgent({ name: 'A', model: okModel() })
350
+ expect((await agent.service.ask(fakeCtx(), { prompt: 'x'.repeat(10_000) })).text).toBe('ok')
351
+ const error = await agent.service
352
+ .ask(fakeCtx(), { prompt: 'x'.repeat(70_000) })
353
+ .catch((e) => e)
354
+ expect(error.code).toBe(413)
355
+ })
356
+ })
@@ -171,3 +171,50 @@ describe('runAgentLoop', () => {
171
171
  expect(JSON.stringify(result.messages)).not.toContain('EARLIER TURN')
172
172
  })
173
173
  })
174
+
175
+ describe('runAgentLoop: step metadata and trace', () => {
176
+ test('journals small per-step facts, returns a trace and logs one line per step', async () => {
177
+ const ctx = fakeCtx()
178
+ const model = new MockLanguageModelV4({
179
+ doGenerate: sequence(
180
+ toolCallResult([{ toolName: 'echo', input: { v: 'a' } }]),
181
+ textResult('done'),
182
+ ),
183
+ })
184
+
185
+ const result = await runAgentLoop(ctx, {
186
+ model,
187
+ system: '',
188
+ prompt: 'go',
189
+ tools: { echo: echoTool },
190
+ })
191
+
192
+ const meta = ctx.steps[0].result.meta
193
+ expect(meta).toMatchObject({ model: 'mock-model-id', finishReason: 'tool-calls' })
194
+ expect(meta.durationMs).toBeGreaterThanOrEqual(0)
195
+ expect(result.trace).toHaveLength(2)
196
+ expect(result.trace[0]).toMatchObject({
197
+ step: 0,
198
+ tools: ['echo'],
199
+ finishReason: 'tool-calls',
200
+ })
201
+ expect(result.trace[1]).toMatchObject({ step: 1, tools: [], finishReason: 'stop' })
202
+ expect(ctx.logs).toHaveLength(2)
203
+ expect(ctx.logs[0]).toContain('llm-step-0')
204
+ expect(ctx.logs[0]).toContain('tools=[echo]')
205
+ })
206
+
207
+ test('the journaled meta carries no prompt or reply text', async () => {
208
+ const ctx = fakeCtx()
209
+ const model = new MockLanguageModelV4({ doGenerate: textResult('secret reply') })
210
+ await runAgentLoop(ctx, { model, system: 'secret system', prompt: 'secret prompt' })
211
+ const meta = JSON.stringify(ctx.steps[0].result.meta)
212
+ expect(meta).not.toContain('secret')
213
+ })
214
+
215
+ test('works with a context that has no console', async () => {
216
+ const ctx = { ...fakeCtx(), console: undefined }
217
+ const model = new MockLanguageModelV4({ doGenerate: textResult('hi') })
218
+ expect((await runAgentLoop(ctx, { model, system: '', prompt: 'x' })).text).toBe('hi')
219
+ })
220
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { TerminalError } from '@restatedev/restate-sdk'
3
+ import { createModel, readLlmEnv } from '../../lib/agent/Model.js'
4
+
5
+ const ok = { LLM_BASE_URL: 'https://llm.example/v1', MODEL_ID: 'm', LLM_API_KEY: 'sk-secret-value' }
6
+
7
+ describe('readLlmEnv', () => {
8
+ test('returns just the three LLM settings', () => {
9
+ expect(readLlmEnv({ ...ok, PATH: '/bin', OTHER: 'x' })).toEqual(ok)
10
+ })
11
+
12
+ test('the key is optional', () => {
13
+ const { LLM_API_KEY: _, ...noKey } = ok
14
+ expect(readLlmEnv(noKey)).toEqual(noKey)
15
+ })
16
+
17
+ test('empty strings count as missing', () => {
18
+ expect(() => readLlmEnv({ ...ok, MODEL_ID: '' })).toThrow(TerminalError)
19
+ })
20
+
21
+ test('a missing setting is a terminal 500 that names it and never prints a value', () => {
22
+ const error = (() => {
23
+ try {
24
+ readLlmEnv({
25
+ LLM_BASE_URL: 'https://llm.example/v1',
26
+ LLM_API_KEY: 'sk-secret-value',
27
+ })
28
+ } catch (e) {
29
+ return e
30
+ }
31
+ })()
32
+ expect(error).toBeInstanceOf(TerminalError)
33
+ expect(error.code).toBe(500)
34
+ expect(error.message).toContain('MODEL_ID')
35
+ expect(error.message).not.toContain('sk-secret-value')
36
+ expect(error.message).not.toContain('llm.example')
37
+ })
38
+
39
+ test('the result feeds createModel', () => {
40
+ expect(createModel(readLlmEnv(ok)).modelId).toBe('m')
41
+ })
42
+ })
@@ -275,3 +275,123 @@ describe('executeTool', () => {
275
275
  })
276
276
  })
277
277
  })
278
+
279
+ describe('tool size caps', () => {
280
+ const big = (n) => 'x'.repeat(n)
281
+ const base = { description: 'd', inputSchema: schema }
282
+
283
+ test('assertToolSpecs rejects non-positive caps', () => {
284
+ for (const cap of [0, -1, 1.5, '10']) {
285
+ expect(() =>
286
+ assertToolSpecs({ t: { ...base, execute: async () => 1, maxOutputBytes: cap } }),
287
+ ).toThrow(TypeError)
288
+ expect(() =>
289
+ assertToolSpecs({ t: { ...base, execute: async () => 1, maxInputBytes: cap } }),
290
+ ).toThrow(TypeError)
291
+ }
292
+ })
293
+
294
+ test('oversized arguments: the tool is not run and the model is told', async () => {
295
+ const ctx = fakeCtx()
296
+ let ran = false
297
+ const tools = { t: { ...base, execute: async () => ((ran = true), 1), maxInputBytes: 100 } }
298
+ const output = await executeTool(ctx, call('t', { v: big(500) }), { tools, ...at })
299
+ expect(ran).toBe(false)
300
+ expect(ctx.steps).toHaveLength(0)
301
+ expect(output.type).toBe('error-text')
302
+ expect(output.value).toContain('over the limit of 100')
303
+ })
304
+
305
+ test('default input cap applies when the tool sets none', async () => {
306
+ const tools = { t: { ...base, execute: async () => 1 } }
307
+ const output = await executeTool(fakeCtx(), call('t', { v: big(20_000) }), { tools, ...at })
308
+ expect(output.type).toBe('error-text')
309
+ })
310
+
311
+ test('oversized result of an execute tool: error to the model, nothing big journaled', async () => {
312
+ const ctx = fakeCtx()
313
+ const tools = {
314
+ t: { ...base, execute: async () => ({ v: big(500) }), maxOutputBytes: 100 },
315
+ }
316
+ const output = await executeTool(ctx, call('t'), { tools, ...at })
317
+ expect(output.type).toBe('error-text')
318
+ expect(output.value).toContain('over the limit of 100')
319
+ expect(JSON.stringify(ctx.steps)).not.toContain('xxxxxxxxxx')
320
+ })
321
+
322
+ test('oversized result of a block tool never reaches the model', async () => {
323
+ const ctx = fakeCtx({ onCall: () => ({ v: big(500) }) })
324
+ const tools = { t: { ...base, block: 'Svc', handler: 'h', maxOutputBytes: 100 } }
325
+ const output = await executeTool(ctx, call('t'), { tools, ...at })
326
+ expect(output.type).toBe('error-text')
327
+ })
328
+
329
+ test('results within the cap pass through', async () => {
330
+ const tools = { t: { ...base, execute: async () => ({ ok: true }), maxOutputBytes: 100 } }
331
+ expect(await executeTool(fakeCtx(), call('t'), { tools, ...at })).toEqual({
332
+ type: 'json',
333
+ value: { ok: true },
334
+ })
335
+ })
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
+ })
@@ -1,84 +1,3 @@
1
- import { TerminalError } from '@restatedev/restate-sdk'
2
-
3
- /**
4
- * A stand-in for Restate's `Context` / `ObjectContext`. Per this repo's testing convention,
5
- * handlers are unit-tested against a mocked `ctx`, never a live Restate server.
6
- *
7
- * `run` mimics the parts of the real one the agent depends on: the result goes through a
8
- * JSON round trip (as it does through the journal), and when a bounded `ctx.run` gives up
9
- * it throws a `TerminalError` wrapping the original message. One attempt stands in for
10
- * "all retries exhausted".
11
- */
12
- export function fakeCtx({ key, state = {}, onCall, onApproval } = {}) {
13
- const steps = []
14
- const calls = []
15
- const sends = []
16
- return {
17
- key,
18
- state,
19
- steps,
20
- calls,
21
- sends,
22
- run: async (name, fn, options) => {
23
- const step = { name, options }
24
- steps.push(step)
25
- let value
26
- try {
27
- value = await fn()
28
- } catch (error) {
29
- if (error instanceof TerminalError || !options?.maxRetryAttempts) throw error
30
- throw new TerminalError(error.message)
31
- }
32
- step.result = value === undefined ? undefined : JSON.parse(JSON.stringify(value))
33
- return step.result
34
- },
35
- request: () => ({ id: 'inv-1' }),
36
- genericCall: async (call) => {
37
- calls.push(call)
38
- return onCall ? onCall(call) : null
39
- },
40
- genericSend: (send) => {
41
- sends.push(send)
42
- },
43
- awakeable: () => ({
44
- id: 'awk-1',
45
- promise: { orTimeout: async (timeout) => onApproval?.(timeout) },
46
- }),
47
- get: async (name) => state[name],
48
- set: (name, value) => {
49
- state[name] = value
50
- },
51
- clear: (name) => {
52
- delete state[name]
53
- },
54
- }
55
- }
56
-
57
- const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } }
58
-
59
- export function textResult(text) {
60
- return { content: [{ type: 'text', text }], finishReason: 'stop', usage, warnings: [] }
61
- }
62
-
63
- /** @param {Array<{toolName: string, input: object}>} calls */
64
- export function toolCallResult(calls) {
65
- return {
66
- content: calls.map(({ toolName, input }, i) => ({
67
- type: 'tool-call',
68
- toolCallId: `call_${i}`,
69
- toolName,
70
- input: JSON.stringify(input),
71
- })),
72
- finishReason: 'tool-calls',
73
- usage,
74
- warnings: [],
75
- }
76
- }
77
-
78
- /** `doGenerate` that returns `results` one after another. */
79
- export function sequence(...results) {
80
- let i = 0
81
- return async () => results[Math.min(i++, results.length - 1)]
82
- }
1
+ export * from '../../lib/testing/Fakes.js'
83
2
 
84
3
  export const env = { LLM_BASE_URL: 'https://unused.example', MODEL_ID: 'unused' }
package/testing.js ADDED
@@ -0,0 +1 @@
1
+ export * from './lib/testing/Fakes.js'