@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.
- package/.github/dependabot.yml +4 -4
- package/AGENTS.md +5 -0
- package/README.md +16 -7
- package/lib/Admin.js +42 -8
- package/lib/agent/Agent.js +15 -6
- package/lib/agent/Loop.js +27 -8
- package/package.json +1 -1
- package/test/Admin.test.js +37 -0
- package/test/agent/Agent.test.js +44 -0
- package/test/agent/Loop.test.js +45 -0
package/.github/dependabot.yml
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
version: 2
|
|
7
7
|
updates:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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
|
-
| `
|
|
140
|
-
| `
|
|
141
|
-
| `
|
|
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 (
|
|
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({
|
|
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
|
-
*
|
|
755
|
-
*
|
|
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 }) =>
|
|
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 }),
|
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/Admin.test.js
CHANGED
|
@@ -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
|
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') })
|