@vercel/agent-runs 1.0.0-rc.1 → 1.0.0-rc.3

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +10 -5
  2. package/NOTICE +24 -0
  3. package/README.md +2 -2
  4. package/dist/src/adapters/serialization.d.ts.map +1 -1
  5. package/dist/src/adapters/serialization.js +34 -8
  6. package/dist/src/adapters/serialization.js.map +1 -1
  7. package/dist/src/core/content-policy.d.ts +8 -0
  8. package/dist/src/core/content-policy.d.ts.map +1 -1
  9. package/dist/src/core/content-policy.js +14 -0
  10. package/dist/src/core/content-policy.js.map +1 -1
  11. package/dist/src/core/scopes.d.ts.map +1 -1
  12. package/dist/src/core/scopes.js +0 -8
  13. package/dist/src/core/scopes.js.map +1 -1
  14. package/dist/src/core/snapshot.d.ts.map +1 -1
  15. package/dist/src/core/snapshot.js +126 -10
  16. package/dist/src/core/snapshot.js.map +1 -1
  17. package/dist/src/core/span-kinds.d.ts.map +1 -1
  18. package/dist/src/core/span-kinds.js +7 -3
  19. package/dist/src/core/span-kinds.js.map +1 -1
  20. package/dist/src/core/types.d.ts +15 -6
  21. package/dist/src/core/types.d.ts.map +1 -1
  22. package/dist/src/index.d.ts +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/operations.d.ts +12 -4
  26. package/dist/src/operations.d.ts.map +1 -1
  27. package/dist/src/operations.js +6 -8
  28. package/dist/src/operations.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/adapters/serialization.ts +31 -7
  31. package/src/core/content-policy.ts +17 -0
  32. package/src/core/scopes.ts +0 -8
  33. package/src/core/snapshot.ts +135 -11
  34. package/src/core/span-kinds.ts +7 -3
  35. package/src/core/types.ts +15 -9
  36. package/src/index.ts +1 -0
  37. package/src/operations.ts +23 -17
@@ -1,10 +1,121 @@
1
1
  import type { ScopeRecord } from "./types.js";
2
2
  import type { ContentSerializer } from "./types.js";
3
3
  import { capturedScopeData } from "./span-kinds.js";
4
- const SERIALIZED_BYTES = 32768;
5
- const SNAPSHOT_BYTES = 65536;
4
+ const SERIALIZED_BYTES = 256 * 1024;
5
+ const SNAPSHOT_BYTES = 512 * 1024;
6
6
  const UNFINISHED_CHILDREN = 10000;
