@frontera-sdk/functions 1.50.79 → 1.50.80
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/package.json +2 -2
- package/src/messages.ts +37 -0
- package/src/runtime-context.ts +276 -33
- package/src/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/functions",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.80",
|
|
4
4
|
"description": "Author Frontera functions: manifest, triggers and the typed handler contract.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"typescript": "^5.9.3"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@frontera-sdk/blueprint": "1.50.
|
|
45
|
+
"@frontera-sdk/blueprint": "1.50.80",
|
|
46
46
|
"cron-parser": "^5.0.6"
|
|
47
47
|
}
|
|
48
48
|
}
|
package/src/messages.ts
CHANGED
|
@@ -106,3 +106,40 @@ export function submitOutsideStepMessage(apiName: string): string {
|
|
|
106
106
|
`Wrap it: ctx.step.run('submit-${apiName}', () => ctx.action.submit({ ... }))`
|
|
107
107
|
)
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A `ctx.agent` turn that outlived the wait the author asked for.
|
|
112
|
+
*
|
|
113
|
+
* The same sentence the service writes when it gives up on the turn, so the
|
|
114
|
+
* synchronous wait, the durable wait and the durable loop's own last-resort
|
|
115
|
+
* deadline all read identically. The turn is NOT cancelled; its output still
|
|
116
|
+
* lands in the conversation linked from the run.
|
|
117
|
+
*/
|
|
118
|
+
export function agentTimeoutMessage(agentSlug: string, timeoutMs: number): string {
|
|
119
|
+
return (
|
|
120
|
+
`Agent "${agentSlug}" did not finish within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
121
|
+
'The run is still executing server-side; shorten the prompt or split the work ' +
|
|
122
|
+
'across steps.'
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A top-level `ctx.agent` call that does not match the one this run made at
|
|
128
|
+
* the same point before it last waited.
|
|
129
|
+
*
|
|
130
|
+
* Top-level code re-runs every time a durable wait resumes, and a call is
|
|
131
|
+
* matched to its earlier self by ORDER. When a read above it returned
|
|
132
|
+
* something different on the re-run (a query, an HTTP call, the clock), the
|
|
133
|
+
* n-th call is now a different call — and silently reusing the turn started
|
|
134
|
+
* for the old one would attribute its answer to the wrong record.
|
|
135
|
+
*/
|
|
136
|
+
export function agentReplayMismatchMessage(agentSlug: string, originalSlug: string, callNumber: number): string {
|
|
137
|
+
return (
|
|
138
|
+
`ctx.agent("${agentSlug}") is not the call this run made at this point before it resumed ` +
|
|
139
|
+
`(call #${callNumber} was ctx.agent("${originalSlug}") with a different prompt, files or timeoutMs). ` +
|
|
140
|
+
'Code outside ctx.step.run runs again every time the run resumes, so it must make the same ' +
|
|
141
|
+
'ctx.agent calls in the same order each time. Move reads whose results can change ' +
|
|
142
|
+
'(ctx.blueprint.query, ctx.http, the current time, random values) into ctx.step.run so ' +
|
|
143
|
+
'they are recorded once and replayed.'
|
|
144
|
+
)
|
|
145
|
+
}
|
package/src/runtime-context.ts
CHANGED
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
* network. Authors get `createTestContext`; the two runtimes get this.
|
|
13
13
|
*/
|
|
14
14
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
15
16
|
// Wording lives in the SDK, not here: the runner refusing a call before it makes
|
|
16
17
|
// it, the service's own 403, and `createTestContext` on the author's machine all
|
|
17
18
|
// have to say the same sentence — a test that fails in different words than
|
|
18
19
|
// production teaches the wrong lesson. Re-exported below because this module is
|
|
19
20
|
// where the runner's code and tests have always reached for them.
|
|
20
21
|
import {
|
|
22
|
+
agentReplayMismatchMessage,
|
|
23
|
+
agentTimeoutMessage,
|
|
21
24
|
duplicateStepMessage as duplicateStepMessageText,
|
|
22
25
|
missingGrantMessage as missingGrantMessageText,
|
|
23
26
|
submitOutsideStepMessage as submitOutsideStepMessageText,
|
|
@@ -198,8 +201,85 @@ export interface StepTools {
|
|
|
198
201
|
run<T>(id: string, fn: () => Promise<T>): Promise<unknown>
|
|
199
202
|
/** Absent on hosts that predate it — the runtime falls back to an inline wait. */
|
|
200
203
|
sleep?(id: string, ms: number): Promise<void>
|
|
204
|
+
/**
|
|
205
|
+
* Park the run until a matching event arrives or `timeout` passes, holding no
|
|
206
|
+
* connection. Resolves to the event, or null on timeout.
|
|
207
|
+
*
|
|
208
|
+
* Absent on hosts that cannot resume a run (the CLI's dev worker): `ctx.agent`
|
|
209
|
+
* then waits on one request, exactly as it did before durable waits existed.
|
|
210
|
+
*/
|
|
211
|
+
waitForEvent?(id: string, opts: { event: string; timeout: string; if: string }): Promise<unknown>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What `ctx.agent` waits when the author names no `timeoutMs`; the service's default. */
|
|
215
|
+
const AGENT_DEFAULT_TIMEOUT_MS = 120_000
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* One durable wait's length before the loop reads the turn again.
|
|
219
|
+
*
|
|
220
|
+
* A wait only matches events sent AFTER it is registered, and a turn can
|
|
221
|
+
* settle between two reads — so the loop never parks for the whole deadline on
|
|
222
|
+
* one wait. A missed wake costs at most one slice, not the call.
|
|
223
|
+
*/
|
|
224
|
+
const AGENT_WAIT_SLICE_MS = 60_000
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* How long past the author's wait the loop keeps reading before it gives up
|
|
228
|
+
* on its own.
|
|
229
|
+
*
|
|
230
|
+
* The service settles the turn AT the deadline, with the same timeout message,
|
|
231
|
+
* and that is the answer the loop normally reads. This margin only matters when
|
|
232
|
+
* the process watching the turn died: the service's minute-by-minute sweep then
|
|
233
|
+
* settles it within about ninety seconds. Past that, the loop reports the
|
|
234
|
+
* timeout itself rather than wait for an answer that is not coming.
|
|
235
|
+
*/
|
|
236
|
+
const AGENT_SETTLE_GRACE_MS = 120_000
|
|
237
|
+
|
|
238
|
+
/** The event the service sends once when a durable agent turn settles. */
|
|
239
|
+
const AGENT_TURN_FINISHED_EVENT = 'agent/turn.finished'
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Asks `/ctx/agent-run` to keep the held request alive: a 200 at once, a space
|
|
243
|
+
* every few seconds while the turn runs, then one JSON envelope —
|
|
244
|
+
* `{ ok: true, data }` or `{ ok: false, status, message }`. Without it the
|
|
245
|
+
* request is silent, and an idle limit on the way (the server's 255 s, a
|
|
246
|
+
* proxy's) drops it however long `timeoutMs` is. A service that predates the header ignores it and
|
|
247
|
+
* answers plainly; both shapes are read below.
|
|
248
|
+
*/
|
|
249
|
+
const KEEPALIVE_HEADER = 'x-ctx-keepalive'
|
|
250
|
+
|
|
251
|
+
type AgentRunOptions = { files?: ReadonlyArray<{ fileId: string }>; timeoutMs?: number }
|
|
252
|
+
|
|
253
|
+
type AgentTurnStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'parked' | 'cancelled'
|
|
254
|
+
|
|
255
|
+
/** The start step's memoized answer. `startedAt` anchors every later deadline
|
|
256
|
+
* check, so it must come from inside the step, never from a replay's clock.
|
|
257
|
+
* `fingerprint` and `slug` identify the call it was made for — see
|
|
258
|
+
* `agentFingerprint`. Absent on a start memoized before they existed. */
|
|
259
|
+
type AgentStart = ({ ok: true; invocationId: string; startedAt: number } | { ok: false; message: string }) & {
|
|
260
|
+
fingerprint?: string
|
|
261
|
+
slug?: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* What makes two `ctx.agent` calls the same call: agent, prompt, files and
|
|
266
|
+
* wait. A replay recomputes it and compares with the one its start step
|
|
267
|
+
* memoized.
|
|
268
|
+
*/
|
|
269
|
+
function agentFingerprint(slug: string, prompt: string, options: AgentRunOptions | undefined): string {
|
|
270
|
+
const files = (options?.files ?? []).map((f) => f.fileId)
|
|
271
|
+
return createHash('sha256')
|
|
272
|
+
.update(JSON.stringify([slug, prompt, files, options?.timeoutMs ?? null]))
|
|
273
|
+
.digest('hex')
|
|
201
274
|
}
|
|
202
275
|
|
|
276
|
+
/** One read step's memoized answer. `now` is the read's own clock, memoized with
|
|
277
|
+
* it, so the loop's decisions replay identically. */
|
|
278
|
+
type AgentRead =
|
|
279
|
+
| { done: true; text: string }
|
|
280
|
+
| { done: true; error: string }
|
|
281
|
+
| { done: false; now: number }
|
|
282
|
+
|
|
203
283
|
interface Deps {
|
|
204
284
|
runId: string
|
|
205
285
|
workspaceId: string
|
|
@@ -558,6 +638,192 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
558
638
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
559
639
|
}
|
|
560
640
|
|
|
641
|
+
/** What an agent call's row says about it — shared by both transports so a
|
|
642
|
+
* durable call and a synchronous one read the same in the run's trace. */
|
|
643
|
+
const agentDetail = (slug: string, prompt: string, options: AgentRunOptions | undefined) =>
|
|
644
|
+
(out: { text?: unknown } | undefined): Record<string, unknown> => ({
|
|
645
|
+
slug,
|
|
646
|
+
promptChars: prompt.length,
|
|
647
|
+
...(options?.files?.length ? { files: options.files.length } : {}),
|
|
648
|
+
// The answer itself, capped. A run whose agent step is the expensive
|
|
649
|
+
// one is read to find out WHAT the agent said, and a length alone
|
|
650
|
+
// sends the reader back to re-run the automation to learn it.
|
|
651
|
+
...(typeof out?.text === 'string' ? { textChars: out.text.length, preview: preview(out.text) } : {}),
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
/** The body both agent routes accept. */
|
|
655
|
+
const agentBody = (slug: string, prompt: string, options: AgentRunOptions | undefined) => ({
|
|
656
|
+
slug,
|
|
657
|
+
prompt,
|
|
658
|
+
// `files` hands the agent already-uploaded files by canonical id — the
|
|
659
|
+
// same `{ fileId }` a `file`-typed run input carries, so an input can be
|
|
660
|
+
// forwarded as `ctx.agent(s).run(p, { files: [ctx.input.doc] })`. The
|
|
661
|
+
// service authorizes each id against this run's workspace and stages the
|
|
662
|
+
// bytes onto the agent's computer; the agent is told the staged paths.
|
|
663
|
+
...(options?.files?.length ? { files: options.files.map((f) => ({ fileId: f.fileId })) } : {}),
|
|
664
|
+
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* `ctx.agent` on one held request — the only shape available inside an
|
|
669
|
+
* author's step body (steps do not nest) and on a host that cannot park a
|
|
670
|
+
* run. Unchanged from before durable waits existed.
|
|
671
|
+
*/
|
|
672
|
+
const agentSync = (slug: string, prompt: string, options: AgentRunOptions | undefined) =>
|
|
673
|
+
step(
|
|
674
|
+
'agent',
|
|
675
|
+
`agent:${slug}`,
|
|
676
|
+
async () => {
|
|
677
|
+
requireGrant(`agent:${slug}:run`)
|
|
678
|
+
const res = await scoped('/ctx/agent-run', {
|
|
679
|
+
method: 'POST',
|
|
680
|
+
headers: { [KEEPALIVE_HEADER]: '1' },
|
|
681
|
+
body: JSON.stringify(agentBody(slug, prompt, options)),
|
|
682
|
+
})
|
|
683
|
+
// A refusal before the turn starts (token, grant, budget, files) is
|
|
684
|
+
// still a plain non-2xx, on either shape.
|
|
685
|
+
if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)
|
|
686
|
+
// The kept-alive body is leading spaces and one JSON value; `JSON.parse`
|
|
687
|
+
// skips the whitespace. The plain body is `{ data }`.
|
|
688
|
+
const body = JSON.parse(await res.text()) as
|
|
689
|
+
| { ok: true; data: { text: string } }
|
|
690
|
+
| { ok: false; status: number; message: string }
|
|
691
|
+
| { ok?: undefined; data: { text: string } }
|
|
692
|
+
// The same words the plain route's non-2xx would have produced.
|
|
693
|
+
if (body.ok === false) throw new Error(`agent ${slug} → ${body.status} ${body.message}`)
|
|
694
|
+
return body.data
|
|
695
|
+
},
|
|
696
|
+
agentDetail(slug, prompt, options),
|
|
697
|
+
) as Promise<{ text: string }>
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Durable `ctx.agent` calls made by THIS execution, in call order.
|
|
701
|
+
*
|
|
702
|
+
* The counter names the call's steps, so it must come out the same on every
|
|
703
|
+
* replay — which it does, because a fresh context is built per execution and
|
|
704
|
+
* a deterministic handler makes its calls in the same order each time. The
|
|
705
|
+
* agent's slug is deliberately NOT in the id: two calls to one agent are two
|
|
706
|
+
* turns, and a name built from the slug would memoize the second as the first.
|
|
707
|
+
*
|
|
708
|
+
* "A deterministic handler" is the author's side of the bargain, and it is
|
|
709
|
+
* CHECKED rather than assumed: the start step memoizes a fingerprint of the
|
|
710
|
+
* call (agent, prompt, files, timeoutMs), and a replay whose n-th call has a
|
|
711
|
+
* different fingerprint fails with `agentReplayMismatchMessage` instead of
|
|
712
|
+
* reusing the other call's turn.
|
|
713
|
+
*/
|
|
714
|
+
let agentCalls = 0
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* `ctx.agent` without a held connection.
|
|
718
|
+
*
|
|
719
|
+
* agent:<n>:start start the turn; the service answers at once
|
|
720
|
+
* agent:<n>:result:<i> read the turn; terminal → done
|
|
721
|
+
* agent:<n>:wait:<i> park until the turn's finished event, or one slice
|
|
722
|
+
*
|
|
723
|
+
* The read is the answer and the event only a wake-up: a wait matches only
|
|
724
|
+
* events sent after it registers, so a turn that settles between a read and
|
|
725
|
+
* the next wait is caught by the read after that slice rather than lost.
|
|
726
|
+
*
|
|
727
|
+
* Every decision is taken from memoized values (`startedAt`, each read's
|
|
728
|
+
* `now`), so a replay walks the same loop and reaches the same step ids. The
|
|
729
|
+
* call's row is written from INSIDE the step that reached the verdict — the
|
|
730
|
+
* one place a body runs exactly once — and times the whole wait.
|
|
731
|
+
*
|
|
732
|
+
* Charged once: the start is the only billable request and it is memoized;
|
|
733
|
+
* reads are free. The idempotency key is the step id, so a start step retried
|
|
734
|
+
* after the service already began the turn gets that same turn back.
|
|
735
|
+
*/
|
|
736
|
+
const agentDurable = async (
|
|
737
|
+
slug: string,
|
|
738
|
+
prompt: string,
|
|
739
|
+
options: AgentRunOptions | undefined,
|
|
740
|
+
): Promise<{ text: string }> => {
|
|
741
|
+
const callNumber = ++agentCalls
|
|
742
|
+
const base = `agent:${callNumber - 1}`
|
|
743
|
+
const label = `agent:${slug}`
|
|
744
|
+
const waitMs = options?.timeoutMs ?? AGENT_DEFAULT_TIMEOUT_MS
|
|
745
|
+
const detailOf = agentDetail(slug, prompt, options)
|
|
746
|
+
const fingerprint = agentFingerprint(slug, prompt, options)
|
|
747
|
+
|
|
748
|
+
const start = (await deps.step.run(`${base}:start`, async (): Promise<AgentStart> => {
|
|
749
|
+
const startedAt = Date.now()
|
|
750
|
+
const res = await scoped('/ctx/agent-start', {
|
|
751
|
+
method: 'POST',
|
|
752
|
+
body: JSON.stringify({ ...agentBody(slug, prompt, options), idempotencyKey: `${base}:start` }),
|
|
753
|
+
})
|
|
754
|
+
if (!res.ok) {
|
|
755
|
+
// A refusal is an answer, not a transient fault: returned, so the
|
|
756
|
+
// platform does not retry a call the service has already turned down,
|
|
757
|
+
// and thrown below in the words the synchronous path uses.
|
|
758
|
+
const message = `agent ${slug} → ${res.status} ${await refusal(res)}`
|
|
759
|
+
await recordStep({ kind: 'agent', label, status: 'error', detail: { message }, durationMs: Date.now() - startedAt })
|
|
760
|
+
return { ok: false, message, fingerprint, slug }
|
|
761
|
+
}
|
|
762
|
+
const { invocationId } = ((await res.json()) as { data: { invocationId: string } }).data
|
|
763
|
+
return { ok: true, invocationId, startedAt, fingerprint, slug }
|
|
764
|
+
})) as AgentStart
|
|
765
|
+
// Replayed from an earlier execution: is this still the call that started
|
|
766
|
+
// it? Steps are matched by ORDER, so if top-level code before this call
|
|
767
|
+
// read different data this time, the n-th call can be another call — and
|
|
768
|
+
// reusing its turn would hand this call someone else's answer.
|
|
769
|
+
if (start.fingerprint !== undefined && start.fingerprint !== fingerprint) {
|
|
770
|
+
throw new Error(agentReplayMismatchMessage(slug, start.slug ?? slug, callNumber))
|
|
771
|
+
}
|
|
772
|
+
if (!start.ok) throw new Error(start.message)
|
|
773
|
+
|
|
774
|
+
const giveUpAt = start.startedAt + waitMs + AGENT_SETTLE_GRACE_MS
|
|
775
|
+
for (let i = 0; ; i++) {
|
|
776
|
+
const read = (await deps.step.run(`${base}:result:${i}`, async (): Promise<AgentRead> => {
|
|
777
|
+
const now = Date.now()
|
|
778
|
+
const settle = async (verdict: { text: string } | { error: string }): Promise<AgentRead> => {
|
|
779
|
+
await recordStep(
|
|
780
|
+
'text' in verdict
|
|
781
|
+
? { kind: 'agent', label, status: 'ok', durationMs: now - start.startedAt, detail: summarize(detailOf, verdict) }
|
|
782
|
+
: { kind: 'agent', label, status: 'error', durationMs: now - start.startedAt, detail: { message: verdict.error } },
|
|
783
|
+
)
|
|
784
|
+
return { done: true, ...verdict }
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let res: Response | null
|
|
788
|
+
try {
|
|
789
|
+
res = await scoped(`/ctx/agent-result/${start.invocationId}`, { method: 'GET' })
|
|
790
|
+
} catch {
|
|
791
|
+
// The service is unreachable for a moment. The turn does not depend
|
|
792
|
+
// on this request, so try again after the next wait.
|
|
793
|
+
res = null
|
|
794
|
+
}
|
|
795
|
+
if (res?.ok) {
|
|
796
|
+
const turn = ((await res.json()) as {
|
|
797
|
+
data: { status: AgentTurnStatus; text: string | null; error: string | null }
|
|
798
|
+
}).data
|
|
799
|
+
if (turn.status === 'succeeded') return settle({ text: turn.text ?? '' })
|
|
800
|
+
if (turn.status !== 'queued' && turn.status !== 'running') {
|
|
801
|
+
// The service stores the message the synchronous route would have
|
|
802
|
+
// thrown; the prefix is the one that route's refusal carries.
|
|
803
|
+
return settle({ error: `agent ${slug} → 400 ${turn.error ?? `the agent turn ended ${turn.status}`}` })
|
|
804
|
+
}
|
|
805
|
+
} else if (res && res.status < 500) {
|
|
806
|
+
// Not transient: the run's token is gone, or the turn is not this
|
|
807
|
+
// run's. Waiting longer cannot change the answer.
|
|
808
|
+
return settle({ error: `agent ${slug} → ${res.status} ${await refusal(res)}` })
|
|
809
|
+
}
|
|
810
|
+
if (now >= giveUpAt) return settle({ error: `agent ${slug} → 400 ${agentTimeoutMessage(slug, waitMs)}` })
|
|
811
|
+
return { done: false, now }
|
|
812
|
+
})) as AgentRead
|
|
813
|
+
|
|
814
|
+
if (read.done) {
|
|
815
|
+
if ('error' in read) throw new Error(read.error)
|
|
816
|
+
return { text: read.text }
|
|
817
|
+
}
|
|
818
|
+
const sliceMs = Math.max(1_000, Math.min(AGENT_WAIT_SLICE_MS, giveUpAt - read.now))
|
|
819
|
+
await deps.step.waitForEvent!(`${base}:wait:${i}`, {
|
|
820
|
+
event: AGENT_TURN_FINISHED_EVENT,
|
|
821
|
+
timeout: `${Math.ceil(sliceMs / 1000)}s`,
|
|
822
|
+
if: `async.data.invocationId == "${start.invocationId}"`,
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
561
827
|
return {
|
|
562
828
|
runId: deps.runId,
|
|
563
829
|
workspaceId: deps.workspaceId,
|
|
@@ -575,39 +841,16 @@ export function buildContext(deps: Deps): AutomationContext {
|
|
|
575
841
|
|
|
576
842
|
agent(slug: string) {
|
|
577
843
|
return {
|
|
578
|
-
run: (prompt: string, options?:
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
method: 'POST',
|
|
589
|
-
body: JSON.stringify({
|
|
590
|
-
slug,
|
|
591
|
-
prompt,
|
|
592
|
-
...(options?.files?.length ? { files: options.files.map((f) => ({ fileId: f.fileId })) } : {}),
|
|
593
|
-
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
594
|
-
}),
|
|
595
|
-
})
|
|
596
|
-
if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)
|
|
597
|
-
return ((await res.json()) as { data: { text: string } }).data
|
|
598
|
-
},
|
|
599
|
-
// The answer itself, capped. A run whose agent step is the expensive
|
|
600
|
-
// one is read to find out WHAT the agent said, and a length alone
|
|
601
|
-
// sends the reader back to re-run the automation to learn it.
|
|
602
|
-
(out) => ({
|
|
603
|
-
slug,
|
|
604
|
-
promptChars: prompt.length,
|
|
605
|
-
...(options?.files?.length ? { files: options.files.length } : {}),
|
|
606
|
-
...(typeof out?.text === 'string'
|
|
607
|
-
? { textChars: out.text.length, preview: preview(out.text) }
|
|
608
|
-
: {}),
|
|
609
|
-
}),
|
|
610
|
-
) as Promise<{ text: string }>,
|
|
844
|
+
run: (prompt: string, options?: AgentRunOptions) => {
|
|
845
|
+
// Durable only where it can be: at the top level of the handler (a
|
|
846
|
+
// step cannot contain steps), on a host that can park a run, and
|
|
847
|
+
// with the grant in place — a missing grant is refused by the
|
|
848
|
+
// synchronous path in the same words and with the same row it has
|
|
849
|
+
// always written.
|
|
850
|
+
const durable =
|
|
851
|
+
!stepScope.getStore() && !!deps.step.waitForEvent && deps.grants.includes(`agent:${slug}:run`)
|
|
852
|
+
return durable ? agentDurable(slug, prompt, options) : agentSync(slug, prompt, options)
|
|
853
|
+
},
|
|
611
854
|
}
|
|
612
855
|
},
|
|
613
856
|
|
package/src/types.ts
CHANGED
|
@@ -220,6 +220,12 @@ export interface AgentHandle {
|
|
|
220
220
|
* `options.timeoutMs` waits longer than the default 120 s for work known to
|
|
221
221
|
* be long (a long scanned document); the service caps it at 480 s, under
|
|
222
222
|
* the run's own 10-minute ceiling.
|
|
223
|
+
*
|
|
224
|
+
* Call it at the TOP LEVEL of the handler, not inside `ctx.step.run`. There
|
|
225
|
+
* the wait is durable: the turn is started once, the run is parked until it
|
|
226
|
+
* finishes, and a resumption replays the answer instead of asking again.
|
|
227
|
+
* Inside a step body (steps cannot nest) the call waits on one request, and
|
|
228
|
+
* a step that is re-run asks the agent again.
|
|
223
229
|
*/
|
|
224
230
|
run(prompt: string, options?: { files?: readonly FileRef[]; timeoutMs?: number }): Promise<{ text: string }>
|
|
225
231
|
}
|