@frontera-sdk/functions 1.52.19 → 1.52.21

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.52.19",
3
+ "version": "1.52.21",
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.52.19",
45
+ "@frontera-sdk/blueprint": "1.52.21",
46
46
  "cron-parser": "^5.0.6"
47
47
  }
48
48
  }
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export {
25
25
  export type { InputFieldCheck, InputValidation } from './inputs'
26
26
  export {
27
27
  EVENT_TRIGGER_SOURCES,
28
+ PERSON_CONFIRM_DEFAULT_MINUTES,
29
+ PERSON_CONFIRM_MAX_MINUTES,
28
30
  TICKETED_TRIGGER_SOURCES,
29
31
  isEventTriggerSource,
30
32
  isTicketedTriggerSource,
package/src/messages.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * different words than production is a test that teaches the wrong lesson.
9
9
  */
10
10
 
11
+ import { PERSON_CONFIRM_MAX_MINUTES } from './types'
12
+
11
13
  /**
12
14
  * A `ctx` call the manifest does not permit.
13
15
  *
@@ -187,3 +189,19 @@ export function oldFileStubMessage(fileId: string): string {
187
189
  'sizeBytes is computed from the content.'
188
190
  )
189
191
  }
192
+
193
+ /**
194
+ * A `ctx.person.confirm` asking to wait longer than a Function run can.
195
+ *
196
+ * Refused before anyone is asked: the run ends at ten minutes, waits included,
197
+ * so a longer question would still be open after its Function stopped. `null`
198
+ * when the wait is one the run can hold.
199
+ */
200
+ export function personConfirmTimeoutRefusal(timeoutMinutes: number | undefined): string | null {
201
+ if (timeoutMinutes === undefined) return null
202
+ if (Number.isInteger(timeoutMinutes) && timeoutMinutes >= 1 && timeoutMinutes <= PERSON_CONFIRM_MAX_MINUTES) return null
203
+ return (
204
+ `ctx.person.confirm: timeoutMinutes must be a whole number from 1 to ${PERSON_CONFIRM_MAX_MINUTES}, got ${String(timeoutMinutes)}. ` +
205
+ 'A Function run ends at ten minutes, waits included. To wait longer for the person, ask in a workflow\'s "Confirm with the person" node.'
206
+ )
207
+ }
@@ -28,12 +28,16 @@ import {
28
28
  duplicateSubmissionMessage as duplicateSubmissionMessageText,
29
29
  emptySubmissionKeyMessage as emptySubmissionKeyMessageText,
30
30
  fileHandleInStepMessage,
31
+ personConfirmTimeoutRefusal,
31
32
  signedUrlRemovedMessage,
32
33
  } from './messages'
33
34
  import type {
34
35
  ActionSubmission,
35
36
  ActionSubmitResult,
36
37
  AutomationContext,
38
+ FunctionCaller,
39
+ PersonConfirmRequest,
40
+ PersonConfirmResult,
37
41
  BlueprintQueryOptions,
38
42
  BlueprintQueryResult,
39
43
  ConversationTranscript,
@@ -102,7 +106,7 @@ function jsonbSafe(value: unknown, seen: WeakSet<object> = new WeakSet()): unkno
102
106
  // to throw inside the fetch that was carrying an otherwise-good row.
103
107
  if (seen.has(value)) return undefined
104
108
  // The ANCESTOR stack, not every node ever visited — unmarked on the way out.
105
- // A node marked for good cannot tell a cycle from an ordinary shared
109
+ // A node that stays marked cannot tell a cycle from an ordinary shared
106
110
  // reference, which `JSON.stringify` handles fine: `{ before: row, after: row }`
107
111
  // lost `after`, and `[row, row]` became `[null, null]`. That is silent data
108
112
  // loss in the very detail this file exists to make readable.
@@ -375,6 +379,14 @@ const AGENT_DEFAULT_TIMEOUT_MS = 120_000
375
379
  */
376
380
  const AGENT_WAIT_SLICE_MS = 60_000
377
381
 
382
+ /**
383
+ * The `i`-th wait of `ctx.person.confirm`: 10 s, doubling, up to a minute. An
384
+ * answer wakes the wait at once; the slice only bounds a missed wake-up.
385
+ */
386
+ function confirmSliceMs(i: number): number {
387
+ return Math.min(10_000 * 2 ** i, AGENT_WAIT_SLICE_MS)
388
+ }
389
+
378
390
  /**
379
391
  * How long past the author's wait the loop keeps reading before it gives up
380
392
  * on its own.
@@ -461,6 +473,8 @@ type AgentRead =
461
473
  interface Deps {
462
474
  runId: string
463
475
  workspaceId: string
476
+ /** An Agent-started run's trusted context, from `startRun`. */
477
+ context?: { caller: Record<string, unknown>; agentId: string; conversationId: string; variables: Record<string, unknown> } | null
464
478
  runToken: string
465
479
  grants: string[]
466
480
  /** The platform's step tools for THIS execution. */
@@ -925,6 +939,61 @@ export function buildContext(deps: Deps): AutomationContext {
925
939
  */
926
940
  let agentCalls = 0
927
941
 
942
+ /**
943
+ * `ctx.person.confirm` without a held connection, the way `agentDurable`
944
+ * waits for a turn:
945
+ *
946
+ * person.confirm:<n>:open ask (the service finds the conversation)
947
+ * person.confirm:<n>:read:<i> read the answer; answered or expired → done
948
+ * person.confirm:<n>:wait:<i> park until the answer's wake-up, or a slice
949
+ *
950
+ * The read is the answer and the event only a wake-up (the service sends
951
+ * `agent/turn.finished` keyed by the confirmation's id when it is answered).
952
+ */
953
+ let confirmCalls = 0
954
+ const personConfirm = async (request: PersonConfirmRequest): Promise<PersonConfirmResult> => {
955
+ if (stepScope.getStore()) {
956
+ throw new Error('ctx.person.confirm must be called at the top level of the handler, not inside ctx.step.run')
957
+ }
958
+ const refused = personConfirmTimeoutRefusal(request.timeoutMinutes)
959
+ if (refused) throw new Error(refused)
960
+ const base = `person.confirm:${confirmCalls++}`
961
+ const opened = (await deps.step.run(`${base}:open`, async () => {
962
+ const res = await scoped('/ctx/person-confirmation', {
963
+ method: 'POST',
964
+ body: JSON.stringify({ key: base, ...request }),
965
+ })
966
+ if (!res.ok) throw new Error(`person.confirm → ${res.status} ${await refusal(res)}`)
967
+ return ((await res.json()) as { data: { id?: string; status: string; expiresAt?: string } }).data
968
+ })) as { id?: string; status: string; expiresAt?: string }
969
+ if (opened.status !== 'pending' || !opened.id) return opened.status as PersonConfirmResult
970
+ for (let i = 0; ; i++) {
971
+ const read = (await deps.step.run(`${base}:read:${i}`, async () => {
972
+ const res = await scoped(`/ctx/person-confirmation/${opened.id}`, { method: 'GET' }).catch(() => null)
973
+ // Refused permanently: waiting on it would only spend the run's steps.
974
+ if (res && res.status >= 400 && res.status < 500) throw new Error(`person.confirm → ${res.status} ${await refusal(res)}`)
975
+ // Unreachable or failing for now: read again after the next wait.
976
+ const status = res?.ok ? ((await res.json()) as { data: { status: string } }).data.status : 'pending'
977
+ return { status, now: Date.now() }
978
+ })) as { status: string; now: number }
979
+ if (read.status !== 'pending') return read.status as PersonConfirmResult
980
+ const slice = confirmSliceMs(i)
981
+ const remaining = Date.parse(opened.expiresAt ?? '') - read.now
982
+ // Past its expiry by this run's clock but not the service's: a whole slice, then read again.
983
+ const sliceMs = Math.max(1_000, remaining > 0 ? Math.min(slice, remaining) : slice)
984
+ if (deps.step.waitForEvent) {
985
+ await deps.step.waitForEvent(`${base}:wait:${i}`, {
986
+ event: AGENT_TURN_FINISHED_EVENT,
987
+ timeout: `${Math.ceil(sliceMs / 1000)}s`,
988
+ if: `async.data.invocationId == "${opened.id}"`,
989
+ })
990
+ } else {
991
+ // No durable wait on this platform: poll within this execution.
992
+ await new Promise((resolve) => setTimeout(resolve, Math.min(sliceMs, 2_000)))
993
+ }
994
+ }
995
+ }
996
+
928
997
  /**
929
998
  * `ctx.agent` without a held connection.
930
999
  *
@@ -1039,6 +1108,10 @@ export function buildContext(deps: Deps): AutomationContext {
1039
1108
  return {
1040
1109
  runId: deps.runId,
1041
1110
  workspaceId: deps.workspaceId,
1111
+ caller: deps.context
1112
+ ? ({ ...deps.context.caller, agentId: deps.context.agentId, conversationId: deps.context.conversationId } as FunctionCaller)
1113
+ : null,
1114
+ variables: deps.context?.variables ?? {},
1042
1115
  // The host passes the run row's frozen copy; the SDK never re-validates — `startRun` is the authority.
1043
1116
  input: deps.input ?? {},
1044
1117
 
@@ -1314,6 +1387,8 @@ export function buildContext(deps: Deps): AutomationContext {
1314
1387
  ) as Promise<BlueprintQueryResult<T>>,
1315
1388
  },
1316
1389
 
1390
+ person: { confirm: personConfirm },
1391
+
1317
1392
  conversation: {
1318
1393
  transcript: () =>
1319
1394
  step('conversation', 'transcript', async () => {
package/src/testing.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  fileHandleInStepMessage,
7
7
  missingGrantMessage,
8
8
  oldFileStubMessage,
9
+ personConfirmTimeoutRefusal,
9
10
  submitOutsideStepMessage,
10
11
  } from './messages'
11
12
  import { asFileHandle, carriesFileHandle } from './runtime-context'
@@ -13,6 +14,9 @@ import type {
13
14
  ActionSubmission,
14
15
  ActionSubmitResult,
15
16
  AutomationContext,
17
+ FunctionCaller,
18
+ PersonConfirmRequest,
19
+ PersonConfirmResult,
16
20
  BlueprintQueryOptions,
17
21
  BlueprintQueryResult,
18
22
  ConversationTranscript,
@@ -51,8 +55,8 @@ import type {
51
55
  * for a refusal, and on `calls` for what ran.
52
56
  */
53
57
  export interface TestCall {
54
- kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action' | 'file' | 'conversation'
55
- /** Step name, log message, agent slug, `install:capability`, URL, object type, Action apiName, or `transcript`. */
58
+ kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action' | 'file' | 'conversation' | 'person'
59
+ /** Step name, log message, agent slug, `install:capability`, URL, object type, Action apiName, `transcript`, or the person's answer. */
56
60
  label: string
57
61
  /** Present on a step: how it ended. */
58
62
  status?: 'ok' | 'error'
@@ -62,6 +66,12 @@ export interface TestCall {
62
66
  export interface TestContextOptions {
63
67
  runId?: string
64
68
  workspaceId?: string
69
+ /** Who asked for the run through an Agent; `null` (no Agent) by default. */
70
+ caller?: FunctionCaller | null
71
+ /** The conversation's variables; `{}` by default. */
72
+ variables?: Record<string, unknown>
73
+ /** How the person answers `ctx.person.confirm`: a fixed answer or a function of the request; `unavailable` by default. */
74
+ confirm?: PersonConfirmResult | ((request: PersonConfirmRequest) => PersonConfirmResult)
65
75
  /** What the run was started with. Passed through verbatim — a unit test
66
76
  * states exactly what the handler sees; defaults are `startRun`'s job. */
67
77
  input?: Record<string, unknown>
@@ -156,6 +166,8 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
156
166
  const ctx: AutomationContext = {
157
167
  runId: options.runId ?? 'test-run',
158
168
  workspaceId: options.workspaceId ?? 'test-workspace',
169
+ caller: options.caller ?? null,
170
+ variables: options.variables ?? {},
159
171
  input: options.input ?? {},
160
172
 
161
173
  step: {
@@ -345,6 +357,16 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
345
357
  },
346
358
  },
347
359
 
360
+ person: {
361
+ async confirm(request) {
362
+ const refused = personConfirmTimeoutRefusal(request.timeoutMinutes)
363
+ if (refused) throw new Error(refused)
364
+ const answer = typeof options.confirm === 'function' ? options.confirm(request) : (options.confirm ?? 'unavailable')
365
+ calls.push({ kind: 'person', label: answer })
366
+ return answer
367
+ },
368
+ },
369
+
348
370
  conversation: {
349
371
  async transcript(): Promise<ConversationTranscript | null> {
350
372
  requireGrant('conversation:read')
package/src/types.ts CHANGED
@@ -479,12 +479,89 @@ export interface ActionSubmitResult {
479
479
  lifecycle: ActionRequestLifecycle
480
480
  }
481
481
 
482
+ /**
483
+ * Who asked an Agent for this run, as the platform knows them. Set by the
484
+ * platform beside the run's input, never by the model, so a Function can
485
+ * trust it for identity and authorization.
486
+ */
487
+ export type FunctionCaller = (
488
+ /** A signed-in member of the workspace. */
489
+ | { kind: 'person'; userId: string }
490
+ /** A system calling with one of the Agent's API keys. */
491
+ | { kind: 'api-key'; apiKeyId: string }
492
+ /** An end user signed in with a chat token your backend minted (`externalUserId` is your id for them). */
493
+ | { kind: 'external-user'; externalUserId: string }
494
+ /**
495
+ * A customer on a messaging channel. `application` is the channel
496
+ * application's name, or the workspace channel's slug; `customerId` is the
497
+ * channel's id for the customer, such as a phone number.
498
+ */
499
+ | { kind: 'channel'; application: string; customerId: string }
500
+ ) & {
501
+ /** The Agent whose tool call started this run. */
502
+ agentId: string
503
+ /** The conversation it was in. */
504
+ conversationId: string
505
+ }
506
+
507
+ /**
508
+ * The longest `ctx.person.confirm` waits, in minutes. A Function run ends at
509
+ * ten minutes, waits included, so a question holds at most eight and leaves
510
+ * two for the Function's own work, the same ceiling as `ctx.agent`'s
511
+ * `timeoutMs`. A longer wait for a person belongs in a workflow's confirm node.
512
+ */
513
+ export const PERSON_CONFIRM_MAX_MINUTES = 8
514
+ /** How long `ctx.person.confirm` waits when the Function names no `timeoutMinutes`. */
515
+ export const PERSON_CONFIRM_DEFAULT_MINUTES = 5
516
+
517
+ /** What `ctx.person.confirm` asks the person. */
518
+ export interface PersonConfirmRequest {
519
+ /** What will be done, in one sentence the person can say yes to ("Refund 49.00 to the card ending 4242"). */
520
+ summary: string
521
+ /** Anything else they should see first, as plain text. */
522
+ details?: string
523
+ /** The confirm button's label; "Confirm" by default. */
524
+ confirmLabel?: string
525
+ /** The cancel button's label; "Cancel" by default. */
526
+ cancelLabel?: string
527
+ /**
528
+ * How long the question stays open, in whole minutes from 1 to 8; 5 by
529
+ * default. The run still ends at ten minutes, waits included.
530
+ */
531
+ timeoutMinutes?: number
532
+ }
533
+
534
+ /**
535
+ * The person's answer. `unavailable`: no Agent started this run from a
536
+ * conversation (a schedule, a manual run, a workflow, a dry run), or the Agent
537
+ * was talking to nobody the platform can name, so there is nobody to ask; or
538
+ * the conversation is on a messaging channel, which cannot show the question.
539
+ */
540
+ export type PersonConfirmResult = 'confirmed' | 'cancelled' | 'expired' | 'unavailable'
541
+
482
542
  /**
483
543
  * `notify` still arrives with a later slice; `governed` is here.
484
544
  */
485
545
  export interface AutomationContext {
486
546
  runId: string
487
547
  workspaceId: string
548
+ /**
549
+ * Who asked for this run, when an Agent's tool call started it: the person,
550
+ * API key, chat-token user or channel customer in that conversation. Set by
551
+ * the platform, not the model, and the same on every resumption. `null`
552
+ * when no Agent started the run (a schedule, a manual run, an App, a
553
+ * workflow), when the Agent was talking to nobody the platform can name
554
+ * (an Agent another Function ran with no person behind it), and in dry
555
+ * (dev) runs.
556
+ */
557
+ caller: FunctionCaller | null
558
+ /**
559
+ * The conversation's variables, by name: the values its entry point
560
+ * supplied when it started (the SDK or API, a channel). Set by the
561
+ * platform, not the model. `{}` when there are none or no Agent started
562
+ * the run.
563
+ */
564
+ variables: Record<string, unknown>
488
565
  /**
489
566
  * The values this run was started with — validated against the manifest's
490
567
  * `inputs` schema and fixed on the run row at start, so every resumption
@@ -527,6 +604,22 @@ export interface AutomationContext {
527
604
  options?: BlueprintQueryOptions,
528
605
  ): Promise<BlueprintQueryResult<T>>
529
606
  }
607
+ person: {
608
+ /**
609
+ * Ask the person in the conversation that started this run to confirm
610
+ * before the run acts. They answer on a card in web chat; in an API
611
+ * conversation, the system holding the Agent's key answers. The Agent
612
+ * waits for the answer, and so does this run, durably, without holding a
613
+ * connection. A conversation on a messaging channel (WhatsApp, Telegram)
614
+ * cannot show the question yet and answers `unavailable` at once.
615
+ *
616
+ * Call it at the top level of the handler, not inside `ctx.step.run`, and
617
+ * act on the answer after it: do the work in a step only when it is
618
+ * `confirmed`. The question is yours, not the Agent's model's: write
619
+ * everything the person needs to decide in `summary` and `details`.
620
+ */
621
+ confirm(request: PersonConfirmRequest): Promise<PersonConfirmResult>
622
+ }
530
623
  conversation: {
531
624
  /**
532
625
  * The conversation that started THIS run, as the person saw it.