experimental-a2 0.18.1 → 0.19.0

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 (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  3. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  4. package/dist/ai-client.d.ts +1 -1
  5. package/dist/ai-client.js +1 -1
  6. package/dist/ai-control-CvLbW10B.js +46 -0
  7. package/dist/ai-control-CvLbW10B.js.map +1 -0
  8. package/dist/ai-fx.d.ts +1 -1
  9. package/dist/ai-fx.d.ts.map +1 -1
  10. package/dist/ai-fx.js +30 -10
  11. package/dist/ai-fx.js.map +1 -1
  12. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  13. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  14. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  15. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  16. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  17. package/dist/ai-mMiWul1T.js.map +1 -0
  18. package/dist/ai-pi.d.ts +1 -1
  19. package/dist/ai-pi.d.ts.map +1 -1
  20. package/dist/ai-pi.js +55 -10
  21. package/dist/ai-pi.js.map +1 -1
  22. package/dist/ai-react.d.ts +1 -1
  23. package/dist/ai-react.js +1 -1
  24. package/dist/ai-server.d.ts +2 -2
  25. package/dist/ai-server.d.ts.map +1 -1
  26. package/dist/ai-server.js +118 -50
  27. package/dist/ai-server.js.map +1 -1
  28. package/dist/ai.d.ts +2 -2
  29. package/dist/ai.js +1 -1
  30. package/dist/scheduler-vercel.d.ts.map +1 -1
  31. package/dist/scheduler-vercel.js +1 -4
  32. package/dist/scheduler-vercel.js.map +1 -1
  33. package/docs/agents/03-conversations.mdx +25 -13
  34. package/docs/agents/04-harnesses.mdx +51 -5
  35. package/docs/contract/03-sessions.mdx +1 -1
  36. package/docs/reference/04-ai.mdx +27 -18
  37. package/docs/reference/05-adapters.mdx +2 -2
  38. package/docs/server/02-timers.mdx +1 -1
  39. package/docs/server/05-production.mdx +2 -2
  40. package/examples/playground/DEPLOY.md +9 -0
  41. package/examples/playground/app/a0/a0.css +244 -5
  42. package/examples/playground/app/a0/app-view.tsx +2 -1
  43. package/examples/playground/app/a0/attachments.ts +133 -0
  44. package/examples/playground/app/a0/chat.test.tsx +504 -0
  45. package/examples/playground/app/a0/chat.tsx +623 -63
  46. package/examples/playground/app/a0/context-menu.tsx +38 -11
  47. package/examples/playground/app/a0/model.test.ts +28 -0
  48. package/examples/playground/app/a0/model.ts +30 -0
  49. package/examples/playground/app/a0/server.ts +5 -0
  50. package/examples/playground/app/a0/uploads/route.ts +29 -0
  51. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  52. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  53. package/examples/playground/app/agent/model.test.ts +12 -3
  54. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  55. package/examples/playground/next.config.ts +7 -4
  56. package/examples/playground/package.json +3 -1
  57. package/package.json +1 -1
  58. package/src/ai-agent.ts +4 -0
  59. package/src/ai-client-state.ts +21 -9
  60. package/src/ai-context-schema.ts +28 -4
  61. package/src/ai-context.ts +83 -3
  62. package/src/ai-control-server.ts +70 -43
  63. package/src/ai-control-state.ts +3 -4
  64. package/src/ai-control.ts +59 -10
  65. package/src/ai-coordinator.ts +7 -6
  66. package/src/ai-fx.ts +58 -20
  67. package/src/ai-harness.ts +153 -29
  68. package/src/ai-message-shape.ts +41 -0
  69. package/src/ai-pi.ts +88 -32
  70. package/src/ai-stored-state.ts +1 -3
  71. package/src/ai.ts +116 -77
  72. package/src/libfx.d.ts +8 -1
  73. package/src/scheduler-vercel.ts +1 -12
  74. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  75. package/dist/ai-D6mGlO8j.js.map +0 -1
  76. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  77. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  78. package/examples/playground/app/a0/no-photon.cjs +0 -4
package/dist/ai.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { $ as agentContract, A as InputRespondedPayload, B as PendingApproval, C as GenerationFailedPayload, D as GenerationStartedPayload, E as GenerationRequestedPayload, F as MessageInterruptedPayload, G as SessionClosedPayload, H as ReasoningLevel, I as ModelLimits, J as ToolCalledPayload, K as SessionCreatedPayload, L as ModelMetadataRequestedPayload, M as LanguageModelUsage, N as MessageCompletedPayload, O as GenerationUsage, P as MessageCreatedPayload, Q as UIMessageChunk, R as ModelMetadataResolvedPayload, S as GenerationCompletedPayload, T as GenerationReason, U as RetryGenerationOptions, V as PendingInput, W as RetryRequestedPayload, X as TranscriptMessage, Y as ToolResultPayload, Z as UIMessage, _ as CompactionPart, a as AI_STATE_REDUCER, at as reasoningLevels, b as CompactionState, c as AgentContract, ct as PendingQueueCommand, d as AgentStatus, dt as ControlReceipt, et as createEvents, f as AgentToolContext, ft as InboxItem, g as CompactionCompletedPayload, h as ApprovalRespondedPayload, i as AIState, it as inputs, j as JSONValue, k as InputRequestedPayload, l as AgentContractOptions, lt as ActiveTurn, m as ApprovalRequestedPayload, n as AIInputs, nt as events, o as ActiveGeneration, ot as reduceAIState, p as AgentToolEventDefs, q as ToolActivity, r as AIMessageOf, rt as handlerContext, s as ActiveGenerationProjection, st as isCompactionPart, t as AIEventDefs, tt as deriveUIMessages, u as AgentReducer, ut as ControlCommand, v as CompactionPartData, w as GenerationProgressPayload, x as FinishReason, y as CompactionRequestedPayload, z as ModelMetadataState } from "./ai-BMLlI2Ff.js";
2
- export { AIEventDefs, AIInputs, AIMessageOf, AIState, AI_STATE_REDUCER, ActiveGeneration, ActiveGenerationProjection, type ActiveTurn, AgentContract, AgentContractOptions, AgentReducer, AgentStatus, AgentToolContext, AgentToolEventDefs, ApprovalRequestedPayload, ApprovalRespondedPayload, CompactionCompletedPayload, CompactionPart, CompactionPartData, CompactionRequestedPayload, CompactionState, type ControlCommand, type ControlReceipt, type FinishReason, GenerationCompletedPayload, GenerationFailedPayload, GenerationProgressPayload, GenerationReason, GenerationRequestedPayload, GenerationStartedPayload, GenerationUsage, type InboxItem, InputRequestedPayload, InputRespondedPayload, type JSONValue, type LanguageModelUsage, MessageCompletedPayload, MessageCreatedPayload, MessageInterruptedPayload, ModelLimits, ModelMetadataRequestedPayload, ModelMetadataResolvedPayload, ModelMetadataState, PendingApproval, PendingInput, type PendingQueueCommand, ReasoningLevel, RetryGenerationOptions, RetryRequestedPayload, SessionClosedPayload, SessionCreatedPayload, ToolActivity, ToolCalledPayload, ToolResultPayload, TranscriptMessage, type UIMessage, type UIMessageChunk, agentContract, createEvents, deriveUIMessages, events, handlerContext, inputs, isCompactionPart, reasoningLevels, reduceAIState };
1
+ import { $ as agentContract, A as InputRespondedPayload, B as PendingApproval, C as GenerationFailedPayload, D as GenerationStartedPayload, E as GenerationRequestedPayload, F as MessageInterruptedPayload, G as SessionClosedPayload, H as ReasoningLevel, I as ModelLimits, J as ToolCalledPayload, K as SessionCreatedPayload, L as ModelMetadataRequestedPayload, M as LanguageModelUsage, N as MessageCompletedPayload, O as GenerationUsage, P as MessageCreatedPayload, Q as UIMessageChunk, R as ModelMetadataResolvedPayload, S as GenerationCompletedPayload, T as GenerationReason, U as RetryGenerationOptions, V as PendingInput, W as RetryRequestedPayload, X as TranscriptMessage, Y as ToolResultPayload, Z as UIMessage, _ as CompactionPart, a as AI_STATE_REDUCER, at as reasoningLevels, b as CompactionState, c as AgentContract, ct as PendingQueueCommand, d as AgentStatus, dt as ControlReceipt, et as createEvents, f as AgentToolContext, ft as InboxItem, g as CompactionCompletedPayload, h as ApprovalRespondedPayload, i as AIState, it as inputs, j as JSONValue, k as InputRequestedPayload, l as AgentContractOptions, lt as ActiveTurn, m as ApprovalRequestedPayload, n as AIInputs, nt as events, o as ActiveGeneration, ot as reduceAIState, p as AgentToolEventDefs, pt as InputRef, q as ToolActivity, r as AIMessageOf, rt as handlerContext, s as ActiveGenerationProjection, st as isCompactionPart, t as AIEventDefs, tt as deriveUIMessages, u as AgentReducer, ut as ControlCommand, v as CompactionPartData, w as GenerationProgressPayload, x as FinishReason, y as CompactionRequestedPayload, z as ModelMetadataState } from "./ai-Cz3hqVhk.js";
2
+ export { AIEventDefs, AIInputs, AIMessageOf, AIState, AI_STATE_REDUCER, ActiveGeneration, ActiveGenerationProjection, type ActiveTurn, AgentContract, AgentContractOptions, AgentReducer, AgentStatus, AgentToolContext, AgentToolEventDefs, ApprovalRequestedPayload, ApprovalRespondedPayload, CompactionCompletedPayload, CompactionPart, CompactionPartData, CompactionRequestedPayload, CompactionState, type ControlCommand, type ControlReceipt, type FinishReason, GenerationCompletedPayload, GenerationFailedPayload, GenerationProgressPayload, GenerationReason, GenerationRequestedPayload, GenerationStartedPayload, GenerationUsage, type InboxItem, type InputRef, InputRequestedPayload, InputRespondedPayload, type JSONValue, type LanguageModelUsage, MessageCompletedPayload, MessageCreatedPayload, MessageInterruptedPayload, ModelLimits, ModelMetadataRequestedPayload, ModelMetadataResolvedPayload, ModelMetadataState, PendingApproval, PendingInput, type PendingQueueCommand, ReasoningLevel, RetryGenerationOptions, RetryRequestedPayload, SessionClosedPayload, SessionCreatedPayload, ToolActivity, ToolCalledPayload, ToolResultPayload, TranscriptMessage, type UIMessage, type UIMessageChunk, agentContract, createEvents, deriveUIMessages, events, handlerContext, inputs, isCompactionPart, reasoningLevels, reduceAIState };
package/dist/ai.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as events, c as reasoningLevels, i as deriveUIMessages, l as reduceAIState, n as agentContract, o as handlerContext, r as createEvents, s as inputs, t as AI_STATE_REDUCER, x as isCompactionPart } from "./ai-D6mGlO8j.js";
1
+ import { a as events, c as reasoningLevels, i as deriveUIMessages, l as reduceAIState, n as agentContract, o as handlerContext, r as createEvents, s as inputs, t as AI_STATE_REDUCER, x as isCompactionPart } from "./ai-mMiWul1T.js";
2
2
  export { AI_STATE_REDUCER, agentContract, createEvents, deriveUIMessages, events, handlerContext, inputs, isCompactionPart, reasoningLevels, reduceAIState };
