@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 +2 -2
- package/src/index.ts +2 -0
- package/src/messages.ts +18 -0
- package/src/runtime-context.ts +76 -1
- package/src/testing.ts +24 -2
- package/src/types.ts +93 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/functions",
|
|
3
|
-
"version": "1.52.
|
|
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.
|
|
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
|
+
}
|
package/src/runtime-context.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|