@telnyx/agent-harness 0.1.0-beta.0 → 0.1.0-beta.1

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/README.md CHANGED
@@ -7,7 +7,7 @@ execution. The Node-only test adapter is intentionally isolated to a subpath.
7
7
  ## Beta and release gate
8
8
 
9
9
  This source change does not publish a package. The planned initial release is
10
- `0.1.0-beta.0` under the `beta` tag. The package remains `UNLICENSED`; an owner
10
+ `0.1.0-beta.1` under the `beta` tag. The package remains `UNLICENSED`; an owner
11
11
  must approve npm organization/package ownership, distribution terms, and trusted
12
12
  publisher bootstrap before the main-only publication workflow is dispatched. A
13
13
  registry-installed `@telnyx/edge-runtime@0.15.2` is required: the runtime
@@ -61,6 +61,56 @@ const harness = createHarness({
61
61
  const result = await harness.turn("Summarize this input");
62
62
  console.log(result.text);
63
63
  ```
64
+ ## Speculative turns (begin / commit / discard)
65
+
66
+ `harness.begin(input, { signal })` starts the model once its activation is available and returns a
67
+ speculative turn handle. Nothing durable is written until `commit(confirmed)`
68
+ is called — no user row, no assistant row, no tool-result row, no SQL run row.
69
+ The handle exposes a `textStream` (async iterable of text deltas), a `result`
70
+ promise, `commit(confirmedText)`, and `discard()`.
71
+
72
+ ```ts
73
+ const turn = harness.begin("guess", { signal });
74
+ // confirmedTranscript is the recognizer's Promise<string | undefined>.
75
+ // Confirmation must run concurrently: stream completion can wait for commit.
76
+ const commitment = confirmedTranscript.then((text) =>
77
+ text === undefined ? turn.discard() : turn.commit(text));
78
+ void commitment.catch(() => {}); // observe even if the consumer exits early
79
+ try {
80
+ for await (const delta of turn.textStream) prepareProvisionalText(delta);
81
+ await commitment;
82
+ await turn.result;
83
+ } finally {
84
+ turn.discard(); // no-op if commitment already won
85
+ }
86
+ ```
87
+
88
+ `commit` publishes the confirmed text (not the guess) as the user row, then
89
+ allows completed assistant/tool steps to publish. In-flight model work continues
90
+ without restarting; all tools are deferred until commitment. Provisional text
91
+ can be synthesized early, but audio must be held until confirmation. `discard`
92
+ before commitment aborts the model, fences late callbacks, and leaves no durable trace; the next
93
+ turn's `seq` follows the previous confirmed turn's with no hole. All tools —
94
+ including read-only tools — wait until commit; there is no tool opt-in
95
+ framework. Durable step contexts, pre-model lifecycle hooks, turn/model-step
96
+ middleware, and structured output fail closed on `begin`; use `turn` for those.
97
+ A static output or any nonempty construction-captured `outputs` catalog makes
98
+ `begin` refuse synchronously, even with no runtime output currently selected.
99
+ The default voice hook waits for confirmation and falls back to `turn` on that
100
+ refusal; absent or empty captured catalogs still permit speculation.
101
+ Unread text is cleared when its single consumer returns. Total text is limited
102
+ to 1,048,576 UTF-8 bytes / 16,384 deltas, failing with `RangeError` on excess.
103
+ Always commit/discard/abort held work. Cancellation cannot undo issued host
104
+ effects; they retain activation ownership until they settle. Recursive turns
105
+ across that exclusive reservation are rejected rather than deadlocking.
106
+ An error after commit may leave partial history, and repeated commit does not retry.
107
+ Speculation reserves exclusive ownership in FIFO order: an already-active
108
+ ordinary turn on the same activation can delay the voice model start even after
109
+ confirmation. All earlier ordinary turns must finish first; later turns remain
110
+ blocked until exclusive ownership is released. Ordinary turns run concurrently
111
+ only when no exclusive reservation intervenes. Other activations are unaffected.
112
+ See the [transactional voice contract](https://github.com/team-telnyx/edge-compute-statefulactor/blob/compute-953/transactional-voice-turn/docs/agent-harness/transactional-voice-turn.md)
113
+ for the full design.
64
114
 
65
115
  A harness captures its ports, model, instructions, and tool map at construction.
66
116
  Each completed model step is committed before callbacks run. This beta does not
package/dist/harness.d.ts CHANGED
@@ -5,6 +5,8 @@ import { type HarnessCheckpoint, type HarnessRunLedger } from "./durable.js";
5
5
  import { type HarnessMiddleware } from "./lifecycle.js";
6
6
  import { type HarnessRuntimeConfig } from "./runtime-config.js";
7
7
  import type { HarnessStepContext } from "./steps.js";
8
+ import { type HarnessSpeculativeTurn } from "./speculative.js";
9
+ export { HarnessSpeculationConflictError, HarnessSpeculationUnsupportedError, type HarnessSpeculativeTurn, } from "./speculative.js";
8
10
  /** Structural subset of an AI SDK output definition used for parsed-result inference. */
9
11
  interface HarnessOutputDefinition<Parsed = unknown> {
10
12
  parseCompleteOutput(options: {
@@ -189,11 +191,12 @@ export interface HarnessTurnOptions<TOOLS extends ToolSet = ToolSet, OutputDefin
189
191
  }
190
192
  /** Portable harness constructed by {@link createHarness}. */
191
193
  export interface Harness<TOOLS extends ToolSet = ToolSet, OutputDefinition extends HarnessOutputDefinition | undefined = undefined> extends HarnessRunLedger {
194
+ /** Start a provisional streaming direct turn, with no writes or tools until commitment. */
195
+ begin(input: string, options?: HarnessTurnOptions<TOOLS, OutputDefinition>): HarnessSpeculativeTurn<HarnessTurnResult<HarnessParsedOutput<OutputDefinition>>>;
192
196
  turn(input: string, options?: HarnessTurnOptions<TOOLS, OutputDefinition>): Promise<HarnessTurnResult<HarnessParsedOutput<OutputDefinition>>>;
193
197
  /** Durable, authorization-gated approval inspection and decisions. */
194
198
  readonly approvals: HarnessApprovalLedger;
195
199
  }
196
200
  /** Construct a portable AI SDK v6 turn engine over an explicit ports object. */
197
201
  export declare function createHarness<State extends Record<string, unknown>, TOOLS extends ToolSet, OutputDefinition extends HarnessOutputDefinition | undefined = undefined>(spec: HarnessSpec<State, TOOLS, OutputDefinition>): Harness<TOOLS, OutputDefinition>;
198
- export {};
199
202
  //# sourceMappingURL=harness.d.ts.map
package/dist/harness.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { GatewayError } from "@ai-sdk/gateway";
2
- import { AISDKError, APICallError, RetryError, asSchema, generateText, jsonSchema, stepCountIs, wrapLanguageModel, } from "ai";
2
+ import { AISDKError, APICallError, RetryError, asSchema, generateText, streamText, jsonSchema, stepCountIs, wrapLanguageModel, } from "ai";
3
3
  import { z } from "zod/v4";
4
4
  import { isToolOutputSerializationFailure } from "./steps.js";
5
5
  import { HarnessApprovalError, createHarnessApprovalExecutionLedger, createHarnessApprovalMessageJournal, } from "./approvals.js";
@@ -7,9 +7,17 @@ import { createHarnessRunExecutionLedger, HarnessApprovalPause, } from "./durabl
7
7
  import { runMiddleware } from "./lifecycle.js";
8
8
  import { withHarnessToolContext } from "./tool-context.js";
9
9
  import { resolveHarnessRuntimeConfig, } from "./runtime-config.js";
10
+ import { HarnessSpeculationUnsupportedError, serializeTurn, speculativeTurn, } from "./speculative.js";
11
+ export { HarnessSpeculationConflictError, HarnessSpeculationUnsupportedError, } from "./speculative.js";
10
12
  const maxStepsSchema = z.number().int().positive();
11
13
  const TOOL_OUTPUT_PREFIX = "\u001e@telnyx/agent-harness:tool-output:v1:";
12
14
  const DURABLE_TOOL_STEP_VERSION = "tool-execution-v1";
15
+ function assertSpeculativeSdkHooks() {
16
+ if (globalThis.AI_SDK_TELEMETRY_INTEGRATIONS?.length
17
+ || typeof globalThis.AI_SDK_LOG_WARNINGS === "function") {
18
+ throw new HarnessSpeculationUnsupportedError("global AI SDK lifecycle integrations or custom warning callbacks");
19
+ }
20
+ }
13
21
  /** Raised after the final allowed model step still requests executable tools. */
14
22
  export class HarnessStepLimitError extends Error {
15
23
  maxSteps;
@@ -66,6 +74,41 @@ export class HarnessObserverError extends Error {
66
74
  this.name = "HarnessObserverError";
67
75
  }
68
76
  }
77
+ async function cancelTurn(onCancel, cancellation = new HarnessCancellationError()) {
78
+ try {
79
+ await onCancel?.(cancellation);
80
+ }
81
+ catch (observer) {
82
+ throw new HarnessObserverError(cancellation, observer, "cancel");
83
+ }
84
+ throw cancellation;
85
+ }
86
+ function captureSpeculativeOptions(options) {
87
+ // Inspect descriptors, not values: spreading invokes getters. A detached
88
+ // null-prototype snapshot cannot inherit hooks installed while queued.
89
+ const supported = new Set(["signal", "onStepCommitted", "onOutput"]);
90
+ const unsupported = new Set(["steps", "callbacks", "onCancel", "onError"]);
91
+ if (options === null || typeof options !== "object"
92
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
93
+ throw new HarnessSpeculationUnsupportedError("non-plain turn options");
94
+ }
95
+ const captured = Object.create(null);
96
+ for (const key of Reflect.ownKeys(options)) {
97
+ const descriptor = Object.getOwnPropertyDescriptor(options, key);
98
+ if (typeof key !== "string" || !descriptor.enumerable || !("value" in descriptor)
99
+ || (!supported.has(key) && !unsupported.has(key))
100
+ || (unsupported.has(key) && descriptor.value !== undefined)) {
101
+ throw new HarnessSpeculationUnsupportedError("accessors, inherited options, or pre-model lifecycle hooks");
102
+ }
103
+ if (supported.has(key)) {
104
+ if (key !== "signal" && descriptor.value !== undefined && typeof descriptor.value !== "function") {
105
+ throw new HarnessSpeculationUnsupportedError("non-function observers");
106
+ }
107
+ Object.defineProperty(captured, key, descriptor);
108
+ }
109
+ }
110
+ return Object.freeze(captured);
111
+ }
69
112
  function historyMessages(stored) {
70
113
  return stored.map((message) => {
71
114
  if (message.role === "assistant") {
@@ -652,9 +695,10 @@ function validateTools(tools, authorization) {
652
695
  }
653
696
  /** Construct a portable AI SDK v6 turn engine over an explicit ports object. */
654
697
  export function createHarness(spec) {
698
+ const activation = spec.ports.activation;
655
699
  const messagePort = spec.ports.messages;
656
- const appendMessage = messagePort.append.bind(messagePort);
657
- const appendMessages = messagePort.appendMany.bind(messagePort);
700
+ const appendMessagePort = messagePort.append.bind(messagePort);
701
+ const appendMessagesPort = messagePort.appendMany.bind(messagePort);
658
702
  const allMessages = messagePort.all.bind(messagePort);
659
703
  const getState = spec.ports.state.get.bind(spec.ports.state);
660
704
  const model = spec.model;
@@ -697,22 +741,24 @@ export function createHarness(spec) {
697
741
  let lastValidRuntimeConfig = resolveHarnessRuntimeConfig(staticRuntimeConfig, undefined).config;
698
742
  let runtimeConfigReadSequence = 0;
699
743
  let lastPublishedRuntimeConfigReadSequence = 0;
700
- const turn = async (input, options = {}) => {
744
+ const executeTurn = async (input, options = {}, speculation) => {
745
+ const committedEffect = (work) => speculation ? speculation.write(work) : work();
746
+ const appendMessage = (message) => speculation
747
+ ? speculation.write(() => appendMessagePort(message)) : appendMessagePort(message);
748
+ const appendMessages = (batch) => speculation
749
+ ? speculation.write(() => appendMessagesPort(batch)) : appendMessagesPort(batch);
701
750
  if (options.signal?.aborted) {
702
- const cancellation = new HarnessCancellationError();
703
- try {
704
- await options.onCancel?.(cancellation);
705
- }
706
- catch (observer) {
707
- throw new HarnessObserverError(cancellation, observer, "cancel");
708
- }
709
- throw cancellation;
751
+ return cancelTurn(options.onCancel);
710
752
  }
711
753
  try {
712
754
  const runtimeConfigReadSequenceForTurn = ++runtimeConfigReadSequence;
713
755
  const state = await getState();
756
+ speculation?.assertLive();
714
757
  const resolution = resolveHarnessRuntimeConfig(staticRuntimeConfig, state.__telnyx_agent_harness, lastValidRuntimeConfig);
715
758
  const runtimeConfig = resolution.config;
759
+ if (speculation && runtimeConfig.output !== undefined) {
760
+ throw new HarnessSpeculationUnsupportedError("structured output");
761
+ }
716
762
  if (typeof runtimeConfig.model === "string" && lifecycleMiddleware.modelStep.length > 0) {
717
763
  throw new TypeError("Harness model-step middleware requires an executable LanguageModel");
718
764
  }
@@ -731,7 +777,11 @@ export function createHarness(spec) {
731
777
  });
732
778
  return await runMiddleware(turnContext, lifecycleMiddleware.turn, async () => {
733
779
  await toolSnapshot.preflight(Object.keys(runtimeConfig.tools ?? {}));
780
+ speculation?.assertLive();
734
781
  const priorMessages = await allMessages();
782
+ speculation?.assertLive();
783
+ if (speculation)
784
+ assertSpeculativeSdkHooks();
735
785
  const inputSteps = options.steps;
736
786
  const inputJournal = inputSteps === undefined
737
787
  ? undefined
@@ -775,10 +825,29 @@ export function createHarness(spec) {
775
825
  const replaysJournaledInput = journaledInput !== undefined;
776
826
  let journalThrough = replaysJournaledInput
777
827
  ? priorMessages.at(-1).seq
778
- : await appendMessage({ role: "user", content: input });
779
- const storedMessages = !replaysJournaledInput
780
- ? await allMessages()
781
- : priorMessages;
828
+ : speculation ? (priorMessages.at(-1)?.seq ?? 0) : await appendMessage({ role: "user", content: input });
829
+ const storedMessages = speculation
830
+ ? [...priorMessages, { role: "user", content: input, seq: journalThrough + 1, at: new Date(spec.ports.clock.now()) }]
831
+ : !replaysJournaledInput
832
+ ? await allMessages()
833
+ : priorMessages;
834
+ // The append-only port allocates the confirmed input's actual sequence.
835
+ // No speculative reservation can conflict with unrelated message writers.
836
+ let publication;
837
+ let confirmedForModel;
838
+ const confirmedMessages = (batch) => confirmedForModel === undefined ? batch : batch.map((message, index) => index === priorMessages.length ? { role: "user", content: confirmedForModel } : message);
839
+ const publishInput = () => {
840
+ if (!speculation)
841
+ return Promise.resolve();
842
+ publication ??= (async () => {
843
+ const confirmed = await speculation.confirmed();
844
+ speculation.assertLive();
845
+ confirmedForModel = confirmed;
846
+ journalThrough = await appendMessage({ role: "user", content: confirmed });
847
+ speculation.assertLive();
848
+ })();
849
+ return publication;
850
+ };
782
851
  const restoredApprovals = options.steps === undefined
783
852
  ? []
784
853
  : await approvalMessages.restoreRun(options.steps.runId);
@@ -825,9 +894,18 @@ export function createHarness(spec) {
825
894
  let modelStepNumber = 0;
826
895
  const limitReached = stepCountIs(runtimeConfig.limits.steps);
827
896
  const assertBoundary = () => {
897
+ speculation?.assertLive();
828
898
  if (boundaryFailed)
829
899
  throw boundaryError;
830
900
  };
901
+ if (speculation)
902
+ void publishInput().catch((error) => {
903
+ if (!boundaryFailed) {
904
+ boundaryFailed = true;
905
+ boundaryError = error;
906
+ }
907
+ speculation.fail(error);
908
+ });
831
909
  const modelForTurn = lifecycleMiddleware.modelStep.length === 0 || typeof runtimeConfig.model === "string"
832
910
  ? runtimeConfig.model
833
911
  : wrapLanguageModel({
@@ -847,7 +925,21 @@ export function createHarness(spec) {
847
925
  toolName,
848
926
  Object.freeze({
849
927
  ...candidate,
928
+ ...(speculation ? {
929
+ inputSchema: jsonSchema(async () => await asSchema(candidate.inputSchema).jsonSchema, {
930
+ validate: async (value) => {
931
+ await publishInput();
932
+ assertBoundary();
933
+ const schema = asSchema(candidate.inputSchema);
934
+ return schema.validate
935
+ ? await committedEffect(async () => await schema.validate(value))
936
+ : { success: true, value };
937
+ },
938
+ }),
939
+ } : {}),
850
940
  async execute(...args) {
941
+ await publishInput();
942
+ assertBoundary();
851
943
  const toolCallOptions = args[1];
852
944
  const toolCallId = typeof toolCallOptions?.toolCallId === "string"
853
945
  ? toolCallOptions.toolCallId
@@ -862,13 +954,14 @@ export function createHarness(spec) {
862
954
  let coreCompleted = false;
863
955
  let replayingCompletedApproval = false;
864
956
  try {
865
- const output = await runMiddleware(Object.freeze({
957
+ const output = await committedEffect(() => runMiddleware(Object.freeze({
866
958
  phase: "tool",
867
959
  config: runtimeConfig,
868
960
  toolName,
869
961
  toolCallId,
870
962
  signal: options.signal,
871
963
  }), lifecycleMiddleware.tool, async () => {
964
+ assertBoundary();
872
965
  const stepName = `tool:${toolName}:${durableToolCallOrdinal}`;
873
966
  const stepVersion = spec.durability?.toolStepVersion ?? DURABLE_TOOL_STEP_VERSION;
874
967
  const restoringCompletedApproval = options.steps !== undefined
@@ -881,12 +974,20 @@ export function createHarness(spec) {
881
974
  ? undefined
882
975
  : await approvals.reauthorizeEffect(options.steps.runId, toolCallId, toolName, args[0]);
883
976
  const approvalId = authorizedEffect?.id;
884
- const effectArgs = authorizedEffect === undefined
977
+ let effectArgs = authorizedEffect === undefined
885
978
  ? args
886
979
  : [authorizedEffect.input, ...args.slice(1)];
980
+ if (speculation) {
981
+ const callOptions = objectRecord(effectArgs[1]);
982
+ if (Array.isArray(callOptions?.messages))
983
+ effectArgs = [effectArgs[0], {
984
+ ...callOptions, messages: confirmedMessages(callOptions.messages),
985
+ }, ...effectArgs.slice(2)];
986
+ }
987
+ assertBoundary();
887
988
  coreStarted = true;
888
989
  const result = options.steps === undefined
889
- ? cloneToolOutput(toolName, await execute.apply(this, args))
990
+ ? cloneToolOutput(toolName, await committedEffect(async () => await execute.apply(this, effectArgs)))
890
991
  : await options.steps.step(stepName, async () => await withHarnessToolContext(Object.freeze({ runId: options.steps.runId, toolName, ordinal: durableToolCallOrdinal }), async () => await execute.apply(this, effectArgs)), {
891
992
  external: true,
892
993
  beforeEffect: () => approvals.consumeAuthorizedEffect(options.steps.runId, approvalId),
@@ -895,7 +996,7 @@ export function createHarness(spec) {
895
996
  });
896
997
  coreCompleted = true;
897
998
  return result;
898
- });
999
+ }));
899
1000
  const restoredCall = toolCallId === undefined ? undefined : restoredApprovalCalls.get(toolCallId);
900
1001
  if (restoredCall !== undefined) {
901
1002
  const approvedOutput = typeof output === "string"
@@ -937,7 +1038,7 @@ export function createHarness(spec) {
937
1038
  durableSteps.nextToolCallOrdinal(event.toolCall.toolName, event.toolCall.toolCallId);
938
1039
  await options.callbacks?.onToolCallStart?.(event);
939
1040
  };
940
- const generation = generateText({
1041
+ const generationOptions = {
941
1042
  model: modelForTurn,
942
1043
  tools: toolsForTurn,
943
1044
  ...(runtimeConfig.output === undefined ? {} : { output: runtimeConfig.output }),
@@ -948,9 +1049,9 @@ export function createHarness(spec) {
948
1049
  experimental_onStepStart: options.callbacks?.onStepStart,
949
1050
  experimental_onToolCallStart: onToolCallStart,
950
1051
  experimental_onToolCallFinish: options.callbacks?.onToolCallFinish,
951
- prepareStep() {
1052
+ prepareStep({ messages: stepMessages }) {
952
1053
  assertBoundary();
953
- return undefined;
1054
+ return speculation && confirmedForModel !== undefined ? { messages: confirmedMessages(stepMessages) } : undefined;
954
1055
  },
955
1056
  async stopWhen(context) {
956
1057
  assertBoundary();
@@ -961,6 +1062,7 @@ export function createHarness(spec) {
961
1062
  },
962
1063
  async onStepFinish(step) {
963
1064
  try {
1065
+ await publishInput();
964
1066
  assertBoundary();
965
1067
  const approvalRequests = step.content.filter((part) => part.type === "tool-approval-request");
966
1068
  const pendingRequests = options.steps === undefined ? [] : await Promise.all(approvalRequests.map(async (request) => replayedCompletedApprovalToolCallIds.has(request.toolCall.toolCallId)
@@ -1042,7 +1144,12 @@ export function createHarness(spec) {
1042
1144
  projectedToolCallIds.add(call.toolCallId);
1043
1145
  for (const call of restoredCalls)
1044
1146
  projectedRestoredToolCallIds.add(call.toolCallId);
1045
- await options.onStepCommitted?.(observedStep(step, journalThrough));
1147
+ assertBoundary();
1148
+ if (options.onStepCommitted) {
1149
+ const snapshot = observedStep(step, journalThrough);
1150
+ await committedEffect(async () => { await options.onStepCommitted(snapshot); });
1151
+ }
1152
+ assertBoundary();
1046
1153
  await options.callbacks?.onStepFinish?.(step);
1047
1154
  }
1048
1155
  catch (error) {
@@ -1054,7 +1161,38 @@ export function createHarness(spec) {
1054
1161
  }
1055
1162
  },
1056
1163
  onFinish: options.callbacks?.onFinish,
1057
- });
1164
+ };
1165
+ const generation = speculation ? (async () => {
1166
+ let streamFailure;
1167
+ let streamFailed = false;
1168
+ const onStreamError = (error) => {
1169
+ streamFailed = true;
1170
+ streamFailure = boundaryFailed ? boundaryError : isProviderFailure(error)
1171
+ ? new HarnessProviderError(providerDiagnostics(runtimeConfig.model, error)) : error;
1172
+ speculation.fail(streamFailure);
1173
+ };
1174
+ const streamed = streamText({
1175
+ ...generationOptions,
1176
+ onError: ({ error }) => onStreamError(error),
1177
+ onChunk: ({ chunk }) => {
1178
+ assertBoundary();
1179
+ const part = objectRecord(chunk);
1180
+ if (part?.type === "text-delta" && typeof part.text === "string")
1181
+ speculation.text(part.text);
1182
+ },
1183
+ });
1184
+ await streamed.consumeStream({ onError: onStreamError });
1185
+ assertBoundary();
1186
+ if (streamFailed)
1187
+ throw streamFailure;
1188
+ return {
1189
+ text: await streamed.text,
1190
+ steps: await streamed.steps,
1191
+ finishReason: await streamed.finishReason,
1192
+ totalUsage: await streamed.totalUsage,
1193
+ output: undefined,
1194
+ };
1195
+ })() : generateText(generationOptions);
1058
1196
  let result;
1059
1197
  try {
1060
1198
  result = await generation;
@@ -1089,7 +1227,9 @@ export function createHarness(spec) {
1089
1227
  ? {}
1090
1228
  : { output: result.output }),
1091
1229
  });
1092
- await options.onOutput?.(output);
1230
+ assertBoundary();
1231
+ if (options.onOutput)
1232
+ await committedEffect(async () => { await options.onOutput(output); });
1093
1233
  return output;
1094
1234
  });
1095
1235
  }
@@ -1102,13 +1242,7 @@ export function createHarness(spec) {
1102
1242
  const classified = error instanceof HarnessCancellationError
1103
1243
  ? error
1104
1244
  : new HarnessCancellationError();
1105
- try {
1106
- await options.onCancel?.(classified);
1107
- }
1108
- catch (observer) {
1109
- throw new HarnessObserverError(classified, observer, "cancel");
1110
- }
1111
- throw classified;
1245
+ return cancelTurn(options.onCancel, classified);
1112
1246
  }
1113
1247
  try {
1114
1248
  await options.onError?.(error);
@@ -1119,6 +1253,22 @@ export function createHarness(spec) {
1119
1253
  throw error;
1120
1254
  }
1121
1255
  };
1256
+ const turn = (input, options = {}) => serializeTurn(activation, () => executeTurn(input, options), false, options.signal, () => cancelTurn(options.onCancel));
1257
+ const begin = (input, options = {}) => {
1258
+ const capturedOptions = captureSpeculativeOptions(options);
1259
+ assertSpeculativeSdkHooks();
1260
+ // A captured catalog can select output from async runtime state. Refuse
1261
+ // before returning a handle so voice can safely fall back after confirmation.
1262
+ if (lifecycleMiddleware.turn.length || lifecycleMiddleware.modelStep.length
1263
+ || staticRuntimeConfig.output !== undefined || Object.keys(outputs).length > 0) {
1264
+ throw new HarnessSpeculationUnsupportedError("durable step contexts, pre-model lifecycle hooks, or structured output");
1265
+ }
1266
+ return speculativeTurn(activation, capturedOptions.signal, () => new HarnessCancellationError(), (control) => executeTurn(input, Object.freeze(Object.assign(Object.create(null), {
1267
+ signal: control.signal,
1268
+ onStepCommitted: capturedOptions.onStepCommitted,
1269
+ onOutput: capturedOptions.onOutput,
1270
+ })), control));
1271
+ };
1122
1272
  const ledger = createHarnessRunExecutionLedger(spec.ports, async (input, signal, steps) => {
1123
1273
  // Journal correlation enriches scheduling recovery, but it must not turn
1124
1274
  // an otherwise executable durable run into a failed one when the journal
@@ -1209,6 +1359,7 @@ export function createHarness(spec) {
1209
1359
  });
1210
1360
  return Object.freeze({
1211
1361
  turn,
1362
+ begin,
1212
1363
  accept: ledger.accept,
1213
1364
  get: ledger.get,
1214
1365
  status: ledger.status,
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from "./ports.js";
3
3
  export * from "./runtime-adapter.js";
4
4
  export * from "./harness.js";
5
+ export { HarnessSpeculationConflictError, HarnessSpeculationUnsupportedError, type HarnessSpeculativeTurn, } from "./speculative.js";
5
6
  export { HARNESS_RUN_STATUSES, HarnessApprovalPause, HarnessInjectedCrash, createHarnessRunLedger, ensureHarnessDurabilitySchema, type HarnessCheckpoint, type HarnessRun, type HarnessRunAcceptance, type HarnessRunLedger, type HarnessRunLedgerOptions, type HarnessRunListOptions, type HarnessRunStatus, } from "./durable.js";
6
7
  export { HARNESS_APPROVAL_STATUSES, HarnessApprovalError, HarnessAuthorizationError, type HarnessApproval, type HarnessApprovalLedger, type HarnessApprovalStatus, } from "./approvals.js";
7
8
  export * from "./steps.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./runtime-adapter.js";
9
9
  // "@telnyx/agent-harness/node" subpath (internal code from
10
10
  // "./node-adapter.js"). The clean-consumer CI check enforces both directions.
11
11
  export * from "./harness.js";
12
+ export { HarnessSpeculationConflictError, HarnessSpeculationUnsupportedError, } from "./speculative.js";
12
13
  export { HARNESS_RUN_STATUSES, HarnessApprovalPause, HarnessInjectedCrash, createHarnessRunLedger, ensureHarnessDurabilitySchema, } from "./durable.js";
13
14
  export { HARNESS_APPROVAL_STATUSES, HarnessApprovalError, HarnessAuthorizationError, } from "./approvals.js";
14
15
  export * from "./steps.js";
@@ -0,0 +1,36 @@
1
+ /** In-memory ownership and commitment; never opens a storage transaction. */
2
+ export declare class HarnessSpeculationConflictError extends Error {
3
+ readonly code: "HARNESS_SPECULATION_CONFLICT";
4
+ constructor();
5
+ }
6
+ export declare class HarnessSpeculationUnsupportedError extends Error {
7
+ readonly code: "HARNESS_SPECULATION_UNSUPPORTED";
8
+ constructor(detail: string);
9
+ }
10
+ /** One speculative direct turn. Text is provisional until commit succeeds. */
11
+ export interface HarnessSpeculativeTurn<Result = {
12
+ text: string;
13
+ }> {
14
+ /** Single-consumer, provisional text. Consuming it must not cause durable effects. */
15
+ readonly textStream: AsyncIterable<string>;
16
+ /** Observed internally even when a caller discards without awaiting it. */
17
+ readonly result: Promise<Result>;
18
+ /** First decision wins. Same text returns the same promise; different text rejects. */
19
+ commit(confirmed: string): Promise<Result>;
20
+ /** True for a discarded turn (including repeats), false after commit won. */
21
+ discard(): boolean;
22
+ }
23
+ /** FIFO exclusive reservations; ordinary callers still run concurrently. */
24
+ export declare function serializeTurn<T>(activation: object, work: () => Promise<T>, exclusive?: boolean, signal?: AbortSignal, onQueuedCancel?: () => Promise<never>): Promise<T>;
25
+ export interface SpeculativeControl {
26
+ readonly signal: AbortSignal;
27
+ assertLive(): void;
28
+ fail(error: unknown): void;
29
+ /** Retain ownership until an issued post-commit host effect settles. */
30
+ write<T>(work: () => Promise<T>): Promise<T>;
31
+ /** Resolves to the confirmed input; rejects on abandonment. */
32
+ confirmed(): Promise<string>;
33
+ text(delta: string): void;
34
+ }
35
+ export declare function speculativeTurn<Result>(activation: object, signal: AbortSignal | undefined, cancellation: () => Error, execute: (control: SpeculativeControl) => Promise<Result>): HarnessSpeculativeTurn<Result>;
36
+ //# sourceMappingURL=speculative.d.ts.map
@@ -0,0 +1,272 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /** In-memory ownership and commitment; never opens a storage transaction. */
3
+ export class HarnessSpeculationConflictError extends Error {
4
+ code = "HARNESS_SPECULATION_CONFLICT";
5
+ constructor() { super("Speculative turn already has a different terminal decision"); this.name = "HarnessSpeculationConflictError"; }
6
+ }
7
+ export class HarnessSpeculationUnsupportedError extends Error {
8
+ code = "HARNESS_SPECULATION_UNSUPPORTED";
9
+ constructor(detail) { super(`Speculation does not support ${detail}`); this.name = "HarnessSpeculationUnsupportedError"; }
10
+ }
11
+ function deferred() {
12
+ let resolve;
13
+ let reject;
14
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
15
+ void promise.catch(() => { });
16
+ return { promise, resolve, reject };
17
+ }
18
+ const contexts = new AsyncLocalStorage();
19
+ const owners = new WeakMap();
20
+ function drain(owner) {
21
+ if (owner.draining)
22
+ return;
23
+ owner.draining = true;
24
+ try {
25
+ while (!owner.exclusive) {
26
+ const next = owner.pending.values().next().value;
27
+ if (!next || (next.exclusive && owner.active > 0))
28
+ return;
29
+ owner.pending.delete(next);
30
+ next.start();
31
+ }
32
+ }
33
+ finally {
34
+ owner.draining = false;
35
+ }
36
+ }
37
+ /** FIFO exclusive reservations; ordinary callers still run concurrently. */
38
+ export function serializeTurn(activation, work, exclusive = true, signal, onQueuedCancel) {
39
+ let owner = owners.get(activation);
40
+ if (!owner) {
41
+ owner = { active: 0, exclusive: false, draining: false, pending: new Set() };
42
+ owners.set(activation, owner);
43
+ }
44
+ const completion = deferred();
45
+ const inherited = contexts.getStore() ?? [];
46
+ if (inherited.some((context) => context.activation === activation && context.active
47
+ && (exclusive || context.exclusive || [...owner.pending].some((entry) => entry.exclusive)))) {
48
+ completion.reject(new HarnessSpeculationUnsupportedError("recursive turns across an exclusive reservation"));
49
+ return completion.promise;
50
+ }
51
+ const context = { activation, exclusive, active: false };
52
+ const reservation = {
53
+ exclusive,
54
+ start() {
55
+ signal?.removeEventListener("abort", cancel);
56
+ context.active = true;
57
+ owner.active++;
58
+ owner.exclusive = exclusive;
59
+ const release = () => {
60
+ context.active = false;
61
+ owner.active--;
62
+ if (exclusive)
63
+ owner.exclusive = false;
64
+ drain(owner);
65
+ };
66
+ try {
67
+ const result = contexts.run([...inherited, context], work);
68
+ void Promise.resolve(result).then((value) => { release(); completion.resolve(value); }, (error) => { release(); completion.reject(error); });
69
+ }
70
+ catch (error) {
71
+ release();
72
+ completion.reject(error);
73
+ }
74
+ },
75
+ };
76
+ const cancel = () => {
77
+ if (!owner.pending.delete(reservation))
78
+ return;
79
+ signal?.removeEventListener("abort", cancel);
80
+ // Deletion is the pending/start winner. Never run execution or acquire
81
+ // ownership for cancellation, nor inherit the abort dispatcher's context.
82
+ try {
83
+ if (onQueuedCancel) {
84
+ void contexts.run(inherited, onQueuedCancel).then(completion.resolve, completion.reject);
85
+ }
86
+ else
87
+ completion.reject(signal?.reason);
88
+ }
89
+ catch (error) {
90
+ completion.reject(error);
91
+ }
92
+ finally {
93
+ drain(owner);
94
+ }
95
+ };
96
+ owner.pending.add(reservation);
97
+ signal?.addEventListener("abort", cancel, { once: true });
98
+ if (signal?.aborted)
99
+ cancel();
100
+ // Register before invoking work, including synchronous reentry.
101
+ else
102
+ drain(owner);
103
+ return completion.promise;
104
+ }
105
+ // The AI SDK also retains stream content, even when our consumer drains it.
106
+ // Bound total text, not just the currently unread queue. A separate chunk cap
107
+ // bounds object overhead for small provider deltas.
108
+ const MAX_TEXT_BYTES = 1_048_576;
109
+ const MAX_TEXT_CHUNKS = 16_384;
110
+ export function speculativeTurn(activation, signal, cancellation, execute) {
111
+ const controller = new AbortController();
112
+ const confirmation = deferred();
113
+ const stopped = deferred();
114
+ let decision = "held";
115
+ let confirmedInput;
116
+ let failure;
117
+ let failed = false;
118
+ let ended = false;
119
+ let executionClosed = false;
120
+ let taken = false;
121
+ let textBytes = 0;
122
+ let textChunks = 0;
123
+ let streamClosed = false;
124
+ const chunks = [];
125
+ const writes = new Set();
126
+ let changed = deferred();
127
+ const wake = () => { changed.resolve(); changed = deferred(); };
128
+ const stopWith = (error) => {
129
+ if (ended || controller.signal.aborted)
130
+ return;
131
+ if (decision === "held")
132
+ decision = "discarded";
133
+ failed = true;
134
+ failure = error;
135
+ chunks.length = 0;
136
+ confirmation.reject(error);
137
+ stopped.reject(error);
138
+ controller.abort(error);
139
+ wake();
140
+ };
141
+ const stop = () => stopWith(cancellation());
142
+ if (signal?.aborted)
143
+ stop();
144
+ else
145
+ signal?.addEventListener("abort", stop, { once: true });
146
+ const assertLive = () => {
147
+ if (controller.signal.aborted)
148
+ throw failure;
149
+ if (executionClosed)
150
+ throw new Error("Speculative execution already finished");
151
+ };
152
+ const control = {
153
+ signal: controller.signal,
154
+ assertLive,
155
+ fail: stopWith,
156
+ write(work) {
157
+ assertLive();
158
+ if (decision !== "committed")
159
+ throw new Error("Speculative writes require commitment");
160
+ const completion = deferred();
161
+ writes.add(completion.promise);
162
+ void completion.promise.then(() => { writes.delete(completion.promise); }, () => { writes.delete(completion.promise); });
163
+ try {
164
+ void work().then(completion.resolve, completion.reject);
165
+ }
166
+ catch (error) {
167
+ completion.reject(error);
168
+ }
169
+ return completion.promise;
170
+ },
171
+ confirmed: async () => { const input = await confirmation.promise; assertLive(); return input; },
172
+ text(delta) {
173
+ assertLive();
174
+ textBytes += Buffer.byteLength(delta, "utf8");
175
+ textChunks++;
176
+ if (textBytes > MAX_TEXT_BYTES || textChunks > MAX_TEXT_CHUNKS) {
177
+ stopWith(new RangeError("Speculative text limit exceeded"));
178
+ throw failure;
179
+ }
180
+ if (!delta)
181
+ return;
182
+ if (!streamClosed) {
183
+ chunks.push(delta);
184
+ wake();
185
+ }
186
+ },
187
+ };
188
+ const owned = serializeTurn(activation, async () => {
189
+ assertLive();
190
+ try {
191
+ return await Promise.race([execute(control).catch((error) => {
192
+ stopWith(error);
193
+ throw error;
194
+ }), stopped.promise]);
195
+ }
196
+ finally {
197
+ executionClosed = true;
198
+ // Issued host effects cannot be undone. Cancellation settles the public
199
+ // result promptly but cannot let a later turn overtake those effects.
200
+ // Recheck: an admitted callback can issue another tracked effect while
201
+ // an earlier batch is settling. The abort fence rejects new admissions.
202
+ while (writes.size)
203
+ await Promise.allSettled([...writes]);
204
+ }
205
+ }, true, controller.signal);
206
+ // Cancellation removes queued reservations and settles active callers
207
+ // promptly, independently of the ownership drain for issued effects.
208
+ const result = Promise.race([owned, stopped.promise]);
209
+ void result.then(() => { ended = true; signal?.removeEventListener("abort", stop); wake(); }, (error) => {
210
+ // A failed speculative model can never later publish its user input.
211
+ stopWith(error);
212
+ ended = true;
213
+ signal?.removeEventListener("abort", stop);
214
+ wake();
215
+ });
216
+ return Object.freeze({
217
+ result,
218
+ textStream: {
219
+ [Symbol.asyncIterator]() {
220
+ if (taken)
221
+ throw new Error("Speculative text stream already has a consumer");
222
+ taken = true;
223
+ const iterator = (async function* () {
224
+ while (true) {
225
+ if (failed)
226
+ throw failure;
227
+ if (streamClosed)
228
+ return;
229
+ const chunk = chunks.shift();
230
+ if (chunk !== undefined)
231
+ yield chunk;
232
+ else if (ended)
233
+ return;
234
+ else
235
+ await changed.promise;
236
+ }
237
+ })();
238
+ return {
239
+ next: () => iterator.next(),
240
+ return: async () => {
241
+ streamClosed = true;
242
+ chunks.length = 0;
243
+ if (decision === "held")
244
+ stop();
245
+ wake();
246
+ return iterator.return();
247
+ },
248
+ };
249
+ },
250
+ },
251
+ commit(input) {
252
+ if (decision === "discarded" || (decision === "committed" && input !== confirmedInput)) {
253
+ const conflict = Promise.reject(new HarnessSpeculationConflictError());
254
+ void conflict.catch(() => { });
255
+ return conflict;
256
+ }
257
+ if (decision === "held") {
258
+ decision = "committed";
259
+ confirmedInput = input;
260
+ confirmation.resolve(input);
261
+ }
262
+ return result;
263
+ },
264
+ discard() {
265
+ if (decision === "committed")
266
+ return false;
267
+ stop();
268
+ return true;
269
+ },
270
+ });
271
+ }
272
+ //# sourceMappingURL=speculative.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telnyx/agent-harness",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.1",
4
4
  "description": "Telnyx Agent Harness beta: portable ports, bounded turns, and Node test-host adapter.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",