@@ -1 +1 @@
1
- {"version":3,"file":"scheduler-vercel.d.ts","names":[],"sources":["../src/scheduler-vercel.ts"],"mappings":";;;;;;;;;;;;cAqDa,uBAAuB;KAExB;EACV;EACA;;;KAIU;EACV,KACE,eACA,SAAS,eACT;IACE;IACA;IACA;MAED;;;;;;;;EAQH,eACE,UACE,kBACA,UAAU,0BACP,iBACH,KAAK,YAAY,QAAQ;;EAE7B,YAAY;;KAGF;;EAEV;;EAEA;;EAEA,YAAY;;EAEZ,QAAQ;;iBA8HM,aAAa,UAAS,sBAA2B"}
1
+ {"version":3,"file":"scheduler-vercel.d.ts","names":[],"sources":["../src/scheduler-vercel.ts"],"mappings":";;;;;;;;;;;;cAqDa,uBAAuB;KAExB;EACV;EACA;;;KAIU;EACV,KACE,eACA,SAAS,eACT;IACE;IACA;IACA;MAED;;;;;;;;EAQH,eACE,UACE,kBACA,UAAU,0BACP,iBACH,KAAK,YAAY,QAAQ;;EAE7B,YAAY;;KAGF;;EAEV;;EAEA;;EAEA,YAAY;;EAEZ,QAAQ;;iBA2HM,aAAa,UAAS,sBAA2B"}
@@ -39,10 +39,8 @@ import { createHash } from "node:crypto";
39
39
  var DrainUnsettled = class extends Error {};
40
40
  /** Vercel Queues rejects shorter visibility changes. */
41
41
  const MIN_VISIBILITY_SECONDS = 30;
42
- /** Vercel Queues' maximum message retention and delivery delay. */
42
+ /** Vercel Queues' maximum message retention; its default is a day. */
43
43
  const MAX_RETENTION_SECONDS = 604800;
44
- /** Leave the final retention day for delivery and retries. */
45
- const MAX_DELAY_SECONDS = 518400;
46
44
  const TOPIC_PATTERN = /^[A-Za-z0-9_-]+$/;
