@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 +22 -6
- package/README.md +36 -1
- package/lib/agent/Agent.js +92 -8
- package/lib/agent/Loop.js +31 -2
- package/lib/agent/Model.js +40 -0
- package/lib/agent/Tools.js +67 -7
- package/lib/testing/Fakes.js +92 -0
- package/package.json +2 -1
- package/test/Testing.test.js +16 -0
- package/test/agent/Agent.test.js +161 -0
- package/test/agent/Loop.test.js +47 -0
- package/test/agent/Model.test.js +42 -0
- package/test/agent/Tools.test.js +120 -0
- package/test/agent/fakes.js +1 -82
- package/testing.js +1 -0
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.
|
|
8
|
-
touching version pins; the SDK's own version numbering runs well ahead of the
|
|
9
|
-
|
|
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`
|
|
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
|
|
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.
|
|
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
|
package/lib/agent/Agent.js
CHANGED
|
@@ -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
|
|
165
|
+
const outputSchema = respond ? (responseSchema ?? {}) : (output ?? AskOutputSchema)
|
|
132
166
|
|
|
133
|
-
const ask = withValidation(input,
|
|
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
|
}
|
package/lib/agent/Model.js
CHANGED
|
@@ -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`
|
package/lib/agent/Tools.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
161
|
-
|
|
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.
|
|
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
|
+
})
|
package/test/agent/Agent.test.js
CHANGED
|
@@ -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
|
+
})
|
package/test/agent/Loop.test.js
CHANGED
|
@@ -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
|
+
})
|
package/test/agent/Tools.test.js
CHANGED
|
@@ -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
|
+
})
|
package/test/agent/fakes.js
CHANGED
|
@@ -1,84 +1,3 @@
|
|
|
1
|
-
|
|
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'
|