@empyria/restate 0.4.11 → 0.4.13

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.
@@ -5,7 +5,7 @@
5
5
 
6
6
  version: 2
7
7
  updates:
8
- - package-ecosystem: "" # See documentation for possible values
9
- directory: "/" # Location of package manifests
10
- schedule:
11
- interval: "daily"
8
+ - package-ecosystem: '' # See documentation for possible values
9
+ directory: '/' # Location of package manifests
10
+ schedule:
11
+ interval: 'daily'
package/AGENTS.md CHANGED
@@ -74,6 +74,11 @@ Attempted to assign to readonly property`). Use `mock.module('@restatedev/restat
74
74
  any mocking) if you need to restore it — reading it back off the live `clients` binding later
75
75
  may observe an already-mocked value. See `test/Admin.test.js`.
76
76
 
77
+ `setupRestate`'s shutdown decision is `shouldDeleteOnShutdown` (unit tested); `keepDeploymentOnShutdown` and
78
+ `forceRegistration` exist because invocations are pinned to their deployment: Restate 1.7 keeps the deployment ID when the
79
+ same address registers again, but only `force: true` updates its handler list. Check both against a live server when
80
+ touching them.
81
+
77
82
  `setupRestate` (in `lib/Admin.js`) is not unit tested: it binds real HTTP/2 + health-check
78
83
  ports, installs process-wide `SIGTERM`/`SIGINT` handlers, and its returned `forceClose` calls
79
84
  `process.exit()` — none of which are safe to exercise in-process in a test run.
