@tangle-network/agent-runtime 0.13.1 → 0.14.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/dist/index.d.ts CHANGED
@@ -59,6 +59,14 @@ interface BackendRetryPolicy {
59
59
  jitter?: number;
60
60
  /** Status codes that trigger a retry. Default: 408, 425, 429, 500, 502, 503, 504. */
61
61
  retryStatuses?: ReadonlyArray<number>;
62
+ /**
63
+ * Per-attempt wall-clock deadline in ms. If a single fetch attempt does
64
+ * not return headers within this window the attempt is aborted and
65
+ * retried. Default 120000 (2 min). Without this a hung upstream blocks
66
+ * the attempt indefinitely — observed in production as a 15-minute
67
+ * `fetch failed` that burned an entire eval persona. Set to 0 to disable.
68
+ */
69
+ requestTimeoutMs?: number;
62
70
  }
63
71
  declare function createOpenAICompatibleBackend<TInput extends AgentBackendInput = AgentBackendInput>(options: {
64
72
  apiKey: string;
@@ -384,6 +392,225 @@ declare class DurableAwaitEventTimeoutError extends DurableRunError {
384
392
  constructor(message: string);
385
393
  }
386
394
 
395
+ /**
396
+ * `runDurableTurn` — a streaming, backend-agnostic, checkpoint+replay durable
397
+ * turn. The single reusable primitive every product's chat handler routes
398
+ * through, so per-product durability code drops to zero.
399
+ *
400
+ * A **turn** is one request→response unit: a producer yields a stream of
401
+ * events and, once drained, exposes the turn's final text. `runDurableTurn`
402
+ * wraps that with a `DurableRunStore`:
403
+ *
404
+ * - **Fresh run** — no completed step for this `(runId)`. The producer
405
+ * runs; its events forward live to the caller (streaming preserved)
406
+ * while final text accumulates; on drain the text is checkpointed.
407
+ *
408
+ * - **Replay** — a completed step already exists (the worker died after
409
+ * the turn finished but before the response reached the client, and the
410
+ * client retried the same turn). The cached text is emitted as a single
411
+ * synthetic event; the producer is never constructed — no LLM call, no
412
+ * double-billing.
413
+ *
414
+ * - **Mid-stream crash** — a turn that died *while streaming* leaves step 0
415
+ * in `running`/`failed`. There is no partial-stream checkpoint (the
416
+ * substrate checkpoints JSON values at step granularity), so the turn
417
+ * re-runs from the top. This is the honest durability ceiling: a
418
+ * *completed* turn is free to replay; an *interrupted* turn re-runs.
419
+ *
420
+ * Generic over the event type `TEvent` so a product can stream its own NDJSON
421
+ * shape or the runtime's `RuntimeStreamEvent` — `runDurableTurn` never
422
+ * inspects events, it only forwards them and reads `finalText()` after drain.
423
+ *
424
+ * Lease: a turn is a single step, fast enough that the heartbeat in
425
+ * `runDurable` is unnecessary — `runDurableTurn` claims the lease once via
426
+ * `startOrResume` and releases it on `endRun`. Concurrent workers on the same
427
+ * `runId` are rejected with `DurableRunLeaseHeldError` (the client retried
428
+ * before the first attempt finished); callers surface that as "turn already
429
+ * in flight."
430
+ */
431
+
432
+ /** The live side of a turn — what a fresh run produces. */
433
+ interface DurableTurnProducer<TEvent> {
434
+ /** The turn's event stream. Forwarded verbatim to the caller. */
435
+ stream: AsyncGenerator<TEvent, void, unknown>;
436
+ /** The turn's final assistant text. Read once, after `stream` drains. */
437
+ finalText(): string;
438
+ }
439
+ interface RunDurableTurnOptions<TEvent> {
440
+ store: DurableRunStore;
441
+ /** Stable per-turn run id. Convention: `chat:<threadId>:<turnIndex>`. The
442
+ * same id on a retry is what enables replay. */
443
+ runId: string;
444
+ manifest: DurableRunManifest;
445
+ /** Stable per-isolate worker id. Defaults to a fresh `deriveWorkerId()`
446
+ * per call when omitted — fine for single-attempt turns. */
447
+ workerId: string;
448
+ /** Lease window in ms. Default 60_000 — a turn rarely runs longer. */
449
+ leaseMs?: number;
450
+ /** Human-readable step label. Default `turn`. */
451
+ intent?: string;
452
+ /** Builds the live producer. Called exactly once, on a fresh run; never
453
+ * called on the replay path. */
454
+ produce: () => DurableTurnProducer<TEvent>;
455
+ /** Synthesizes the single event emitted on the replay path from the
456
+ * cached final text (e.g. a product's `{ type: 'result', data: {...} }`). */
457
+ replayEvent: (finalText: string) => TEvent;
458
+ /** Optional live accumulator. When the producer's `finalText()` is only
459
+ * valid after drain, this lets `runDurableTurn` also observe each event
460
+ * to build the text — return the running text or `undefined` to ignore
461
+ * an event. When omitted, `producer.finalText()` is the sole source. */
462
+ accumulate?: (event: TEvent, current: string) => string | undefined;
463
+ }
464
+ interface DurableTurnHandle<TEvent> {
465
+ /** Drop-in stream. Fresh runs forward producer events live; replays emit
466
+ * exactly one `replayEvent(cachedText)`. */
467
+ stream: AsyncGenerator<TEvent, void, unknown>;
468
+ /** The turn's final text. Valid after `stream` drains. */
469
+ finalText(): string;
470
+ /** True iff this turn replayed a cached result (no producer ran). Valid
471
+ * after `stream` drains. */
472
+ replayed(): boolean;
473
+ /** The durable `RunRecord` for this turn. Valid after `stream` drains. */
474
+ record(): RunRecord | undefined;
475
+ }
476
+ declare function runDurableTurn<TEvent>(options: RunDurableTurnOptions<TEvent>): DurableTurnHandle<TEvent>;
477
+
478
+ /**
479
+ * `DurableChatTurnEngine` — the framework-neutral chat-turn orchestrator every
480
+ * product chat handler routes through. It owns the parts that were copy-pasted
481
+ * across legal / gtm / creative / tax: durable checkpointing, the NDJSON
482
+ * `StreamEvent` line protocol, the `session.run.*` lifecycle vocabulary, the
483
+ * runtime-run cost ledger, and trace flush. Everything genuinely
484
+ * product-specific is a hook the product supplies.
485
+ *
486
+ * What the engine owns:
487
+ * - durable turn (`runDurableTurn`): completed turns replay free, no re-bill
488
+ * - the `session.run.started` / `session.run.completed` / `session.run.failed`
489
+ * event envelope around the producer's events
490
+ * - NDJSON encoding into a `ReadableStream<Uint8Array>` (the body every
491
+ * product returns, React Router or Hono alike)
492
+ * - calling the product's persist / post-process hooks in the right order,
493
+ * after the stream drains, with the assembled final text
494
+ * - never throwing into the HTTP layer — a producer failure becomes an
495
+ * `error` + `session.run.failed` event pair, the stream still closes
496
+ *
497
+ * What the product supplies (`ChatTurnHooks`):
498
+ * - `produce` — build the backend stream for this turn (sandbox / router
499
+ * / tcloud / runtime — the engine does not care which)
500
+ * - `persistAssistantMessage` — write the assistant turn to the product DB
501
+ * - `onTurnComplete` (optional) — post-process (proposals, citations, …)
502
+ * - `onEvent` (optional) — per-event side-channel (e.g. DO broadcast)
503
+ * - `transformFinalText` (optional) — pre-persist transform (e.g. PII redact)
504
+ *
505
+ * Framework neutrality: the engine takes already-resolved values
506
+ * (`userId`, identity tuple, parsed message, a `DurableRunStore`, a
507
+ * `waitUntil`), never a `Request` or a `Context`. The product's thin route
508
+ * adapter does auth + parse + access-control, then calls `engine.runTurn(...)`
509
+ * and returns `result.body` as its platform `Response`.
510
+ */
511
+
512
+ /** The NDJSON line protocol every product chat client already speaks. */
513
+ interface ChatStreamEvent {
514
+ type: string;
515
+ data?: Record<string, unknown>;
516
+ }
517
+ /** Identity of a chat turn. `tenantId` is the workspace id for workspace-
518
+ * scoped products and the user id for session-scoped products. */
519
+ interface ChatTurnIdentity {
520
+ tenantId: string;
521
+ /** Thread / session id — the durable run is keyed on this + `turnIndex`. */
522
+ sessionId: string;
523
+ userId: string;
524
+ /** Monotonic 0-based turn index within the session. */
525
+ turnIndex: number;
526
+ }
527
+ interface ChatTurnHooks {
528
+ /**
529
+ * Build the backend stream for this turn. The engine never inspects which
530
+ * backend this is — sandbox container, tcloud router, direct runtime, a
531
+ * test double — it only forwards the events and reads `finalText()`.
532
+ */
533
+ produce(): DurableTurnProducer<ChatStreamEvent>;
534
+ /**
535
+ * Persist the completed assistant message to the product's own store.
536
+ * Called once, after the stream drains, on a fresh (non-replay) run.
537
+ * Receives the assembled (and `transformFinalText`-transformed) text.
538
+ */
539
+ persistAssistantMessage(input: {
540
+ identity: ChatTurnIdentity;
541
+ finalText: string;
542
+ record: RunRecord | undefined;
543
+ }): Promise<void>;
544
+ /**
545
+ * Optional post-processing after persistence — proposal extraction,
546
+ * citation validation, credit metering, etc. Product policy; the engine
547
+ * has no shared logic here. Errors are swallowed + logged (post-process
548
+ * must never fail the turn that already streamed successfully).
549
+ */
550
+ onTurnComplete?(input: {
551
+ identity: ChatTurnIdentity;
552
+ finalText: string;
553
+ }): Promise<void>;
554
+ /**
555
+ * Optional per-event side channel (e.g. Durable Object broadcast). Runs
556
+ * for every event the engine emits, lifecycle envelope included. Errors
557
+ * are swallowed — a broadcast failure must not break the chat stream.
558
+ */
559
+ onEvent?(event: ChatStreamEvent): void | Promise<void>;
560
+ /**
561
+ * Optional pre-persist transform of the final text (e.g. PII redaction).
562
+ * Affects only what is persisted; the live stream is never altered.
563
+ */
564
+ transformFinalText?(text: string): string | Promise<string>;
565
+ /**
566
+ * Optional trace flush — resolves when OTLP export completes. The engine
567
+ * hands it to `waitUntil` so the worker isolate stays alive for the POST.
568
+ */
569
+ traceFlush?(): Promise<void>;
570
+ }
571
+ interface RunChatTurnInput {
572
+ identity: ChatTurnIdentity;
573
+ /** The user's message for this turn. Hashed into the durable run identity. */
574
+ userMessage: string;
575
+ /** Product id for telemetry / the durable manifest (`legal-agent`, …). */
576
+ projectId: string;
577
+ /** Domain tag for the task spec (`legal`, `gtm`, …). */
578
+ domain: string;
579
+ /** Model id, when known — recorded on the manifest. */
580
+ model?: string;
581
+ store: DurableRunStore;
582
+ hooks: ChatTurnHooks;
583
+ /** Worker liveness hook (`ctx.waitUntil` / `executionCtx.waitUntil`). When
584
+ * omitted, trace flush is awaited inline before the stream closes. */
585
+ waitUntil?: (p: Promise<unknown>) => void;
586
+ /** Stable per-isolate worker id. Defaults to a fresh `deriveWorkerId()`. */
587
+ workerId?: string;
588
+ /** Lease window in ms. Default 60_000. */
589
+ leaseMs?: number;
590
+ /** Optional structured logger for swallowed hook errors. */
591
+ log?: (message: string, meta?: Record<string, unknown>) => void;
592
+ }
593
+ interface ChatTurnResult {
594
+ /** NDJSON body — return this as the platform `Response` body. */
595
+ body: ReadableStream<Uint8Array>;
596
+ /** Content type for the response. */
597
+ contentType: 'application/x-ndjson';
598
+ }
599
+ /**
600
+ * The engine. One instance is stateless and reusable across requests — all
601
+ * per-turn state lives in `runTurn`'s closure.
602
+ */
603
+ declare class DurableChatTurnEngine {
604
+ /**
605
+ * Run one durable chat turn. Returns immediately with a `ReadableStream`
606
+ * body; the turn executes as the body is pulled. Never rejects — backend
607
+ * failures surface as `error` + `session.run.failed` events.
608
+ */
609
+ runTurn(input: RunChatTurnInput): ChatTurnResult;
610
+ }
611
+ /** Convenience singleton — the engine is stateless, one instance is enough. */
612
+ declare const durableChatTurnEngine: DurableChatTurnEngine;
613
+
387
614
  /**
388
615
  * D1DurableRunStore — the production path for Cloudflare Workers. Backed by
389
616
  * a D1 (SQLite-compatible) database via the binding the worker already holds.
@@ -1424,4 +1651,4 @@ declare function createTraceBridge(options: TraceBridgeOptions): TraceBridge;
1424
1651
  */
1425
1652
  declare function toAgentEvalTrace(event: RuntimeStreamEvent, options: TraceBridgeOptions): TraceEvent | undefined;
1426
1653
 
1427
- export { AgentBackendContext, AgentBackendInput, AgentExecutionBackend, AgentRuntimeEvent, AgentTaskRunResult, AgentTaskRunSummary, AgentTaskSpec, AgentTaskStatus, type BackendRetryPolicy, BackendTransportError, ChatTurnError, type ChatTurnMessage, type ChatTurnOverlay, type ChatTurnSandbox, type ClassifyIntentOptions, type ClassifyIntentResult, type ConformanceIssue, type ConformanceOptions, type ConformanceResult, type D1DatabaseLike, D1DurableRunStore, type D1PreparedStatementLike, DURABLE_SCHEMA_SQL, DURABLE_SCHEMA_VERSION, DurableAwaitEventTimeoutError, type DurableContext, DurableRunDivergenceError, DurableRunError, DurableRunInputMismatchError, DurableRunLeaseHeldError, type DurableRunManifest, type DurableRunStore, type EventRecord, FileSystemDurableRunStore, InMemoryDurableRunStore, InMemoryRuntimeSessionStore, KnowledgeReadinessDecision, RunAgentTaskOptions, RunAgentTaskStreamOptions, type RunChatTurnOptions, type RunDurableInput, type RunDurableResult, type RunOnWorkflowStepInput, type RunOutcome, type RunStatus, type RuntimeEventCollector, type RuntimeRunCompleteInput, type RuntimeRunCost, type RuntimeRunHandle, type RuntimeRunOptions, type RuntimeRunPersistenceAdapter, type RuntimeRunRow, RuntimeRunStateError, type RuntimeRunStatus, RuntimeSession, RuntimeSessionStore, RuntimeStreamEvent, type RuntimeStreamEventCollector, type RuntimeStreamEventSink, type RuntimeStreamEventSummary, type RuntimeTelemetryOptions, type SanitizedKnowledgeReadinessReport, type SanitizedKnowledgeRequirement, type ServerSentEventOptions, SessionMismatchError, type StepError, type StepKind, type StepRecord, type StepStatus, type SubagentMatcher, type TraceBridge, type TraceBridgeOptions, type WorkflowStepConfig, type WorkflowStepLike, assertProfileConformance, canonicalHash, canonicalJson, classifyIntent, composeTurnProfile, createIterableBackend, createOpenAICompatibleBackend, createRuntimeEventCollector, createRuntimeStreamEventCollector, createSandboxPromptBackend, createTraceBridge, decideKnowledgeReadiness, deriveWorkerId, encodeServerSentEvent, manifestHash, readinessServerSentEvent, runAgentTask, runAgentTaskStream, runChatTurn, runDurable, runOnWorkflowStep, runtimeStreamServerSentEvent, sandboxAsChatTurnTarget, sanitizeAgentRuntimeEvent, sanitizeKnowledgeReadinessReport, sanitizeRuntimeStreamEvent, startRuntimeRun, stepId, summarizeAgentTaskRun, toAgentEvalTrace };
1654
+ export { AgentBackendContext, AgentBackendInput, AgentExecutionBackend, AgentRuntimeEvent, AgentTaskRunResult, AgentTaskRunSummary, AgentTaskSpec, AgentTaskStatus, type BackendRetryPolicy, BackendTransportError, type ChatStreamEvent, ChatTurnError, type ChatTurnHooks, type ChatTurnIdentity, type ChatTurnMessage, type ChatTurnOverlay, type ChatTurnResult, type ChatTurnSandbox, type ClassifyIntentOptions, type ClassifyIntentResult, type ConformanceIssue, type ConformanceOptions, type ConformanceResult, type D1DatabaseLike, D1DurableRunStore, type D1PreparedStatementLike, DURABLE_SCHEMA_SQL, DURABLE_SCHEMA_VERSION, DurableAwaitEventTimeoutError, DurableChatTurnEngine, type DurableContext, DurableRunDivergenceError, DurableRunError, DurableRunInputMismatchError, DurableRunLeaseHeldError, type DurableRunManifest, type DurableRunStore, type DurableTurnHandle, type DurableTurnProducer, type EventRecord, FileSystemDurableRunStore, InMemoryDurableRunStore, InMemoryRuntimeSessionStore, KnowledgeReadinessDecision, RunAgentTaskOptions, RunAgentTaskStreamOptions, type RunChatTurnInput, type RunChatTurnOptions, type RunDurableInput, type RunDurableResult, type RunDurableTurnOptions, type RunOnWorkflowStepInput, type RunOutcome, type RunStatus, type RuntimeEventCollector, type RuntimeRunCompleteInput, type RuntimeRunCost, type RuntimeRunHandle, type RuntimeRunOptions, type RuntimeRunPersistenceAdapter, type RuntimeRunRow, RuntimeRunStateError, type RuntimeRunStatus, RuntimeSession, RuntimeSessionStore, RuntimeStreamEvent, type RuntimeStreamEventCollector, type RuntimeStreamEventSink, type RuntimeStreamEventSummary, type RuntimeTelemetryOptions, type SanitizedKnowledgeReadinessReport, type SanitizedKnowledgeRequirement, type ServerSentEventOptions, SessionMismatchError, type StepError, type StepKind, type StepRecord, type StepStatus, type SubagentMatcher, type TraceBridge, type TraceBridgeOptions, type WorkflowStepConfig, type WorkflowStepLike, assertProfileConformance, canonicalHash, canonicalJson, classifyIntent, composeTurnProfile, createIterableBackend, createOpenAICompatibleBackend, createRuntimeEventCollector, createRuntimeStreamEventCollector, createSandboxPromptBackend, createTraceBridge, decideKnowledgeReadiness, deriveWorkerId, durableChatTurnEngine, encodeServerSentEvent, manifestHash, readinessServerSentEvent, runAgentTask, runAgentTaskStream, runChatTurn, runDurable, runDurableTurn, runOnWorkflowStep, runtimeStreamServerSentEvent, sandboxAsChatTurnTarget, sanitizeAgentRuntimeEvent, sanitizeKnowledgeReadinessReport, sanitizeRuntimeStreamEvent, startRuntimeRun, stepId, summarizeAgentTaskRun, toAgentEvalTrace };
package/dist/index.js CHANGED
@@ -113,6 +113,32 @@ function pickRetryDelayMs(attempt, policy) {
113
113
  const jitter = capped * policy.jitter * (Math.random() * 2 - 1);
114
114
  return Math.max(0, Math.round(capped + jitter));
115
115
  }
116
+ function withTimeout(callerSignal, timeoutMs) {
117
+ if (timeoutMs <= 0) {
118
+ return { signal: callerSignal ?? new AbortController().signal, dispose: () => void 0 };
119
+ }
120
+ const controller = new AbortController();
121
+ const timer = setTimeout(
122
+ () => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)),
123
+ timeoutMs
124
+ );
125
+ if (typeof timer.unref === "function") {
126
+ ;
127
+ timer.unref();
128
+ }
129
+ const onCallerAbort = () => controller.abort(callerSignal?.reason ?? new Error("aborted"));
130
+ if (callerSignal) {
131
+ if (callerSignal.aborted) onCallerAbort();
132
+ else callerSignal.addEventListener("abort", onCallerAbort, { once: true });
133
+ }
134
+ return {
135
+ signal: controller.signal,
136
+ dispose: () => {
137
+ clearTimeout(timer);
138
+ callerSignal?.removeEventListener("abort", onCallerAbort);
139
+ }
140
+ };
141
+ }
116
142
  function sleep(ms, signal) {
117
143
  return new Promise((resolve, reject) => {
118
144
  if (signal?.aborted) {
@@ -138,7 +164,8 @@ function createOpenAICompatibleBackend(options) {
138
164
  initialBackoffMs: options.retry?.initialBackoffMs ?? 1e3,
139
165
  maxBackoffMs: options.retry?.maxBackoffMs ?? 3e4,
140
166
  jitter: options.retry?.jitter ?? 0.25,
141
- retryStatuses: options.retry?.retryStatuses ?? DEFAULT_RETRY_STATUSES
167
+ retryStatuses: options.retry?.retryStatuses ?? DEFAULT_RETRY_STATUSES,
168
+ requestTimeoutMs: options.retry?.requestTimeoutMs ?? 12e4
142
169
  };
143
170
  return {
144
171
  kind,
@@ -146,24 +173,40 @@ function createOpenAICompatibleBackend(options) {
146
173
  return newRuntimeSession(kind, context.requestedSessionId);
147
174
  },
148
175
  async *stream(input, context) {
176
+ const url = `${options.baseUrl.replace(/\/$/, "")}/chat/completions`;
177
+ const requestBody = JSON.stringify({
178
+ model: options.model,
179
+ stream: true,
180
+ messages: input.messages ?? [
181
+ { role: "user", content: input.message ?? context.task.intent }
182
+ ]
183
+ });
149
184
  let response;
150
185
  let lastStatus = 0;
186
+ let lastThrown;
151
187
  for (let attempt = 1; attempt <= retryPolicy.maxAttempts; attempt++) {
152
- response = await fetcher(`${options.baseUrl.replace(/\/$/, "")}/chat/completions`, {
153
- method: "POST",
154
- headers: {
155
- Authorization: `Bearer ${options.apiKey}`,
156
- "Content-Type": "application/json"
157
- },
158
- body: JSON.stringify({
159
- model: options.model,
160
- stream: true,
161
- messages: input.messages ?? [
162
- { role: "user", content: input.message ?? context.task.intent }
163
- ]
164
- }),
165
- signal: context.signal
166
- });
188
+ lastThrown = void 0;
189
+ const attemptSignal = withTimeout(context.signal, retryPolicy.requestTimeoutMs);
190
+ try {
191
+ response = await fetcher(url, {
192
+ method: "POST",
193
+ headers: {
194
+ Authorization: `Bearer ${options.apiKey}`,
195
+ "Content-Type": "application/json"
196
+ },
197
+ body: requestBody,
198
+ signal: attemptSignal.signal
199
+ });
200
+ } catch (err) {
201
+ attemptSignal.dispose();
202
+ if (context.signal?.aborted) throw err;
203
+ lastThrown = err;
204
+ response = void 0;
205
+ if (attempt === retryPolicy.maxAttempts) break;
206
+ await sleep(pickRetryDelayMs(attempt, retryPolicy), context.signal);
207
+ continue;
208
+ }
209
+ attemptSignal.dispose();
167
210
  if (response.ok) break;
168
211
  lastStatus = response.status;
169
212
  if (!retryPolicy.retryStatuses.includes(response.status)) break;
@@ -175,7 +218,15 @@ function createOpenAICompatibleBackend(options) {
175
218
  const delayMs = pickRetryDelayMs(attempt, retryPolicy);
176
219
  await sleep(delayMs, context.signal);
177
220
  }
178
- if (!response || !response.ok) {
221
+ if (!response) {
222
+ const reason = lastThrown instanceof Error ? lastThrown.message : String(lastThrown);
223
+ throw new BackendTransportError(
224
+ kind,
225
+ `chat backend unreachable after ${retryPolicy.maxAttempts} attempts: ${reason}`,
226
+ { status: 0 }
227
+ );
228
+ }
229
+ if (!response.ok) {
179
230
  throw new BackendTransportError(kind, `chat backend returned ${lastStatus || "unknown"}`, {
180
231
  status: lastStatus || 0
181
232
  });
@@ -497,6 +548,228 @@ function deriveWorkerId() {
497
548
  return `${host}:${pid}:${rand}:${counter}`;
498
549
  }
499
550
 
551
+ // src/durable/turn.ts
552
+ var STEP_INDEX = 0;
553
+ function runDurableTurn(options) {
554
+ const { store, runId, manifest, workerId } = options;
555
+ const leaseMs = options.leaseMs ?? 6e4;
556
+ const intent = options.intent ?? "turn";
557
+ const inputHash = canonicalHash(manifest.input);
558
+ let accumulated = "";
559
+ let didReplay = false;
560
+ let finalRecord;
561
+ async function* stream() {
562
+ const { completedSteps } = await store.startOrResume({
563
+ runId,
564
+ manifest,
565
+ workerId,
566
+ leaseMs
567
+ });
568
+ const prior = completedSteps.find((s) => s.stepIndex === STEP_INDEX);
569
+ if (prior && prior.status === "completed") {
570
+ didReplay = true;
571
+ const cached = prior.result;
572
+ accumulated = cached?.finalText ?? "";
573
+ yield options.replayEvent(accumulated);
574
+ finalRecord = await store.endRun({ runId, workerId, status: "completed" });
575
+ return;
576
+ }
577
+ await store.beginStep({
578
+ runId,
579
+ stepIndex: STEP_INDEX,
580
+ intent,
581
+ kind: "llm",
582
+ inputHash
583
+ });
584
+ try {
585
+ const producer = options.produce();
586
+ for await (const event of producer.stream) {
587
+ if (options.accumulate) {
588
+ const next = options.accumulate(event, accumulated);
589
+ if (typeof next === "string") accumulated = next;
590
+ }
591
+ yield event;
592
+ }
593
+ const producerText = producer.finalText();
594
+ if (producerText) accumulated = producerText;
595
+ await store.completeStep({
596
+ runId,
597
+ stepIndex: STEP_INDEX,
598
+ result: { finalText: accumulated }
599
+ });
600
+ finalRecord = await store.endRun({
601
+ runId,
602
+ workerId,
603
+ status: "completed",
604
+ outcome: { notes: intent, metadata: { chars: accumulated.length } }
605
+ });
606
+ } catch (err) {
607
+ await store.failStep({
608
+ runId,
609
+ stepIndex: STEP_INDEX,
610
+ error: { message: err instanceof Error ? err.message : String(err) }
611
+ });
612
+ finalRecord = await store.endRun({ runId, workerId, status: "failed" });
613
+ throw err;
614
+ }
615
+ }
616
+ return {
617
+ stream: stream(),
618
+ finalText: () => accumulated,
619
+ replayed: () => didReplay,
620
+ record: () => finalRecord
621
+ };
622
+ }
623
+
624
+ // src/durable/chat-engine.ts
625
+ var encoder = new TextEncoder();
626
+ function encodeLine(event) {
627
+ return encoder.encode(`${JSON.stringify(event)}
628
+ `);
629
+ }
630
+ var DurableChatTurnEngine = class {
631
+ /**
632
+ * Run one durable chat turn. Returns immediately with a `ReadableStream`
633
+ * body; the turn executes as the body is pulled. Never rejects — backend
634
+ * failures surface as `error` + `session.run.failed` events.
635
+ */
636
+ runTurn(input) {
637
+ const workerId = input.workerId ?? deriveWorkerId();
638
+ const log = input.log ?? (() => void 0);
639
+ const { identity } = input;
640
+ const runId = `chat:${identity.sessionId}:${identity.turnIndex}`;
641
+ const manifest = {
642
+ projectId: input.projectId,
643
+ scenarioId: identity.sessionId,
644
+ task: {
645
+ id: `${input.projectId}:chat:${identity.sessionId}:${identity.turnIndex}`,
646
+ intent: `Run a ${input.domain} chat turn with workspace context.`,
647
+ domain: input.domain,
648
+ requiredKnowledge: [],
649
+ metadata: {
650
+ tenantId: identity.tenantId,
651
+ sessionId: identity.sessionId,
652
+ turnIndex: identity.turnIndex
653
+ }
654
+ },
655
+ input: {
656
+ userMessage: input.userMessage,
657
+ model: input.model ?? null
658
+ },
659
+ tags: {
660
+ session_id: identity.sessionId,
661
+ tenant_id: identity.tenantId
662
+ }
663
+ };
664
+ const body = new ReadableStream({
665
+ start: async (controller) => {
666
+ const emit2 = async (event) => {
667
+ controller.enqueue(encodeLine(event));
668
+ if (input.hooks.onEvent) {
669
+ try {
670
+ await input.hooks.onEvent(event);
671
+ } catch (err) {
672
+ log("[chat-engine] onEvent hook threw", {
673
+ error: err instanceof Error ? err.message : String(err)
674
+ });
675
+ }
676
+ }
677
+ };
678
+ let turnFailed = false;
679
+ try {
680
+ await emit2({
681
+ type: "session.run.started",
682
+ data: {
683
+ sessionId: identity.sessionId,
684
+ tenantId: identity.tenantId,
685
+ turnIndex: identity.turnIndex
686
+ }
687
+ });
688
+ const turn = runDurableTurn({
689
+ store: input.store,
690
+ runId,
691
+ manifest,
692
+ workerId,
693
+ leaseMs: input.leaseMs,
694
+ intent: `chat:turn-${identity.turnIndex}`,
695
+ produce: input.hooks.produce,
696
+ replayEvent: (finalText2) => ({ type: "result", data: { finalText: finalText2 } }),
697
+ accumulate: (event, current) => {
698
+ if (event.type === "message.part.updated") {
699
+ const data = event.data ?? {};
700
+ const delta = typeof data.delta === "string" ? data.delta : "";
701
+ const part = data.part;
702
+ if (delta) return current + delta;
703
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
704
+ return void 0;
705
+ }
706
+ if (event.type === "result") {
707
+ const data = event.data ?? {};
708
+ if (typeof data.finalText === "string") return data.finalText;
709
+ }
710
+ return void 0;
711
+ }
712
+ });
713
+ for await (const event of turn.stream) {
714
+ await emit2(event);
715
+ }
716
+ const rawFinal = turn.finalText();
717
+ const finalText = input.hooks.transformFinalText ? await input.hooks.transformFinalText(rawFinal) : rawFinal;
718
+ if (!turn.replayed()) {
719
+ try {
720
+ await input.hooks.persistAssistantMessage({
721
+ identity,
722
+ finalText,
723
+ record: turn.record()
724
+ });
725
+ } catch (err) {
726
+ log("[chat-engine] persistAssistantMessage threw", {
727
+ error: err instanceof Error ? err.message : String(err)
728
+ });
729
+ }
730
+ if (input.hooks.onTurnComplete) {
731
+ try {
732
+ await input.hooks.onTurnComplete({ identity, finalText });
733
+ } catch (err) {
734
+ log("[chat-engine] onTurnComplete threw", {
735
+ error: err instanceof Error ? err.message : String(err)
736
+ });
737
+ }
738
+ }
739
+ }
740
+ await emit2({
741
+ type: "session.run.completed",
742
+ data: { sessionId: identity.sessionId, replayed: turn.replayed() }
743
+ });
744
+ } catch (err) {
745
+ turnFailed = true;
746
+ const message = err instanceof Error ? err.message : String(err);
747
+ log("[chat-engine] turn failed", { error: message });
748
+ await emit2({ type: "error", data: { message } });
749
+ await emit2({
750
+ type: "session.run.failed",
751
+ data: { sessionId: identity.sessionId, message }
752
+ });
753
+ } finally {
754
+ if (input.hooks.traceFlush) {
755
+ const flush = input.hooks.traceFlush().catch(
756
+ (err) => log("[chat-engine] traceFlush threw", {
757
+ error: err instanceof Error ? err.message : String(err)
758
+ })
759
+ );
760
+ if (input.waitUntil) input.waitUntil(flush);
761
+ else await flush;
762
+ }
763
+ controller.close();
764
+ void turnFailed;
765
+ }
766
+ }
767
+ });
768
+ return { body, contentType: "application/x-ndjson" };
769
+ }
770
+ };
771
+ var durableChatTurnEngine = new DurableChatTurnEngine();
772
+
500
773
  // src/durable/types.ts
501
774
  var DurableRunError = class extends Error {
502
775
  constructor(message, code) {
@@ -2865,6 +3138,7 @@ export {
2865
3138
  DURABLE_SCHEMA_SQL,
2866
3139
  DURABLE_SCHEMA_VERSION,
2867
3140
  DurableAwaitEventTimeoutError,
3141
+ DurableChatTurnEngine,
2868
3142
  DurableRunDivergenceError,
2869
3143
  DurableRunError,
2870
3144
  DurableRunInputMismatchError,
@@ -2892,6 +3166,7 @@ export {
2892
3166
  createTraceBridge,
2893
3167
  decideKnowledgeReadiness,
2894
3168
  deriveWorkerId,
3169
+ durableChatTurnEngine,
2895
3170
  encodeServerSentEvent,
2896
3171
  manifestHash,
2897
3172
  readinessServerSentEvent,
@@ -2899,6 +3174,7 @@ export {
2899
3174
  runAgentTaskStream,
2900
3175
  runChatTurn,
2901
3176
  runDurable,
3177
+ runDurableTurn,
2902
3178
  runOnWorkflowStep,
2903
3179
  runtimeStreamServerSentEvent,
2904
3180
  sandboxAsChatTurnTarget,