@frontera-sdk/functions 1.50.79 → 1.50.81

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/functions",
3
- "version": "1.50.79",
3
+ "version": "1.50.81",
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.79",
45
+ "@frontera-sdk/blueprint": "1.50.81",
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
+ }
@@ -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?: { files?: Array<{ fileId: string }>; timeoutMs?: number }) =>
579
- step('agent', `agent:${slug}`, async () => {
580
- requireGrant(`agent:${slug}:run`)
581
- // `files` hands the agent already-uploaded files by canonical id —
582
- // the same `{ fileId }` a `file`-typed run input carries, so an
583
- // input can be forwarded as `ctx.agent(s).run(p, { files:
584
- // [ctx.input.doc] })`. The service authorizes each id against this
585
- // run's workspace and stages the bytes onto the agent's computer;
586
- // the agent is told the staged paths in its prompt.
587
- const res = await scoped('/ctx/agent-run', {
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
  }