package/README.md CHANGED
@@ -124,7 +124,15 @@ Cron expressions are `croner` patterns (five fields, or six with seconds for tes
124
124
 
125
125
  ### An endpoint that does not register itself
126
126
 
127
- `setupRestate` serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. When something else owns
127
+ `setupRestate` serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. Invocations in flight are
128
+ pinned to their deployment, so deleting it strands them: a restarted process cannot finish them and they can only be
129
+ killed. For a process restarted at the same address, pass `keepDeploymentOnShutdown: true` and `forceRegistration: true`:
130
+ Restate keeps the deployment ID when the same address registers again (checked against Restate 1.7), running
131
+ invocations resume on the new process, and `force` updates the services and handlers in place (a plain
132
+ re-registration keeps the old handler list). Only compatible changes are safe this way: an invocation of a removed or
133
+ renamed handler cannot resume.
134
+
135
+ When something else owns
128
136
  registration (a supervisor that registers a process's address once and restarts the process), use
129
137
  `serveEndpoint({port, services})` instead: it only serves. Invocations already running on a deployment are pinned to its
130
138
  address, so the deployment must survive the process stopping and come back at the same address.
@@ -133,12 +141,13 @@ address, so the deployment must survive the process stopping and come back at th
133
141
 
134
142
  `defineAgent` options beyond the basics:
135
143
 
136
- | Option | Does |
137
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
138
- | `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). |
139
- | `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. |
140
- | `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. |
141
- | `limits.maxInputBytes` | Largest request (JSON bytes, default 64 KB); a larger one is refused terminally (413). |
144
+ | Option | Does |
145
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | `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). |
147
+ | `output` as a function | `output: (input) => schema \| undefined` chooses the answer's JSON Schema per request (same rules as `tools`: pure, no I/O); `undefined` means a free-text answer for that request. With any `output`, the model's answer is checked against the schema: a wrong one is handed back to the model to correct, and a model that never gets it right ends at `maxSteps`. |
148
+ | `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. |
149
+ | `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. |
150
+ | `limits.maxInputBytes` | Largest request (JSON bytes, default 64 KB); a larger one is refused terminally (413). |
142
151
 
143
152
  **Keying block tools.** A block tool calls another Restate building block. Its `key(args, meta)` and `input(args, meta)`
144
153
  hooks decide the target key and the parameter from the model's arguments and a per-call idempotency key
package/lib/Admin.js CHANGED
@@ -581,7 +581,15 @@ export function defineObject(def) {
581
581
  /**
582
582
  * Starts the HTTP/2 Restate endpoint, a health-check HTTP server, registers
583
583
  * the deployment with the admin API, and wires SIGTERM/SIGINT for graceful
584
- * shutdown (deregistering the deployment before exiting).
584
+ * shutdown (deregistering the deployment before exiting, unless `keepDeploymentOnShutdown`).
585
+ *
586
+ * Invocations in flight are pinned to their deployment. Deleting it on shutdown means a restarted process cannot
587
+ * finish them, and they cannot even be cancelled (only killed). For a process that is restarted at the same address
588
+ * (a dev loop, a service that is redeployed in place), pass `keepDeploymentOnShutdown: true` and
589
+ * `forceRegistration: true`: Restate keeps the deployment ID when the same address registers again, so running
590
+ * invocations resume on the new process, and `force` updates the deployment's services and handlers in place (a plain
591
+ * re-registration keeps the old handler list). Only compatible changes are safe that way: an invocation of a handler
592
+ * that was removed or renamed cannot resume.
585
593
  * @param {Object} params
586
594
  * @param {string} params.restateAdminURL
587
595
  * @param {string} params.host
@@ -590,6 +598,9 @@ export function defineObject(def) {
590
598
  * @param {Parameters<typeof restate.createEndpointHandler>[0]['services']} [params.services]
591
599
  * @param {() => void} [params.shutdownCallbackFn]
592
600
  * @param {(deploymentId: string) => void} [params.registrationCallbackFn]
601
+ * @param {boolean} [params.keepDeploymentOnShutdown=false] Leave the deployment registered on SIGINT/SIGTERM.
602
+ * @param {boolean} [params.forceRegistration=false] Register with `force: true`, updating an existing deployment at
603
+ * the same address in place.
593
604
  * @returns {Promise<RestateServer>}
594
605
  */
595
606
  export async function setupRestate({
@@ -600,6 +611,8 @@ export async function setupRestate({
600
611
  services = [],
601
612
  shutdownCallbackFn,
602
613
  registrationCallbackFn,
614
+ keepDeploymentOnShutdown = false,
615
+ forceRegistration = false,
603
616
  }) {
604
617
  const handler = restate.createEndpointHandler({ services })
605
618
 
@@ -647,8 +660,12 @@ export async function setupRestate({
647
660
  isHealthy = false
648
661
 
649
662
  try {
650
- if (deploymentId && deploymentId !== 'NA') {
663
+ if (shouldDeleteOnShutdown({ deploymentId, keepDeploymentOnShutdown })) {
651
664
  await deleteDeployment(restateAdminURL, deploymentId)
665
+ } else if (keepDeploymentOnShutdown && deploymentId) {
666
+ console.log(
667
+ `Keeping deployment ${deploymentId}: invocations in flight resume when it comes back`,
668
+ )
652
669
  }
653
670
  } catch (err) {
654
671
  console.error(err)
@@ -688,7 +705,11 @@ export async function setupRestate({
688
705
 
689
706
  for (let attempt = 1; attempt <= attempts; attempt++) {
690
707
  try {
691
- const result = await registerDeployment({ restateAdminURL, serviceURL })
708
+ const result = await registerDeployment({
709
+ restateAdminURL,
710
+ serviceURL,
711
+ force: forceRegistration,
712
+ })
692
713
 
693
714
  try {
694
715
  registrationCallbackFn?.(result.id)
@@ -751,15 +772,27 @@ export async function setupRestate({
751
772
  }
752
773
 
753
774
  /**
754
- * POSTs this process's HTTP endpoint to the Restate admin API so its services are discoverable.
755
- * @param {{restateAdminURL: string, serviceURL: string}} params
775
+ * Whether `setupRestate`'s shutdown removes the deployment: only a real one (registered, not `'NA'`), and not when the
776
+ * caller keeps it for the next start.
777
+ * @param {{deploymentId: string|undefined, keepDeploymentOnShutdown?: boolean}} params
778
+ * @returns {boolean}
779
+ */
780
+ export function shouldDeleteOnShutdown({ deploymentId, keepDeploymentOnShutdown = false }) {
781
+ return Boolean(deploymentId) && deploymentId !== 'NA' && !keepDeploymentOnShutdown
782
+ }
783
+
784
+ /**
785
+ * POSTs this process's HTTP endpoint to the Restate admin API so its services are discoverable. Registering an
786
+ * address that is already registered returns the same deployment; with `force` its services and handlers are
787
+ * updated in place (without it, Restate keeps the ones it knew).
788
+ * @param {{restateAdminURL: string, serviceURL: string, force?: boolean}} params
756
789
  * @returns {Promise<RegisterDeploymentResponse>}
757
790
  */
758
- export async function registerDeployment({ restateAdminURL, serviceURL }) {
791
+ export async function registerDeployment({ restateAdminURL, serviceURL, force = false }) {
759
792
  const uri = serviceURL
760
793
 
761
794
  /** @type {RegisterDeploymentRequest} */
762
- const body = { uri, force: null }
795
+ const body = { uri, force: force ? true : null }
763
796
 
764
797
  console.log(`Registering: ${uri} at: ${restateAdminURL}/deployments`)
765
798
 
@@ -872,7 +905,8 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
872
905
  pollInvocation: ({ invocationId, intervalMs }) =>
873
906
  pollInvocation({ restateURL, invocationId, intervalMs }),
874
907
 
875
- getInvocationOutput: ({ invocationId }) => getInvocationOutput({ restateURL, invocationId }),
908
+ getInvocationOutput: ({ invocationId }) =>
909
+ getInvocationOutput({ restateURL, invocationId }),
876
910
 
877
911
  sendMessage: ({ name, message, key, payload, idempotencyKey }) =>
878
912
  sendMessage({ restateURL, name, message, key, payload, idempotencyKey }),
@@ -68,9 +68,11 @@ export const AskOutputSchema = {
68
68
  * @property {object} [input] Input JSON Schema. Defaults to {@link AskInputSchema}.
69
69
  * @property {(input: any) => string} [prompt] Builds the user prompt from the validated
70
70
  * input. Defaults to `input.prompt`.
71
- * @property {object} [output] Output JSON Schema. When given, the model must answer through
72
- * the `final_answer` tool and `ask` returns its arguments. Defaults to
73
- * {@link AskOutputSchema} (`{text, steps}`).
71
+ * @property {object | ((input: any) => object | undefined)} [output] Output JSON Schema. When given, the model
72
+ * must answer through the `final_answer` tool; its arguments are checked against the schema (a wrong answer is
73
+ * handed back to the model to correct) and `ask` returns them. A function chooses the schema per request, like
74
+ * `tools`: it runs on every execution and replay, so it must be pure and do no I/O; `undefined` means a free-text
75
+ * answer for that request. Defaults to {@link AskOutputSchema} (`{text, steps}`).
74
76
  * @property {(result: {text: string, output?: unknown, steps: number, totalTokens: number, trace: Array<object>}, input: any) => any} [respond]
75
77
  * Shapes what `ask` returns from the loop's result (`output` is set when an `output` schema is
76
78
  * given; `trace` is the per-step summary). Without it, `ask` returns `output`, or `{text, steps}`.
@@ -162,7 +164,12 @@ export function defineAgent(spec) {
162
164
  maxInputBytes = DEFAULT_MAX_INPUT_BYTES,
163
165
  } = limits
164
166
 
165
- const outputSchema = respond ? (responseSchema ?? {}) : (output ?? AskOutputSchema)
167
+ // A per-request `output` cannot be known here; the loop checks the answer against the schema it was given.
168
+ const outputSchema = respond
169
+ ? (responseSchema ?? {})
170
+ : typeof output === 'function'
171
+ ? {}
172
+ : (output ?? AskOutputSchema)
166
173
 
167
174
  const ask = withValidation(input, outputSchema, async (ctx, validatedInput) => {
168
175
  const inputBytes = jsonBytes(validatedInput)
@@ -193,6 +200,8 @@ export function defineAgent(spec) {
193
200
  const requestTools =
194
201
  typeof tools === 'function' ? await resolveTools(name, tools, validatedInput) : tools
195
202
 
203
+ const requestOutput = typeof output === 'function' ? output(validatedInput) : output
204
+
196
205
  const context = loadContext
197
206
  ? await ctx.run('load-context', async () => {
198
207
  const { systemPrompt = '', skills = [] } =
@@ -217,7 +226,7 @@ export function defineAgent(spec) {
217
226
  skills: context.skills,
218
227
  loadSkill: skillLoader({ loadSkill, loadContext, input: validatedInput }),
219
228
  loadHistory: session?.load,
220
- outputSchema: output,
229
+ outputSchema: requestOutput,
221
230
  maxSteps,
222
231
  maxTokens,
223
232
  retry: llmRetry,
@@ -226,7 +235,7 @@ export function defineAgent(spec) {
226
235
  if (session) await session.save(result.messages, maxHistoryTurns)
227
236
 
228
237
  if (respond) return respond(result, validatedInput)
229
- return output ? result.output : { text: result.text, steps: result.steps }
238
+ return requestOutput ? result.output : { text: result.text, steps: result.steps }
230
239
  }
231
240
 
232
241
  const options = { retryPolicy }
package/lib/agent/Loop.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import { generateText } from 'ai'
2
2
  import { TerminalError } from '@restatedev/restate-sdk'
3
+ import { createValidator } from '@empyria/common'
3
4
  import { DEFAULT_LLM_RETRY, toRestateLLMError } from './Errors.js'
4
5
  import { cachedSkillFetcher, expandSkillRefs } from './Skills.js'
5
6
  import { FINAL_ANSWER_TOOL, buildToolDefs, executeTool } from './Tools.js'
6
7
 
7
8
  const FINAL_ANSWER_NUDGE = `Respond by calling the ${FINAL_ANSWER_TOOL} tool with your answer.`
8
9
 
10
+ /** The text handed back to the model when its final answer does not fit the schema (kept short). */
11
+ const rejection = (message) =>
12
+ `Your answer was not accepted because it does not match the required format: ${String(message).slice(0, 1500)}\nCall ${FINAL_ANSWER_TOOL} again with a corrected answer.`
13
+
9
14
  /**
10
15
  * Drives an AI SDK Core tool-calling loop ONE round at a time, from inside a Restate
11
16
  * handler — deliberately NOT `generateText`'s own multi-step loop wrapped in a single
@@ -36,7 +41,8 @@ const FINAL_ANSWER_NUDGE = `Respond by calling the ${FINAL_ANSWER_TOOL} tool wit
36
41
  * retry?: import('@restatedev/restate-sdk').RunOptions<any>,
37
42
  * }} params `skills` is the index only (name + description); `loadSkill` fetches a body
38
43
  * when the model asks for it. With `outputSchema`, the model must answer through the
39
- * `final_answer` tool and the loop returns that tool's input as `output`.
44
+ * `final_answer` tool and the loop returns that tool's input as `output`, after checking it against the
45
+ * schema: an answer that does not fit is handed back to the model to correct, not accepted.
40
46
  * @returns {Promise<{text: string, output?: unknown, steps: number, totalTokens: number, trace: Array<StepTrace>, messages: Array<object>}>}
41
47
  * `messages` are the new messages of this turn only (prompt, responses, tool results).
42
48
  * `trace` has one small entry per LLM step. The same facts are recorded in the journal with each
@@ -72,6 +78,7 @@ export async function runAgentLoop(
72
78
  const messages = [{ role: 'user', content: prompt }]
73
79
  let totalTokens = 0
74
80
  const fetchSkill = cachedSkillFetcher(loadSkill)
81
+ const validateOutput = outputSchema ? createValidator(outputSchema) : undefined
75
82
  const trace = []
76
83
 
77
84
  for (let step = 0; step < maxSteps; step++) {
@@ -149,14 +156,26 @@ export async function runAgentLoop(
149
156
 
150
157
  for (const [index, call] of result.toolCalls.entries()) {
151
158
  if (outputSchema && call.toolName === FINAL_ANSWER_TOOL) {
152
- return {
153
- text: result.text,
154
- output: call.input,
155
- steps: step + 1,
156
- totalTokens,
157
- trace,
158
- messages,
159
+ let output
160
+ try {
161
+ output = validateOutput(call.input)
162
+ } catch (error) {
163
+ // The model's arguments are not checked by the provider. Tell it what is wrong and let it
164
+ // answer again; this uses a step, so a model that cannot get it right ends at maxSteps.
165
+ messages.push({
166
+ role: 'tool',
167
+ content: [
168
+ {
169
+ type: 'tool-result',
170
+ toolCallId: call.toolCallId,
171
+ toolName: call.toolName,
172
+ output: { type: 'error-text', value: rejection(error.message) },
173
+ },
174
+ ],
175
+ })
176
+ continue
159
177
  }
178
+ return { text: result.text, output, steps: step + 1, totalTokens, trace, messages }
160
179
  }
161
180
  const output = await executeTool(ctx, call, { tools, loadSkill, step, index })
162
181
  messages.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -20,6 +20,7 @@ import {
20
20
  sendMessageWithDiscovery,
21
21
  sendMessageAsyncWithDiscovery,
22
22
  registerDeployment,
23
+ shouldDeleteOnShutdown,
23
24
  sendMessageAsynSafe,
24
25
  defineWorkflow,
25
26
  defineService,
@@ -521,6 +522,42 @@ describe('registerDeployment', () => {
521
522
  registerDeployment({ restateAdminURL: 'http://admin', serviceURL: 'http://svc' }),
522
523
  ).rejects.toThrow(/bad uri/)
523
524
  })
525
+
526
+ test('sends force: null by default and force: true when asked, to the deployments endpoint', async () => {
527
+ const sent = []
528
+ globalThis.fetch = mock(async (url, init) => {
529
+ sent.push({ url, body: JSON.parse(init.body) })
530
+ return jsonResponse({ id: 'dep-1', services: [] })
531
+ })
532
+ await registerDeployment({ restateAdminURL: 'http://admin', serviceURL: 'http://svc:9080' })
533
+ await registerDeployment({
534
+ restateAdminURL: 'http://admin',
535
+ serviceURL: 'http://svc:9080',
536
+ force: true,
537
+ })
538
+ expect(sent).toEqual([
539
+ { url: 'http://admin/deployments', body: { uri: 'http://svc:9080', force: null } },
540
+ { url: 'http://admin/deployments', body: { uri: 'http://svc:9080', force: true } },
541
+ ])
542
+ })
543
+ })
544
+
545
+ describe('shouldDeleteOnShutdown', () => {
546
+ test('deletes a registered deployment by default', () => {
547
+ expect(shouldDeleteOnShutdown({ deploymentId: 'dep-1' })).toBe(true)
548
+ })
549
+
550
+ test('keeps it when asked, so invocations in flight can resume after a restart', () => {
551
+ expect(
552
+ shouldDeleteOnShutdown({ deploymentId: 'dep-1', keepDeploymentOnShutdown: true }),
553
+ ).toBe(false)
554
+ })
555
+
556
+ test('never deletes without a real deployment', () => {
557
+ for (const deploymentId of [undefined, '', 'NA']) {
558
+ expect(shouldDeleteOnShutdown({ deploymentId })).toBe(false)
559
+ }
560
+ })
524
561
  })
525
562
 
526
563
  // `clients` is a live ESM namespace binding (read-only — Object.assign/property
@@ -111,6 +111,50 @@ describe('defineAgent: ask', () => {
111
111
  await expect(answer({ score: 'high' })).rejects.toThrow(TerminalError)
112
112
  })
113
113
 
114
+ test('output can be chosen per request; undefined means a free-text answer', async () => {
115
+ const schemas = {
116
+ score: {
117
+ type: 'object',
118
+ properties: { score: { type: 'number' } },
119
+ required: ['score'],
120
+ },
121
+ name: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
122
+ }
123
+ const agent = (model) =>
124
+ defineAgent({
125
+ name: 'A',
126
+ model,
127
+ input: { type: 'object', properties: { want: { type: 'string' } } },
128
+ prompt: () => 'go',
129
+ output: ({ want }) => schemas[want],
130
+ })
131
+ const answering = (input) =>
132
+ new MockLanguageModelV4({
133
+ doGenerate: toolCallResult([{ toolName: 'final_answer', input }]),
134
+ })
135
+
136
+ expect(
137
+ await agent(answering({ score: 3 })).service.ask(fakeCtx(), { want: 'score' }),
138
+ ).toEqual({ score: 3 })
139
+ expect(
140
+ await agent(answering({ name: 'x' })).service.ask(fakeCtx(), { want: 'name' }),
141
+ ).toEqual({
142
+ name: 'x',
143
+ })
144
+ // A wrong answer for the request's schema is corrected, not accepted.
145
+ const model = new MockLanguageModelV4({
146
+ doGenerate: sequence(
147
+ toolCallResult([{ toolName: 'final_answer', input: { name: 5 } }]),
148
+ toolCallResult([{ toolName: 'final_answer', input: { name: 'ok' } }]),
149
+ ),
150
+ })
151
+ expect(await agent(model).service.ask(fakeCtx(), { want: 'name' })).toEqual({ name: 'ok' })
152
+ // No schema for this request: the plain answer.
153
+ expect(await agent(okModel()).service.ask(fakeCtx(), { want: 'none' })).toMatchObject({
154
+ steps: 1,
155
+ })
156
+ })
157
+
114
158
  test('a custom input schema with a prompt builder', async () => {
115
159
  const model = okModel()
116
160
  const agent = defineAgent({
@@ -154,6 +154,51 @@ describe('runAgentLoop', () => {
154
154
  expect(JSON.stringify(model.doGenerateCalls[1].prompt)).toContain('final_answer')
155
155
  })
156
156
 
157
+ test('an answer that does not fit the schema goes back to the model, which corrects it', async () => {
158
+ const model = new MockLanguageModelV4({
159
+ doGenerate: sequence(
160
+ toolCallResult([{ toolName: 'final_answer', input: { score: 'high' } }]),
161
+ toolCallResult([{ toolName: 'final_answer', input: { score: 7 } }]),
162
+ ),
163
+ })
164
+
165
+ const result = await runAgentLoop(fakeCtx(), {
166
+ model,
167
+ system: '',
168
+ prompt: 'rate it',
169
+ outputSchema: {
170
+ type: 'object',
171
+ properties: { score: { type: 'number' } },
172
+ required: ['score'],
173
+ },
174
+ })
175
+
176
+ expect(result).toMatchObject({ output: { score: 7 }, steps: 2 })
177
+ const second = JSON.stringify(model.doGenerateCalls[1].prompt)
178
+ expect(second).toContain('does not match the required format')
179
+ expect(second).toContain('final_answer')
180
+ })
181
+
182
+ test('a model that never gets the answer right ends at maxSteps, terminally', async () => {
183
+ const model = new MockLanguageModelV4({
184
+ doGenerate: toolCallResult([{ toolName: 'final_answer', input: { score: 'high' } }]),
185
+ })
186
+ const caught = await runAgentLoop(fakeCtx(), {
187
+ model,
188
+ system: '',
189
+ prompt: 'rate it',
190
+ maxSteps: 3,
191
+ outputSchema: {
192
+ type: 'object',
193
+ properties: { score: { type: 'number' } },
194
+ required: ['score'],
195
+ },
196
+ }).catch((e) => e)
197
+ expect(caught).toBeInstanceOf(TerminalError)
198
+ expect(caught.message).toContain('maxSteps')
199
+ expect(model.doGenerateCalls).toHaveLength(3)
200
+ })
201
+
157
202
  test('prepends loadHistory’s messages to every model call, without journaling them', async () => {
158
203
  const ctx = fakeCtx()
159
204
  const model = new MockLanguageModelV4({ doGenerate: textResult('ok') })