47
45
  const RETRYABLE_HTTP_CLIENT_STATUSES = /* @__PURE__ */ new Set([
48
46
  408,
@@ -119,7 +117,6 @@ function vercelQueues(options = {}) {
119
117
  const dueAt = requestedDueAt <= nowMs ? Math.floor(nowMs / 1e3) * 1e3 : Math.ceil(requestedDueAt / 1e3) * 1e3;
120
118
  const deliveryTargetAt = task.kind === "drain" ? dueAt : requestedDueAt;
121
119
  const deliveryDelaySeconds = Math.max(0, Math.ceil((deliveryTargetAt - nowMs) / 1e3));
122
- if (deliveryDelaySeconds > MAX_DELAY_SECONDS) throw classifySchedulerSendFailure(/* @__PURE__ */ new RangeError("vercelQueues() tasks cannot be delayed more than 6 days"), "terminal");
123
120
  const message = {
124
121
  ...task,
125
122
  dueAt
@@ -1 +1 @@
1
- {"version":3,"file":"scheduler-vercel.js","names":[],"sources":["../src/scheduler-vercel.ts"],"sourcesContent":["/**\n * experimental-a2/scheduler-vercel — queue-backed scheduler over Vercel Queues.\n *\n * `vercelQueues()` returns an `A2Scheduler` (a2-implementation.md §9):\n *\n * - drain tasks round the requested watchdog time up to a one-second slot.\n * One message per `(topic, contract, session, dueAt)` coalesces appends,\n * claim renewals and racing callbacks. `DuplicateMessageError` is\n * success because that due-time slot is already durable.\n * - append tasks hash their stable task id with the topic for provider\n * deduplication. Their six-day maximum delay leaves a full day for\n * delivery and retries inside the provider's seven-day retention.\n * Clear tasks follow the same identity and delay rules. All tasks use\n * the configured deployment scope for sending and receiving.\n * - `handler(...servers)` is the delivery route: look up the server for\n * the contract named in the message and drain it. Settled drains ack;\n * contention and handler failure arm a later watchdog before acking.\n *\n * `@vercel/queue` is an optional peer dependency, imported lazily. The\n * `transport` option is the injection seam — tests run a real little\n * in-memory queue through the same interface.\n */\n\nimport { createHash } from 'node:crypto'\nimport { SYSTEM_CLOCK, type Clock } from './store.ts'\nimport {\n assertSchedulerTargets,\n classifySchedulerSendFailure,\n DRAIN_TIMINGS,\n serverInternals,\n} from './internal.ts'\nimport { retryableLazy } from './retryable-lazy.ts'\nimport {\n normalizeSchedulerTask,\n type SchedulerDrainTask,\n type SchedulerTask,\n} from './scheduler-task.ts'\nimport {\n deliverSchedulerAppend,\n deliverSchedulerClear,\n type A2Scheduler,\n type DrainableServer,\n} from './server.ts'\n\n/**\n * The \"not settled yet\" signal for a drain whose outcome is unknown — an\n * expected state, not a transport failure. The default transport classifies\n * it in `@vercel/queue`'s `retry` option and reschedules the delivery quietly\n * (`{ afterSeconds }` → visibility change → 200 response, no error log)\n * instead of letting it surface as a thrown 500. Busy and stalled deliveries\n * use a fresh watchdog instead. Custom transports should treat this class the\n * same way when their protocol has a polite reschedule.\n */\nexport class DrainUnsettled extends Error {}\n\nexport type QueueDeliveryMetadata = {\n messageId: string\n deliveryCount: number\n}\n\n/** What this module needs from a Vercel Queues-compatible transport. */\nexport type QueueTransport = {\n send(\n topic: string,\n message: SchedulerTask,\n options?: {\n delaySeconds?: number\n idempotencyKey?: string\n retentionSeconds?: number\n },\n ): Promise<unknown>\n /**\n * Wrap the delivery handler into a route. The handler throws\n * `DrainUnsettled` when the session needs redelivery — transports\n * with a polite reschedule (visibility change) should use it there\n * instead of surfacing an error; anything else thrown is a genuine\n * failure and should stay loud.\n */\n handleCallback(\n handler: (\n message: unknown,\n metadata: QueueDeliveryMetadata,\n ) => Promise<void>,\n ): (req: Request) => Promise<Response>\n /** Is this send error the idempotency-key collision? (= success) */\n isDuplicate(error: unknown): boolean\n}\n\nexport type VercelQueuesOptions = {\n /** Queue topic matching `[A-Za-z0-9_-]+`. Default `'a2'`. */\n topic?: string\n /** Queue SDK deployment scope for all tasks. Default: VERCEL_DEPLOYMENT_ID; null opts out. */\n deploymentId?: string | null\n /** Injectable transport (tests). Default: `@vercel/queue`. */\n transport?: QueueTransport\n /** Injectable clock for due-time calculations (tests). */\n clock?: Clock\n}\n\n/** Vercel Queues rejects shorter visibility changes. */\nconst MIN_VISIBILITY_SECONDS = 30\n\n/** Vercel Queues' maximum message retention and delivery delay. */\nconst MAX_RETENTION_SECONDS = 7 * 24 * 60 * 60\n\n/** Leave the final retention day for delivery and retries. */\nconst MAX_DELAY_SECONDS = 6 * 24 * 60 * 60\n\nconst TOPIC_PATTERN = /^[A-Za-z0-9_-]+$/\nconst RETRYABLE_HTTP_CLIENT_STATUSES = new Set([408, 409, 425, 429])\n/** The SDK's send path maps these statuses to name-only error instances. */\nconst TERMINAL_VERCEL_SEND_ERROR_NAMES = new Set([\n 'BadRequestError',\n 'UnauthorizedError',\n 'ForbiddenError',\n])\n\nconst recordOf = (value: unknown): Record<string, unknown> | null =>\n value !== null && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : null\n\nconst httpStatusOf = (error: unknown): number | undefined => {\n try {\n const record = recordOf(error)\n if (!record) return undefined\n const response = recordOf(record['response'])\n const statuses = [\n record['status'],\n record['statusCode'],\n response?.['status'],\n ].filter(\n (value): value is number =>\n typeof value === 'number' &&\n Number.isInteger(value) &&\n value >= 100 &&\n value <= 599,\n )\n const [status] = statuses\n if (status === undefined) return undefined\n return statuses.every((candidate) => candidate === status)\n ? status\n : undefined\n } catch {\n return undefined\n }\n}\n\nconst errorNameOf = (error: unknown): string | undefined => {\n try {\n const name = recordOf(error)?.['name']\n return typeof name === 'string' ? name : undefined\n } catch {\n return undefined\n }\n}\n\nconst classifyVercelSendFailure = (error: unknown): unknown => {\n const status = httpStatusOf(error)\n const terminal =\n status === undefined\n ? TERMINAL_VERCEL_SEND_ERROR_NAMES.has(errorNameOf(error) ?? '')\n : status >= 400 &&\n status < 500 &&\n !RETRYABLE_HTTP_CLIENT_STATUSES.has(status)\n return classifySchedulerSendFailure(\n error,\n terminal ? 'terminal' : 'retryable',\n )\n}\n\nconst digestId = (parts: readonly string[]): string =>\n `a2_${createHash('sha256').update(JSON.stringify(parts)).digest('hex')}`\n\n/** Base of the unsettled redelivery backoff, doubled per delivery. */\nconst UNSETTLED_BACKOFF_BASE_SECONDS = 5\n\n/** Redelivery backoff for retry states: base, 2×, 4×… bounded. */\nconst unsettledBackoffSeconds = (deliveryCount: number): number =>\n Math.min(\n Math.max(\n UNSETTLED_BACKOFF_BASE_SECONDS * 2 ** Math.max(deliveryCount - 1, 0),\n MIN_VISIBILITY_SECONDS,\n ),\n 60,\n )\n\nconst vercelQueueTransport = async (\n clientOptions: Pick<VercelQueuesOptions, 'deploymentId'>,\n): Promise<QueueTransport> => {\n const queue = await import('@vercel/queue').catch(() => {\n throw classifySchedulerSendFailure(\n new Error(\n \"experimental-a2/scheduler-vercel needs the '@vercel/queue' package (optional peer dependency) — install it, or inject a transport\",\n ),\n 'terminal',\n )\n })\n const client = new queue.QueueClient(clientOptions)\n return {\n send: (topic, message, options) => client.send(topic, message, options),\n handleCallback: (handler) =>\n client.handleCallback(handler, {\n // A hard-killed callback normally has a fresh claim watchdog. If\n // its successor send also failed, make the old unacked delivery's\n // fallback as prompt as Vercel Queues permits.\n visibilityTimeoutSeconds: MIN_VISIBILITY_SECONDS,\n // Not-settled-yet is an expected state: reschedule the (still\n // unacked) delivery via a visibility change — 200, no error\n // log. Everything else returns undefined → the SDK's default\n // throw path, which is loud on purpose.\n retry: (error, metadata) =>\n error instanceof DrainUnsettled\n ? {\n afterSeconds: unsettledBackoffSeconds(metadata.deliveryCount),\n }\n : undefined,\n }) as (req: Request) => Promise<Response>,\n isDuplicate: (error) => error instanceof queue.DuplicateMessageError,\n }\n}\n\nexport function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {\n const topic = options.topic ?? 'a2'\n if (typeof topic !== 'string' || !TOPIC_PATTERN.test(topic)) {\n throw new TypeError('vercelQueues() topic must match [A-Za-z0-9_-]+')\n }\n const clock = options.clock ?? SYSTEM_CLOCK\n const clientOptions =\n options.deploymentId === undefined\n ? {}\n : { deploymentId: options.deploymentId }\n\n // Lazy: constructing the scheduler value does no I/O and works without\n // the peer dep until the first task or delivery.\n const transport = retryableLazy(() =>\n options.transport\n ? Promise.resolve(options.transport)\n : vercelQueueTransport(clientOptions),\n ).get\n\n const sendTask = async (task: SchedulerTask): Promise<void> => {\n const nowMs = clock.now().getTime()\n const requestedDueAt = task.dueAt\n if (!Number.isFinite(requestedDueAt)) {\n throw classifySchedulerSendFailure(\n new TypeError('vercel scheduler dueAt must be a finite number'),\n 'terminal',\n )\n }\n const dueAt =\n requestedDueAt <= nowMs\n ? Math.floor(nowMs / 1_000) * 1_000\n : Math.ceil(requestedDueAt / 1_000) * 1_000\n const deliveryTargetAt = task.kind === 'drain' ? dueAt : requestedDueAt\n const deliveryDelaySeconds = Math.max(\n 0,\n Math.ceil((deliveryTargetAt - nowMs) / 1_000),\n )\n if (deliveryDelaySeconds > MAX_DELAY_SECONDS) {\n throw classifySchedulerSendFailure(\n new RangeError(\n 'vercelQueues() tasks cannot be delayed more than 6 days',\n ),\n 'terminal',\n )\n }\n const message: SchedulerTask = { ...task, dueAt }\n const idempotencyKey =\n task.kind === 'drain'\n ? digestId([\n 'a2:scheduler-vercel:drain:v1',\n topic,\n task.contract,\n task.sessionId,\n String(dueAt),\n ])\n : task.kind === 'append'\n ? digestId(['a2:scheduler-vercel:append:v1', topic, task.id])\n : digestId(['a2:scheduler-vercel:clear:v1', topic, task.id])\n const t = await transport()\n try {\n await t.send(topic, message, {\n delaySeconds: deliveryDelaySeconds,\n idempotencyKey,\n retentionSeconds: MAX_RETENTION_SECONDS,\n })\n } catch (err) {\n if (t.isDuplicate(err)) return\n throw classifyVercelSendFailure(err)\n }\n }\n\n const successorDueAt = (message: SchedulerDrainTask): number => {\n const minimumDueAt =\n clock.now().getTime() +\n DRAIN_TIMINGS.claimTtlMs +\n DRAIN_TIMINGS.watchdogGraceMs\n const beats = Math.max(\n 1,\n Math.ceil(\n (minimumDueAt - message.dueAt) / DRAIN_TIMINGS.claimHeartbeatMs,\n ),\n )\n return message.dueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs\n }\n\n return {\n async schedule(task) {\n await sendTask(task)\n },\n\n handler(...servers: DrainableServer[]) {\n assertSchedulerTargets(servers)\n const byContract = new Map<string, DrainableServer>()\n for (const server of servers) {\n byContract.set(server.contract.name, server)\n }\n return async (req: Request): Promise<Response> => {\n const t = await transport()\n return t.handleCallback(async (delivered) => {\n const task = normalizeSchedulerTask(delivered)\n if (!task) {\n throw new TypeError('a2 scheduler received an invalid task')\n }\n const server = byContract.get(task.contract)\n if (!server) {\n // A message for a contract this route doesn't serve is a\n // wiring bug — throw so it redelivers and stays visible in\n // queue observability instead of vanishing on an ack.\n throw new Error(\n `a2 scheduler: no server for contract '${task.contract}' — pass it to scheduler.handler(...)`,\n )\n }\n const internals = serverInternals.get(server)\n if (task.kind === 'append') {\n await deliverSchedulerAppend(server, task)\n return\n }\n if (task.kind === 'clear') {\n await deliverSchedulerClear(server, task)\n return\n }\n\n const nextDueAt = successorDueAt(task)\n const result = internals\n ? await internals.schedulerDrain(task.sessionId, {\n watchdogDueAt: nextDueAt,\n })\n : {\n ...(await server.drain(task.sessionId)),\n outcome: 'unknown' as const,\n }\n if (result.settled) return\n\n if (result.outcome === 'busy' || result.outcome === 'stalled') {\n // The live owner arms its own claim windows, and the store holds\n // a failed event until its retry time. Ensure a later slot here\n // as well before relinquishing this durable delivery. If the queue is down, the\n // throw leaves the current message unacked for visibility-timeout\n // redelivery.\n if (result.schedulerArm) await result.schedulerArm\n else {\n await sendTask({\n version: 1,\n kind: 'drain',\n contract: task.contract,\n sessionId: task.sessionId,\n dueAt: nextDueAt,\n })\n }\n return\n }\n\n // A drain whose outcome is unknown keeps the current durable message\n // and uses the transport retry path.\n throw new DrainUnsettled(\n `a2 scheduler: session '${task.sessionId}' of '${task.contract}' not settled yet (${result.outcome}) — rescheduled for redelivery`,\n )\n })(req)\n }\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"scheduler-vercel.js","names":[],"sources":["../src/scheduler-vercel.ts"],"sourcesContent":["/**\n * experimental-a2/scheduler-vercel — queue-backed scheduler over Vercel Queues.\n *\n * `vercelQueues()` returns an `A2Scheduler` (a2-implementation.md §9):\n *\n * - drain tasks round the requested watchdog time up to a one-second slot.\n * One message per `(topic, contract, session, dueAt)` coalesces appends,\n * claim renewals and racing callbacks. `DuplicateMessageError` is\n * success because that due-time slot is already durable.\n * - append tasks hash their stable task id with the topic for provider\n * deduplication. Their six-day maximum delay leaves a full day for\n * delivery and retries inside the provider's seven-day retention.\n * Clear tasks follow the same identity and delay rules. All tasks use\n * the configured deployment scope for sending and receiving.\n * - `handler(...servers)` is the delivery route: look up the server for\n * the contract named in the message and drain it. Settled drains ack;\n * contention and handler failure arm a later watchdog before acking.\n *\n * `@vercel/queue` is an optional peer dependency, imported lazily. The\n * `transport` option is the injection seam — tests run a real little\n * in-memory queue through the same interface.\n */\n\nimport { createHash } from 'node:crypto'\nimport { SYSTEM_CLOCK, type Clock } from './store.ts'\nimport {\n assertSchedulerTargets,\n classifySchedulerSendFailure,\n DRAIN_TIMINGS,\n serverInternals,\n} from './internal.ts'\nimport { retryableLazy } from './retryable-lazy.ts'\nimport {\n normalizeSchedulerTask,\n type SchedulerDrainTask,\n type SchedulerTask,\n} from './scheduler-task.ts'\nimport {\n deliverSchedulerAppend,\n deliverSchedulerClear,\n type A2Scheduler,\n type DrainableServer,\n} from './server.ts'\n\n/**\n * The \"not settled yet\" signal for a drain whose outcome is unknown — an\n * expected state, not a transport failure. The default transport classifies\n * it in `@vercel/queue`'s `retry` option and reschedules the delivery quietly\n * (`{ afterSeconds }` → visibility change → 200 response, no error log)\n * instead of letting it surface as a thrown 500. Busy and stalled deliveries\n * use a fresh watchdog instead. Custom transports should treat this class the\n * same way when their protocol has a polite reschedule.\n */\nexport class DrainUnsettled extends Error {}\n\nexport type QueueDeliveryMetadata = {\n messageId: string\n deliveryCount: number\n}\n\n/** What this module needs from a Vercel Queues-compatible transport. */\nexport type QueueTransport = {\n send(\n topic: string,\n message: SchedulerTask,\n options?: {\n delaySeconds?: number\n idempotencyKey?: string\n retentionSeconds?: number\n },\n ): Promise<unknown>\n /**\n * Wrap the delivery handler into a route. The handler throws\n * `DrainUnsettled` when the session needs redelivery — transports\n * with a polite reschedule (visibility change) should use it there\n * instead of surfacing an error; anything else thrown is a genuine\n * failure and should stay loud.\n */\n handleCallback(\n handler: (\n message: unknown,\n metadata: QueueDeliveryMetadata,\n ) => Promise<void>,\n ): (req: Request) => Promise<Response>\n /** Is this send error the idempotency-key collision? (= success) */\n isDuplicate(error: unknown): boolean\n}\n\nexport type VercelQueuesOptions = {\n /** Queue topic matching `[A-Za-z0-9_-]+`. Default `'a2'`. */\n topic?: string\n /** Queue SDK deployment scope for all tasks. Default: VERCEL_DEPLOYMENT_ID; null opts out. */\n deploymentId?: string | null\n /** Injectable transport (tests). Default: `@vercel/queue`. */\n transport?: QueueTransport\n /** Injectable clock for due-time calculations (tests). */\n clock?: Clock\n}\n\n/** Vercel Queues rejects shorter visibility changes. */\nconst MIN_VISIBILITY_SECONDS = 30\n\n/** Vercel Queues' maximum message retention; its default is a day. */\nconst MAX_RETENTION_SECONDS = 7 * 24 * 60 * 60\n\nconst TOPIC_PATTERN = /^[A-Za-z0-9_-]+$/\nconst RETRYABLE_HTTP_CLIENT_STATUSES = new Set([408, 409, 425, 429])\n/** The SDK's send path maps these statuses to name-only error instances. */\nconst TERMINAL_VERCEL_SEND_ERROR_NAMES = new Set([\n 'BadRequestError',\n 'UnauthorizedError',\n 'ForbiddenError',\n])\n\nconst recordOf = (value: unknown): Record<string, unknown> | null =>\n value !== null && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : null\n\nconst httpStatusOf = (error: unknown): number | undefined => {\n try {\n const record = recordOf(error)\n if (!record) return undefined\n const response = recordOf(record['response'])\n const statuses = [\n record['status'],\n record['statusCode'],\n response?.['status'],\n ].filter(\n (value): value is number =>\n typeof value === 'number' &&\n Number.isInteger(value) &&\n value >= 100 &&\n value <= 599,\n )\n const [status] = statuses\n if (status === undefined) return undefined\n return statuses.every((candidate) => candidate === status)\n ? status\n : undefined\n } catch {\n return undefined\n }\n}\n\nconst errorNameOf = (error: unknown): string | undefined => {\n try {\n const name = recordOf(error)?.['name']\n return typeof name === 'string' ? name : undefined\n } catch {\n return undefined\n }\n}\n\nconst classifyVercelSendFailure = (error: unknown): unknown => {\n const status = httpStatusOf(error)\n const terminal =\n status === undefined\n ? TERMINAL_VERCEL_SEND_ERROR_NAMES.has(errorNameOf(error) ?? '')\n : status >= 400 &&\n status < 500 &&\n !RETRYABLE_HTTP_CLIENT_STATUSES.has(status)\n return classifySchedulerSendFailure(\n error,\n terminal ? 'terminal' : 'retryable',\n )\n}\n\nconst digestId = (parts: readonly string[]): string =>\n `a2_${createHash('sha256').update(JSON.stringify(parts)).digest('hex')}`\n\n/** Base of the unsettled redelivery backoff, doubled per delivery. */\nconst UNSETTLED_BACKOFF_BASE_SECONDS = 5\n\n/** Redelivery backoff for retry states: base, 2×, 4×… bounded. */\nconst unsettledBackoffSeconds = (deliveryCount: number): number =>\n Math.min(\n Math.max(\n UNSETTLED_BACKOFF_BASE_SECONDS * 2 ** Math.max(deliveryCount - 1, 0),\n MIN_VISIBILITY_SECONDS,\n ),\n 60,\n )\n\nconst vercelQueueTransport = async (\n clientOptions: Pick<VercelQueuesOptions, 'deploymentId'>,\n): Promise<QueueTransport> => {\n const queue = await import('@vercel/queue').catch(() => {\n throw classifySchedulerSendFailure(\n new Error(\n \"experimental-a2/scheduler-vercel needs the '@vercel/queue' package (optional peer dependency) — install it, or inject a transport\",\n ),\n 'terminal',\n )\n })\n const client = new queue.QueueClient(clientOptions)\n return {\n send: (topic, message, options) => client.send(topic, message, options),\n handleCallback: (handler) =>\n client.handleCallback(handler, {\n // A hard-killed callback normally has a fresh claim watchdog. If\n // its successor send also failed, make the old unacked delivery's\n // fallback as prompt as Vercel Queues permits.\n visibilityTimeoutSeconds: MIN_VISIBILITY_SECONDS,\n // Not-settled-yet is an expected state: reschedule the (still\n // unacked) delivery via a visibility change — 200, no error\n // log. Everything else returns undefined → the SDK's default\n // throw path, which is loud on purpose.\n retry: (error, metadata) =>\n error instanceof DrainUnsettled\n ? {\n afterSeconds: unsettledBackoffSeconds(metadata.deliveryCount),\n }\n : undefined,\n }) as (req: Request) => Promise<Response>,\n isDuplicate: (error) => error instanceof queue.DuplicateMessageError,\n }\n}\n\nexport function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {\n const topic = options.topic ?? 'a2'\n if (typeof topic !== 'string' || !TOPIC_PATTERN.test(topic)) {\n throw new TypeError('vercelQueues() topic must match [A-Za-z0-9_-]+')\n }\n const clock = options.clock ?? SYSTEM_CLOCK\n const clientOptions =\n options.deploymentId === undefined\n ? {}\n : { deploymentId: options.deploymentId }\n\n // Lazy: constructing the scheduler value does no I/O and works without\n // the peer dep until the first task or delivery.\n const transport = retryableLazy(() =>\n options.transport\n ? Promise.resolve(options.transport)\n : vercelQueueTransport(clientOptions),\n ).get\n\n const sendTask = async (task: SchedulerTask): Promise<void> => {\n const nowMs = clock.now().getTime()\n const requestedDueAt = task.dueAt\n if (!Number.isFinite(requestedDueAt)) {\n throw classifySchedulerSendFailure(\n new TypeError('vercel scheduler dueAt must be a finite number'),\n 'terminal',\n )\n }\n const dueAt =\n requestedDueAt <= nowMs\n ? Math.floor(nowMs / 1_000) * 1_000\n : Math.ceil(requestedDueAt / 1_000) * 1_000\n const deliveryTargetAt = task.kind === 'drain' ? dueAt : requestedDueAt\n const deliveryDelaySeconds = Math.max(\n 0,\n Math.ceil((deliveryTargetAt - nowMs) / 1_000),\n )\n const message: SchedulerTask = { ...task, dueAt }\n const idempotencyKey =\n task.kind === 'drain'\n ? digestId([\n 'a2:scheduler-vercel:drain:v1',\n topic,\n task.contract,\n task.sessionId,\n String(dueAt),\n ])\n : task.kind === 'append'\n ? digestId(['a2:scheduler-vercel:append:v1', topic, task.id])\n : digestId(['a2:scheduler-vercel:clear:v1', topic, task.id])\n const t = await transport()\n try {\n await t.send(topic, message, {\n delaySeconds: deliveryDelaySeconds,\n idempotencyKey,\n retentionSeconds: MAX_RETENTION_SECONDS,\n })\n } catch (err) {\n if (t.isDuplicate(err)) return\n throw classifyVercelSendFailure(err)\n }\n }\n\n const successorDueAt = (message: SchedulerDrainTask): number => {\n const minimumDueAt =\n clock.now().getTime() +\n DRAIN_TIMINGS.claimTtlMs +\n DRAIN_TIMINGS.watchdogGraceMs\n const beats = Math.max(\n 1,\n Math.ceil(\n (minimumDueAt - message.dueAt) / DRAIN_TIMINGS.claimHeartbeatMs,\n ),\n )\n return message.dueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs\n }\n\n return {\n async schedule(task) {\n await sendTask(task)\n },\n\n handler(...servers: DrainableServer[]) {\n assertSchedulerTargets(servers)\n const byContract = new Map<string, DrainableServer>()\n for (const server of servers) {\n byContract.set(server.contract.name, server)\n }\n return async (req: Request): Promise<Response> => {\n const t = await transport()\n return t.handleCallback(async (delivered) => {\n const task = normalizeSchedulerTask(delivered)\n if (!task) {\n throw new TypeError('a2 scheduler received an invalid task')\n }\n const server = byContract.get(task.contract)\n if (!server) {\n // A message for a contract this route doesn't serve is a\n // wiring bug — throw so it redelivers and stays visible in\n // queue observability instead of vanishing on an ack.\n throw new Error(\n `a2 scheduler: no server for contract '${task.contract}' — pass it to scheduler.handler(...)`,\n )\n }\n const internals = serverInternals.get(server)\n if (task.kind === 'append') {\n await deliverSchedulerAppend(server, task)\n return\n }\n if (task.kind === 'clear') {\n await deliverSchedulerClear(server, task)\n return\n }\n\n const nextDueAt = successorDueAt(task)\n const result = internals\n ? await internals.schedulerDrain(task.sessionId, {\n watchdogDueAt: nextDueAt,\n })\n : {\n ...(await server.drain(task.sessionId)),\n outcome: 'unknown' as const,\n }\n if (result.settled) return\n\n if (result.outcome === 'busy' || result.outcome === 'stalled') {\n // The live owner arms its own claim windows, and the store holds\n // a failed event until its retry time. Ensure a later slot here\n // as well before relinquishing this durable delivery. If the queue is down, the\n // throw leaves the current message unacked for visibility-timeout\n // redelivery.\n if (result.schedulerArm) await result.schedulerArm\n else {\n await sendTask({\n version: 1,\n kind: 'drain',\n contract: task.contract,\n sessionId: task.sessionId,\n dueAt: nextDueAt,\n })\n }\n return\n }\n\n // A drain whose outcome is unknown keeps the current durable message\n // and uses the transport retry path.\n throw new DrainUnsettled(\n `a2 scheduler: session '${task.sessionId}' of '${task.contract}' not settled yet (${result.outcome}) — rescheduled for redelivery`,\n )\n })(req)\n }\n },\n }\n}\n"],"mappings":"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"}
@@ -446,9 +446,11 @@ export function Queue({ sessionId }: { sessionId: string }) {
446
446
  ```
447
447
 
448
448
  `sendMessage()` puts a message in the durable inbox, `state.inbox.items`. The
449
- controller admits one input at a time, freezes its content, and adds it to
450
- `state.messages`; pending inputs never enter the model prompt. `state.active`
451
- is the logical turn: its ID, frozen `input`, and `phase`. The turn ID holds
449
+ controller admits one input at a time and adds it to `state.messages`; pending
450
+ inputs never enter the model prompt, and an edit after admission is rejected.
451
+ The log stores a message once, in the command that sent it or the edit that
452
+ last changed it. `state.active` is
453
+ the logical turn: its ID, its `input`, and `phase`. The turn ID holds
452
454
  across model steps, tools, and a pause. Stop ends the turn and admits the next
453
455
  input when admission is running.
454
456
 
@@ -466,15 +468,20 @@ replay cannot recreate the prompt. Removing an input that is no longer pending
466
468
  (admitted, removed, or never queued) returns `not-pending`, as does an edit that
467
469
  loses to admission; a Stop aimed at a finished turn returns `stale-turn`.
468
470
 
469
- `steer()` puts new input first. If the observed turn is still active, it
470
- finishes its current model response and that response's tools, including
471
- authorized deferred provider results, then hands off before another model
472
- step. Output produced after the click stays, and unanswered approval and input
473
- requests close, so the unapproved tool never runs; a denied approval never
474
- blocks the handoff. With no active turn, `steer()` sends like `sendMessage()`.
475
-
476
- `queue.sendNow()` makes the same handoff with a queued message, keeping its ID
477
- and latest accepted content and promoting passive user input to generation.
471
+ `steer()` puts new input right after the turn's earlier steering, or first
472
+ when there is none, so steering hands off in the order it was sent. If the observed turn
473
+ is still active, it finishes its current model response and that response's
474
+ tools, including authorized deferred provider results, then hands off before
475
+ another model step. Everything steering that turn enters together as one new
476
+ turn: the earlier messages as context and the last one generating, so the model
477
+ answers them once. Output produced after the click stays, and unanswered
478
+ approval and input requests close, so the unapproved tool never runs; a denied
479
+ approval never blocks the handoff. With no active turn, `steer()` sends like
480
+ `sendMessage()`.
481
+
482
+ `queue.sendNow()` makes the same handoff with a queued message, after the
483
+ steering sent before it, keeping its ID and latest accepted content and
484
+ promoting passive user input to generation.
478
485
  Non-user context returns `not-user-input`, and a stale observed turn returns
479
486
  `stale-turn` without changing the queue. Send now uses the accepted content, so
480
487
  save editor text first. `steer()` and `queue.sendNow()` both preserve pause: a
@@ -483,7 +490,8 @@ immediate interruption.
483
490
 
484
491
  A queued item's optional `afterStepOf` records the observed turn for steering.
485
492
  Editing keeps it, removing the item cancels it, and queue order decides which
486
- input is admitted at the handoff. See [`inputs`](/reference/ai#inputs) for the
493
+ inputs are admitted at the handoff: the run at the front that steers the same
494
+ turn. See [`inputs`](/reference/ai#inputs) for the
487
495
  full handoff rules.
488
496
 
489
497
  ### Input identity
@@ -531,6 +539,10 @@ finish. `pause({ when: 'now' })` also cancels current model and tool work;
531
539
  `state.active.phase` is `pausing` while old work settles, then `paused`.
532
540
  `resume()` resumes retained work before admitting another input.
533
541
 
542
+ A turn paused with Pause now keeps its steering until Resume. Every
543
+ `steer()` or `queue.sendNow()` sent while it's paused targets it, and on Resume
544
+ they enter together as one turn; the paused turn's own work does not resume.
545
+
534
546
  Resume starts a fresh model request from durable state; it does not resume the
535
547
  provider's old stream or undo completed tool effects. While paused, accepted
536
548
  tool calls are retained with their outcomes recorded, and the turn holds no
@@ -110,17 +110,63 @@ Both harnesses call models through Vercel AI Gateway. `model` is a Gateway
110
110
  model ID, or [`ModelSettings`](/reference/ai#modelsettings) with one, resolved
111
111
  for each step like any agent's model; `pi()` takes the Gateway models Pi's own
112
112
  catalog lists. `reasoning` becomes FX's effort or Pi's thinking level, and
113
- `speed: 'fast'` asks FX for the faster tier. Credentials come from
114
- `AI_GATEWAY_API_KEY`, or from Vercel's OIDC token on Vercel. The harness reads
115
- the text of each user message; files and seeded assistant messages do not
116
- reach it.
113
+ `speed: 'fast'` asks AI Gateway for the model's faster tier under either
114
+ harness. Credentials come from `AI_GATEWAY_API_KEY`, or from Vercel's OIDC
115
+ token on Vercel. Seeded assistant messages do not reach the harness.
116
+
117
+ ## Images and files
118
+
119
+ The harness reads each user message's text and its image files. Neither FX nor
120
+ Pi takes URLs, so A2 fetches each image from its `https:` or `data:` URL and
121
+ hands the harness its bytes. The fetch goes through the
122
+ AI SDK's download, which refuses private addresses, checks each redirect, and
123
+ stops past 32 MiB of one message's files. Each step of a response replays its
124
+ turn, so a process keeps the images it fetched instead of fetching them again.
125
+
126
+ A file that isn't an image, or that A2 can't fetch, fails the turn with an
127
+ error that names it. So does a steer with files under FX, which steers with
128
+ text only. The harness applies its own rules to the images it gets: FX fails a
129
+ turn whose model doesn't take images, and Pi resizes them. Every step sends the
130
+ conversation so far, files and all, so a file that fails one turn fails the
131
+ turns after it too.
132
+
133
+ A2 fetches whatever URL a user message names, so check file URLs on the way
134
+ in. An agent's `messageSchema` can require them to point at your storage:
135
+
136
+ ```ts server/reviewer.ts
137
+ import type { UIMessage } from 'ai'
138
+ import { agent } from 'experimental-a2/ai/server'
139
+ import { fx } from 'experimental-a2/ai/fx'
140
+ import { z } from 'zod'
141
+
142
+ // your storage's public URL:
143
+ const uploads = 'https://uploads.example.com/'
144
+
145
+ export const reviewer = agent({
146
+ name: 'reviewer',
147
+ harness: fx(),
148
+ model: 'anthropic/claude-sonnet-5.5',
149
+ instructions: 'Review the screenshots the user sends. Keep replies short.',
150
+ messageSchema: z
151
+ .custom<UIMessage>()
152
+ .refine(
153
+ (message) =>
154
+ message.role !== 'user' ||
155
+ message.parts.every(
156
+ (part) => part.type !== 'file' || part.url.startsWith(uploads),
157
+ ),
158
+ "A user's files must be uploads",
159
+ ),
160
+ })
161
+ ```
117
162
 
118
163
  ## What the harness owns
119
164
 
120
165
  The harness owns its loop, its prompt, and its compaction, so `generation`,
121
166
  `generate`, `compaction`, and `messageMetadata` exist only in
122
167
  `{ type: 'ai-sdk' }`. A harness runs its tools without approval, so a tool that
123
- sets `needsApproval` is rejected. `maxSteps` limits the model calls in one
168
+ sets `needsApproval` is rejected. It hands the engine only tools A2 runs, so a
169
+ provider-defined tool, such as a provider's web search, is rejected too. `maxSteps` limits the model calls in one
124
170
  response under every harness. Steering enters at the next step, as with any
125
171
  agent: the harness sees the steered message before its next model call.
126
172
 
@@ -109,7 +109,7 @@ append returned.
109
109
  Delivery runs the same conditional clear and finishes the deletion before it
110
110
  acknowledges, so provider retries complete an interrupted delete. No event is
111
111
  appended. The clear happens at delivery, not at an exact deadline, within
112
- provider limits: Vercel Queues accepts delays up to six days.
112
+ provider limits: Vercel Queues accepts delays up to seven days.
113
113
 
114
114
  ## Sessions and your database
115
115
 
@@ -39,8 +39,8 @@ state: serve it with `agent({ contract })` and use it with
39
39
  through.
40
40
  - An `initialState` key that `AIState` also has throws a `TypeError` and fails
41
41
  to typecheck.
42
- - The reducer name is `AI_STATE_REDUCER` (`a2.ai.state.v26`) without
43
- `reducer`, else `${reducer.name}+a2.ai.state.v26`, so checkpoints advance when
42
+ - The reducer name is `AI_STATE_REDUCER` (`a2.ai.state.v27`) without
43
+ `reducer`, else `${reducer.name}+a2.ai.state.v27`, so checkpoints advance when
44
44
  either fold changes. `AgentReducer` types that name (so
45
45
  [`createAgentClient()`](#createagentclientoptions) accepts a plain
46
46
  `agentContract()`) and carries `X` to `agent()`'s resolvers.
@@ -61,9 +61,9 @@ even when `M` declares its own data parts.
61
61
 
62
62
  ### `AIMessageOf<Events>`
63
63
 
64
- The `UIMessage` type a contract's `ai.message.created` schema carries, such as
65
- `AIMessageOf<typeof contract.events>`; `UIMessage` otherwise. `agent()` types
66
- messages with it.
64
+ The `UIMessage` type a contract's input commands (`ai.control.requested`)
65
+ carry, such as `AIMessageOf<typeof contract.events>`; `UIMessage` otherwise.
66
+ `agent()` types messages with it.
67
67
 
68
68
  ### `handlerContext(contract?)`
69
69
 
@@ -105,13 +105,13 @@ Pure typed builders of `ai.control.requested` commands, for `append()` and
105
105
  | `inputs.requestInput(request)` | requests application input from trusted server code |
106
106
  | `inputs.retry(options)` | retries a failed response |
107
107
  | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances when admission is open |
108
- | `inputs.steer({ turnId, message, lastSeenIndex? })` | puts the new input first for a handoff after the observed turn's current step |
108
+ | `inputs.steer({ turnId, message, lastSeenIndex? })` | queues the new input right after the observed turn's earlier steering, or first, for a handoff after its current step; the turn's steering enters together, its last generating input starting the turn |
109
109
  | `inputs.queue.edit({ message })` | changes a pending input; its ID stays fixed |
110
110
  | `inputs.queue.move({ inputId, beforeId })` | moves pending input before an ID; `null` means the end |
111
111
  | `inputs.queue.remove({ inputId })` | removes pending input; its submission stays in the log |
112
- | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input for a handoff after the current step; preserves pause |
112
+ | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input, after the observed turn's earlier steering, for a handoff after the current step; preserves pause |
113
113
  | `inputs.pause({ when: 'now' \| 'after-turn' })` | pauses execution now or inbox admission after the turn |
114
- | `inputs.resume()` | resumes retained work, then inbox admission |
114
+ | `inputs.resume()` | resumes retained work, then inbox admission; a turn paused now with steering hands it off instead |
115
115
  | `inputs.toolResult(result)` | records a deferred provider-executed tool result from trusted server code |
116
116
  | `inputs.interrupt(options)` | interrupts a specific generation/request owner |
117
117
 
@@ -174,7 +174,9 @@ Pure typed builders of `ai.control.requested` commands, for `append()` and
174
174
  - **Interruption.** `ai.message.interrupted` is `{ messageId, requestId,
175
175
  reason?, lastSeenIndex? }` or `{ messageId, generationId, reason?,
176
176
  lastSeenIndex? }`: copy `activeRequestId` before the generation starts,
177
- `activeGeneration.generationId` after. A request-owned interruption survives
177
+ `activeGeneration.generationId` after. A2 writes the reason
178
+ `'steered after step'` itself when steering ends a response, so an
179
+ interruption you send can't use it. A request-owned interruption survives
178
180
  that request starting first. An older or mismatched message/owner pair is a
179
181
  no-op. An accepted one fences its owner for good: later events stay in raw
180
182
  history but cannot alter `AIState`, satisfy pending work, or reactivate the
@@ -249,7 +251,7 @@ extra I/O or raw-history reads.
249
251
  win.
250
252
  - Steer, and Send now against an active turn, show in `state.messages` after
251
253
  the current response, not the inbox, until admission; they stay out of the
252
- prompt. Several steering inputs show in admission order, most recent first,
254
+ prompt. Several steering inputs show in the order they were sent,
253
255
  and the display survives receipt and hydration, including while paused.
254
256
  - A pending Stop clears the displayed `state.active`, generation, and pending
255
257
  approvals and inputs, previews an idle status (paused when the inbox is), and
@@ -274,8 +276,8 @@ events. Encoding is internal; no event or tool payload is truncated.
274
276
  | Field | What it holds |
275
277
  | --- | --- |
276
278
  | `messages` | [`TranscriptMessage<M>`](#transcriptmessagem) values; server-side, without pending inputs |
277
- | `inbox` | `{ paused, items }`; items are `{ id, message, generate, afterStepOf? }`, where `afterStepOf` is an observed steering turn that hands off after its current step (removal cancels it, admission consumes it) |
278
- | `active` | `null` or `{ turnId, input, phase }`; admission freezes `input` |
279
+ | `inbox` | `{ paused, items }`; items are `{ id, role, message, generate, afterStepOf? }`, where `afterStepOf` is an observed steering turn that hands off after its current step (removal cancels it, admission consumes it) |
280
+ | `active` | `null` or `{ turnId, input, phase }`, where `input` is the admitted input's `{ id, role, generate }` |
279
281
  | `status` | generation status |
280
282
  | `starting` | a speculative optimistic turn |
281
283
  | `receipt` | the latest user command receipt |
@@ -311,7 +313,10 @@ See [What happens after a send](/agents/introduction#what-happens-after-a-send).
311
313
 
312
314
  `deriveUIMessages()` folds a raw event history into `TranscriptMessage<M>[]`.
313
315
  `reduceAIState()` is the event-at-a-time form the standard reducer uses. Both
314
- project the same chunk-only protocol synchronously.
316
+ project the same chunk-only protocol synchronously. A message's content is in
317
+ the command that brought it in, so a fold needs each `ai.message.created`'s
318
+ command before it; one without throws. Only A2's control lane writes
319
+ `ai.message.created`.
315
320
 
316
321
  ## `experimental-a2/ai/server`
317
322
 
@@ -525,7 +530,11 @@ name the harness's own tools, such as Pi's `bash`. `model` is resolved per step
525
530
  as for any agent. `instructions` and `tools` reach the harness; how they
526
531
  combine with its own prompt and tools depends on the harness. Tool approval
527
532
  policy lives in `harness.generation`, so a harness agent's tools run without
528
- approval, and a tool that sets `needsApproval` is rejected.
533
+ approval, and a tool that sets `needsApproval` is rejected. So is a
534
+ provider-defined tool (`type: 'provider'`), which the engine can't run. A user
535
+ message reaches the harness as its text and its images, which A2 fetches from
536
+ their `https:` or `data:` URLs; any other file fails the turn. See
537
+ [Images and files](/agents/harnesses#images-and-files).
529
538
 
530
539
  A harness resumes after a crash at the interrupted step. Finished model calls
531
540
  are not made again and finished tool calls do not run again. A2 rebuilds the
@@ -589,8 +598,8 @@ continue a response, A2 restores FX's latest checkpoint and replays the
589
598
  response so far from the log: recorded model responses and tool results answer
590
599
  the kernel without network calls or tool runs, then FX makes its next model
591
600
  call. Replay grows with the response, roughly 0.1 to 0.5 seconds for one of 80
592
- to 200 tool calls. FX checkpoints hold at most 4 MiB of conversation; past that,
593
- A2 replays from an earlier checkpoint.
601
+ to 200 tool calls. When libfx can't take a checkpoint, A2 replays from an
602
+ earlier one.
594
603
 
595
604
  Credentials come from `AI_GATEWAY_API_KEY`, or Vercel's OIDC token on Vercel.
596
605
  This entry point is server-only.
@@ -617,8 +626,8 @@ forbids, such as a property it does not list. A call to one of the agent's tools
617
626
  then goes through the tool's schema, as under `{ type: 'ai-sdk' }`: `execute`
618
627
  receives the schema's output, defaults included. Input either check rejects goes
619
628
  back to the model as an error, without running the tool. `reasoning` becomes
620
- Pi's thinking level, and `model` is one of the Gateway models Pi's catalog
621
- lists. Pi works in the sandbox's working directory; its `bash` runs with the
629
+ Pi's thinking level and `speed: 'fast'` asks AI Gateway for the model's fast
630
+ tier; `model` is one of the Gateway models Pi's catalog lists. Pi works in the sandbox's working directory; its `bash` runs with the
622
631
  sandbox's environment, never the server's. Pi compacts its own context; a
623
632
  compaction appears in the transcript as a `data-a2-compaction` part with its
624
633
  summary.
@@ -189,8 +189,8 @@ export const scheduler = vercelQueues()
189
189
  Deploymentless delivery lets Vercel choose an eligible consumer, not the
190
190
  latest production deployment; every eligible consumer must use
191
191
  `deploymentId: null` and stay compatible with pending tasks and session work.
192
- - Tasks are retained for seven days. Delays are accepted through six days,
193
- leaving the final day for delivery and retries.
192
+ - Tasks are retained for seven days, Vercel Queues' maximum. A delay goes out
193
+ as asked; Vercel Queues refuses one longer than the retention.
194
194
  - `transport` and `clock` are advanced injection seams for tests and custom
195
195
  delivery. An injected transport receives the topic, an exact
196
196
  `SchedulerTask`, and send options, and owns its provider configuration;
@@ -119,7 +119,7 @@ handler schedule the next one.
119
119
 
120
120
  | Adapter | Maximum one-shot delay |
121
121
  | --- | --- |
122
- | Vercel Queues | 6 days |
122
+ | Vercel Queues | 7 days |
123
123
  | QStash free | 7 days |
124
124
  | QStash usage-based | 1 year |
125
125
  | QStash fixed | no fixed maximum |
@@ -143,8 +143,8 @@ The trigger is required. It connects the route to Vercel Queues and makes the
143
143
  function private, so only queue infrastructure can invoke it. Don't expose the
144
144
  callback as a public route or wrap it in a second authentication scheme.
145
145
 
146
- The adapter keeps messages for 7 days (Vercel Queues' default is 24 hours) and
147
- accepts scheduled appends and clears up to 6 days out.
146
+ The adapter keeps messages for 7 days (Vercel Queues' default is 24 hours), so
147
+ scheduled appends and clears can be up to 7 days out.
148
148
 
149
149
  ### QStash
150
150
 
@@ -116,6 +116,15 @@ of a public deployment. A0's sandboxes are named `a2-a0-*`, which is where to
116
116
  look when cleaning up. The sidebar lists the apps this browser opened, from a
117
117
  cookie.
118
118
 
119
+ Files attached in A0's chat upload from the browser straight to a public
120
+ Vercel Blob store, through a token from `/a0/uploads`. Connect a Blob store to
121
+ the project, which sets `BLOB_READ_WRITE_TOKEN`; `vercel env pull` brings it
122
+ to local runs. Images go to the model with the message; other files go into
123
+ its text as links, which the agent downloads in its sandbox. Each lives at an
124
+ unguessable public URL under `a0/<app id>/`, which anyone with the URL can
125
+ read. Nothing deletes them: the conversation that stays in the log still links
126
+ them.
127
+
119
128
  A0's dev tools have a Crash button, which ends the function instance running
120
129
  an app's agent, mid-turn, so the scheduler resumes the turn in a fresh one.
121
130
  Crashing ends everything else that instance is serving, and under `next dev`