7
- import { withoutDeclinedContent } from "./content-policy.js";
7
+ import { preservingCapturePolicy, withoutDeclinedContent } from "./content-policy.js";
8
+
9
+ const TRUNCATION_MARKER = "… [truncated]";
10
+ const TURN_CONTENT_ATTRIBUTES = ["gen_ai.input.messages", "gen_ai.output.messages"] as const;
11
+ const encoder = new TextEncoder();
12
+
13
+ function byteLength(value: string): number {
14
+ return encoder.encode(value).length;
15
+ }
16
+
17
+ /** Text of a serialized GenAI message's text parts, the truncatable content. */
18
+ function messageText(parts: unknown): string {
19
+ if (!Array.isArray(parts)) return "";
20
+ let text = "";
21
+ for (const part of parts) {
22
+ if (typeof part !== "object" || part === null) continue;
23
+ const record = part as Record<string, unknown>;
24
+ if (record.type !== "text" || typeof record.content !== "string") continue;
25
+ text += (text.length === 0 ? "" : "\n") + record.content;
26
+ }
27
+ return text;
28
+ }
29
+
30
+ /**
31
+ * Shrinks a serialized GenAI message array to `budgetBytes` while keeping it
32
+ * valid JSON: the newest message's text is truncated with the library's
33
+ * truncation marker, mirroring how the serializer caps oversized prompts.
34
+ * The cut is binary searched, so an attribute far over the budget costs a
35
+ * logarithmic number of encodes instead of re-encoding a near-full candidate
36
+ * at every step.
37
+ */
38
+ function fitTurnContent(input: string, budgetBytes: number): string | undefined {
39
+ if (budgetBytes <= 0) return undefined;
40
+ if (byteLength(input) <= budgetBytes) return input;
41
+ let messages: unknown;
42
+ try {
43
+ messages = JSON.parse(input);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ if (!Array.isArray(messages) || messages.length === 0) return undefined;
48
+ const last = messages.at(-1);
49
+ if (typeof last !== "object" || last === null) return undefined;
50
+ const message = last as Record<string, unknown>;
51
+ if (typeof message.role !== "string") return undefined;
52
+ const text = messageText(message.parts);
53
+ const base =
54
+ typeof message.kind === "string"
55
+ ? { kind: message.kind, role: message.role }
56
+ : { role: message.role };
57
+ const candidate = (length: number): string =>
58
+ JSON.stringify([
59
+ {
60
+ ...base,
61
+ parts: [{ content: `${text.slice(0, length)}${TRUNCATION_MARKER}`, type: "text" }],
62
+ },
63
+ ]);
64
+ let best: string | undefined;
65
+ let low = 1;
66
+ let high = text.length;
67
+ while (low <= high) {
68
+ const middle = Math.floor((low + high) / 2);
69
+ const probe = candidate(middle);
70
+ if (byteLength(probe) <= budgetBytes) {
71
+ best = probe;
72
+ low = middle + 1;
73
+ } else {
74
+ high = middle - 1;
75
+ }
76
+ }
77
+ if (best !== undefined) return best;
78
+ const minimal = candidate(0);
79
+ return byteLength(minimal) <= budgetBytes ? minimal : undefined;
80
+ }
81
+
82
+ /**
83
+ * Adds truncated copies of the turn's content attributes back into a stripped
84
+ * checkpoint, sized so the whole record stays within the snapshot budgets. A
85
+ * copy that cannot fit small enough is left out; the capture-policy attributes
86
+ * stay truthful either way.
87
+ */
88
+ function withFittedTurnContent(
89
+ attributes: Record<string, unknown>,
90
+ original: Readonly<Record<string, unknown>>,
91
+ record: ScopeRecord,
92
+ ): Record<string, unknown> {
93
+ let fitted = attributes;
94
+ for (const key of TURN_CONTENT_ATTRIBUTES) {
95
+ const value = original[key];
96
+ if (typeof value !== "string") continue;
97
+ const withoutKey = { ...fitted };
98
+ delete withoutKey[key];
99
+ const bare = { ...record, attributes: withoutKey } as ScopeRecord;
100
+ let budget = Math.min(
101
+ SERIALIZED_BYTES - byteLength(JSON.stringify(bare.data)),
102
+ SNAPSHOT_BYTES - nodeBytes(bare),
103
+ );
104
+ while (budget > 0) {
105
+ const truncated = fitTurnContent(value, budget);
106
+ // Shrinking the budget cannot help when even the minimal form does not
107
+ // fit or the copy is not truncatable JSON.
108
+ if (truncated === undefined) break;
109
+ const candidate = { ...fitted, [key]: truncated };
110
+ if (nodeBytes({ ...record, attributes: candidate } as ScopeRecord) <= SNAPSHOT_BYTES) {
111
+ fitted = candidate;
112
+ break;
113
+ }
114
+ budget = Math.floor(budget / 2);
115
+ }
116
+ }
117
+ return fitted;
118
+ }
8
119
 
9
120
  import type { TraceSnapshot } from "./types.js";
10
121
  export function boundedSerializer(
@@ -108,10 +219,17 @@ export function validSnapshot(
108
219
  step: [],
109
220
  model: ["provider", "modelId"],
110
221
  tool: ["callId", "name"],
111
- approval: ["requestId", "callId", "toolName"],
222
+ approval: ["requestId"],
112
223
  memory: ["operation", "phase", "slot", "storeId"],
113
224
  };
114
225
  if (!fields[record.data.type].every((key) => typeof options[key] === "string")) return false;
226
+ if (
227
+ record.data.type === "approval" &&
228
+ !["kind", "connectionName", "callId", "toolName"].every(
229
+ (key) => options[key] === undefined || typeof options[key] === "string",
230
+ )
231
+ )
232
+ return false;
115
233
  if (record.data.type === "activation" && !Number.isSafeInteger(options.sequence)) return false;
116
234
  if (record.data.type === "step" && !Number.isSafeInteger(options.index)) return false;
117
235
  if (
@@ -201,15 +319,25 @@ export function snapshotRecord(record: ScopeRecord, serializer: ContentSerialize
201
319
  json = serializer.json(data);
202
320
  } catch {}
203
321
  const fallback = () => {
204
- let structural = capturedScopeData(data, {
322
+ const declined = {
205
323
  emit: record.capture.emit,
206
324
  recordInputs: false,
207
325
  recordOutputs: false,
208
- });
326
+ };
327
+ let structural = capturedScopeData(data, declined);
209
328
  if (structural.type === "step")
210
329
  structural = { ...structural, options: { ...structural.options, runtimeContext: undefined } };
211
330
  if (structural.type === "model")
212
331
  structural = { ...structural, options: { ...structural.options, runtimeContext: undefined } };
332
+ // A size-driven strip is not a capture-policy decline: the policy
333
+ // attributes keep the record's real decision, and the turn's content
334
+ // attributes stay as truncated copies when they fit the node budget.
335
+ const original = record.attributes ?? {};
336
+ const attributes = withFittedTurnContent(
337
+ preservingCapturePolicy(withoutDeclinedContent(original, declined) ?? {}, original),
338
+ original,
339
+ { ...record, version: 1, data: structural },
340
+ );
213
341
  return JSON.parse(
214
342
  JSON.stringify({
215
343
  ...record,
@@ -224,11 +352,7 @@ export function snapshotRecord(record: ScopeRecord, serializer: ContentSerialize
224
352
  errorCode: record.terminal.errorCode,
225
353
  usage: record.terminal.usage,
226
354
  },
227
- attributes:
228
- withoutDeclinedContent(record.attributes ?? {}, {
229
- recordInputs: false,
230
- recordOutputs: false,
231
- }) ?? record.attributes,
355
+ attributes,
232
356
  }),
233
357
  ) as ScopeRecord;
234
358
  };
@@ -238,19 +238,23 @@ const kinds: { [K in ScopeData["type"]]: Kind<K> } = {
238
238
  },
239
239
  },
240
240
  approval: {
241
- parents: ["tool"],
241
+ parents: ["tool", "activation"],
242
242
  prepare({ options }, context) {
243
243
  return {
244
244
  name: SPAN_NAMES.approval,
245
245
  attributes: {
246
246
  ...context.attributes,
247
247
  ...frameworkAttributes(context.identity.framework),
248
- ...attemptAttributes(context),
248
+ // A wait the turn holds belongs to no step.
249
+ ...(options.callId === undefined
250
+ ? { "agent.turn.id": context.attempt.turnId }
251
+ : attemptAttributes(context)),
249
252
  ...namingAttributes(SPAN_NAMES.approval, "workflow"),
250
253
  "gen_ai.operation.name": "workflow",
251
254
  "gen_ai.tool.call.id": options.callId,
252
255
  "agent.action.name": options.toolName,
253
- "agent.approval.kind": "tool-approval",
256
+ "agent.approval.kind": options.kind ?? "tool-approval",
257
+ "agent.connection.name": options.connectionName,
254
258
  "agent.approval.request_id": options.requestId,
255
259
  [CONTENT_FIELDS.approvalRequest]: context.capture.recordInputs
256
260
  ? context.serializer.json(options.request)
package/src/core/types.ts CHANGED
@@ -202,20 +202,26 @@ export interface ToolOptions {
202
202
  readonly kind?: ToolKind;
203
203
  readonly arguments?: unknown;
204
204
  }
205
+ /**
206
+ * A wait on a person. Under a tool call it names that call; under a turn it has no call, such
207
+ * as a budget prompt or a sign-in the turn needs before it can run.
208
+ */
209
+ export interface ApprovalOptions {
210
+ readonly requestId: string;
211
+ readonly request?: unknown;
212
+ /** What the person is asked, recorded as `agent.approval.kind`. Defaults to `tool-approval`. */
213
+ readonly kind?: string;
214
+ /** The connection a sign-in is for, recorded as `agent.connection.name`. */
215
+ readonly connectionName?: string;
216
+ readonly callId?: string;
217
+ readonly toolName?: string;
218
+ }
205
219
  export type ScopeData =
206
220
  | { readonly type: "activation"; readonly options: TurnMetadata }
207
221
  | { readonly type: "step"; readonly options: StepOptions }
208
222
  | { readonly type: "model"; readonly options: ModelOptions }
209
223
  | { readonly type: "tool"; readonly options: ToolOptions }
210
- | {
211
- readonly type: "approval";
212
- readonly options: {
213
- requestId: string;
214
- request?: unknown;
215
- callId: string;
216
- toolName: string;
217
- };
218
- }
224
+ | { readonly type: "approval"; readonly options: ApprovalOptions }
219
225
  | {
220
226
  readonly type: "memory";
221
227
  readonly options: {
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export type {
16
16
  ToolInput,
17
17
  ModelOperation,
18
18
  ToolOperation,
19
+ ApprovalInput,
19
20
  ApprovalOperation,
20
21
  MemoryOperation,
21
22
  ModelCallReturn,
package/src/operations.ts CHANGED
@@ -52,6 +52,17 @@ export interface TurnOperation extends Operation {
52
52
  /** Replaces the turn's links before its deferred span is exported. */
53
53
  links(links: readonly TraceLink[]): void;
54
54
  memory: WrappedOperation<MemoryInput, MemoryOperation>;
55
+ /** A wait on a person that no tool call owns, such as a budget prompt or a sign-in. */
56
+ approval: WrappedOperation<ApprovalInput, ApprovalOperation>;
57
+ /** Returns an open turn-level approval without starting one. Durable turns only. */
58
+ findApproval(requestId: string): ApprovalOperation | undefined;
59
+ }
60
+ /** What a person is asked. `kind` defaults to `tool-approval`. */
61
+ export interface ApprovalInput {
62
+ readonly requestId: string;
63
+ readonly request?: unknown;
64
+ readonly kind?: string;
65
+ readonly connectionName?: string;
55
66
  }
56
67
  /**
57
68
  * One tool call. In a durable turn, calling `tool` again with the same `callId`
@@ -77,7 +88,7 @@ export interface AttemptOperation extends Operation {
77
88
  memory: WrappedOperation<MemoryInput, MemoryOperation>;
78
89
  }
79
90
  export interface ToolOperation extends Operation {
80
- approval: WrappedOperation<{ requestId: string; request?: unknown }, ApprovalOperation>;
91
+ approval: WrappedOperation<ApprovalInput, ApprovalOperation>;
81
92
  /** Returns an open approval without starting one. Durable turns only. */
82
93
  findApproval(requestId: string): ApprovalOperation | undefined;
83
94
  /** A tool call made while this tool runs, such as a connector's nested call. */
@@ -200,10 +211,20 @@ export function operationHandle(
200
211
  undefined,
201
212
  onError,
202
213
  );
214
+ const approvals = (call?: { callId: string; toolName: string }) => ({
215
+ findApproval: (requestId: string) =>
216
+ found<ApprovalOperation>(runtime.find({ type: "approval", options: { requestId, ...call } })),
217
+ approval: wrappedOperation(
218
+ async (input: ApprovalInput) => child({ type: "approval", options: { ...input, ...call } }),
219
+ { outcome: "ignored" },
220
+ onError,
221
+ ),
222
+ });
203
223
  if (data.type === "activation")
204
224
  return live({
205
225
  ...common,
206
226
  memory,
227
+ ...approvals(),
207
228
  links: (links: readonly TraceLink[]) => runtime.update({ links }),
208
229
  findAttempt: (input: { stepIndex: number; attempt: number }) =>
209
230
  found<AttemptOperation>(
@@ -309,22 +330,7 @@ export function operationHandle(
309
330
  ...common,
310
331
  memory,
311
332
  tool,
312
- findApproval: (requestId: string) =>
313
- found<ApprovalOperation>(
314
- runtime.find({
315
- type: "approval",
316
- options: { requestId, callId: data.options.callId, toolName: data.options.name },
317
- }),
318
- ),
319
- approval: wrappedOperation(
320
- async (input: { requestId: string; request?: unknown }) =>
321
- child({
322
- type: "approval",
323
- options: { ...input, callId: data.options.callId, toolName: data.options.name },
324
- }),
325
- { outcome: "ignored" },
326
- onError,
327
- ),
333
+ ...approvals({ callId: data.options.callId, toolName: data.options.name }),
328
334
  } as ToolOperation);
329
335
  return common;
330
336
  }