@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 +7 -6
- package/lib/agent/Agent.js +15 -6
- package/lib/agent/Loop.js +27 -8
- package/package.json +1 -1
- package/test/agent/Agent.test.js +44 -0
- package/test/agent/Loop.test.js +45 -0
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
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
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
|
package/lib/agent/Agent.js
CHANGED
|
@@ -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
|
|
72
|
-
* the `final_answer` tool
|
|
73
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
output
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
package/test/agent/Agent.test.js
CHANGED
|
@@ -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({
|
package/test/agent/Loop.test.js
CHANGED
|
@@ -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') })
|