@empyria/restate 0.4.11 → 0.4.12

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/README.md CHANGED
@@ -133,12 +133,13 @@ address, so the deployment must survive the process stopping and come back at th
133
133
 
134
134
  `defineAgent` options beyond the basics:
135
135
 
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). |
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
+ | `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`. |
140
+ | `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. |
141
+ | `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. |
142
+ | `limits.maxInputBytes` | Largest request (JSON bytes, default 64 KB); a larger one is refused terminally (413). |
142
143
 
143
144
  **Keying block tools.** A block tool calls another Restate building block. Its `key(args, meta)` and `input(args, meta)`
144
145
  hooks decide the target key and the parameter from the model's arguments and a per-call idempotency key
@@ -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.12",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -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') })