@independo/inderun-web 0.5.0-rc.1 → 0.5.0-rc.2

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 (39) hide show
  1. package/dist/core/artifacts.d.ts +110 -0
  2. package/dist/core/artifacts.d.ts.map +1 -0
  3. package/dist/core/artifacts.js +190 -0
  4. package/dist/core/engine.d.ts +28 -1
  5. package/dist/core/engine.d.ts.map +1 -1
  6. package/dist/core/engine.js +232 -28
  7. package/dist/core/generated/inderun-api.d.ts +12 -4
  8. package/dist/core/generated/inderun-api.d.ts.map +1 -1
  9. package/dist/core/host.d.ts +1 -1
  10. package/dist/core/host.d.ts.map +1 -1
  11. package/dist/core/provenance.d.ts +5 -2
  12. package/dist/core/provenance.d.ts.map +1 -1
  13. package/dist/core/provenance.js +6 -2
  14. package/dist/core/provider.d.ts +44 -1
  15. package/dist/core/provider.d.ts.map +1 -1
  16. package/dist/core/route-planner.d.ts.map +1 -1
  17. package/dist/core/route-planner.js +5 -6
  18. package/dist/core/router.d.ts +16 -0
  19. package/dist/core/router.d.ts.map +1 -1
  20. package/dist/core/router.js +14 -5
  21. package/dist/core/task-profiles.d.ts +54 -0
  22. package/dist/core/task-profiles.d.ts.map +1 -0
  23. package/dist/core/task-profiles.js +92 -0
  24. package/dist/facade/text.d.ts.map +1 -1
  25. package/dist/facade/text.js +5 -3
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/core/artifacts.ts +280 -0
  30. package/src/core/engine.ts +281 -27
  31. package/src/core/generated/inderun-api.ts +12 -4
  32. package/src/core/host.ts +1 -0
  33. package/src/core/provenance.ts +7 -2
  34. package/src/core/provider.ts +53 -0
  35. package/src/core/route-planner.ts +5 -6
  36. package/src/core/router.ts +30 -7
  37. package/src/core/task-profiles.ts +145 -0
  38. package/src/facade/text.ts +5 -3
  39. package/src/index.ts +6 -1
@@ -1,8 +1,12 @@
1
1
  import {
2
+ type ArtifactReadRequest,
3
+ getArtifactReadRequestValidationIssues,
2
4
  getTaskRequestValidationIssues,
5
+ type MediaArtifact,
3
6
  type StreamEvent,
4
7
  type StreamRunHandle,
5
8
  type StreamTerminalOutcome,
9
+ type TaskOutput,
6
10
  type TaskRequest,
7
11
  type TaskResult
8
12
  } from "@independo/inderun-contracts";
@@ -27,6 +31,18 @@ import {
27
31
  validateModelProfiles
28
32
  } from "./model-profiles.js";
29
33
  import { ProvenanceRecorder } from "./provenance.js";
34
+ import {
35
+ checkOutput,
36
+ type OutputDelivery,
37
+ recheckBoundVoice,
38
+ taskProfileFor
39
+ } from "./task-profiles.js";
40
+ import {
41
+ type ArtifactChunk,
42
+ type ArtifactLimits,
43
+ ArtifactStore,
44
+ type AttemptArtifacts
45
+ } from "./artifacts.js";
30
46
 
31
47
  /**
32
48
  * @deprecated Renamed to `StreamRun`, which is the name the generated
@@ -49,6 +65,11 @@ export interface IndeRunOptions {
49
65
  * `"auto"` is built in and cannot be redefined. See `docs/architecture/api-layering.md`.
50
66
  */
51
67
  modelProfiles?: ModelProfiles;
68
+ /**
69
+ * Size limits of the engine's in-memory artifact store (ADR 017). Defaults: 16 MiB per
70
+ * artifact, 64 MiB in total, 1 MiB per `readArtifact` call.
71
+ */
72
+ artifacts?: ArtifactLimits;
52
73
  }
53
74
 
54
75
  /**
@@ -58,6 +79,7 @@ export interface IndeRunOptions {
58
79
  */
59
80
  export class IndeRun implements IndeRunApi {
60
81
  private router: Router;
82
+ private readonly artifacts: ArtifactStore;
61
83
  private telemetryService: TelemetryService;
62
84
  private modelProfiles: ModelProfiles;
63
85
 
@@ -78,6 +100,7 @@ export class IndeRun implements IndeRunApi {
78
100
  options.telemetryService || this.hostServices.telemetry || new NoOpTelemetryService();
79
101
  this.modelProfiles = options.modelProfiles ?? {};
80
102
  validateModelProfiles(this.modelProfiles);
103
+ this.artifacts = new ArtifactStore(options.artifacts);
81
104
  }
82
105
 
83
106
  /**
@@ -87,6 +110,12 @@ export class IndeRun implements IndeRunApi {
87
110
  */
88
111
  private validateRequest(request: TaskRequest, runId: string): ResolvedModelProfile {
89
112
  const validationIssues = getTaskRequestValidationIssues(request);
113
+ if (validationIssues.length === 0 && taskProfileFor(request.task.kind) === undefined) {
114
+ validationIssues.push({
115
+ path: "/task/kind",
116
+ message: `task kind "${request.task.kind}" is not supported by this engine`
117
+ });
118
+ }
90
119
  if (validationIssues.length === 0) {
91
120
  const resolved = resolveModelProfile(this.modelProfiles, request.modelProfile);
92
121
  if (resolved.known) {
@@ -190,10 +219,13 @@ export class IndeRun implements IndeRunApi {
190
219
 
191
220
  const runId = request.requestId || `run_${Math.random().toString(36).substring(2, 11)}`;
192
221
  const provenance = new ProvenanceRecorder(request.modelProfile ?? AUTO_MODEL_PROFILE);
222
+ // The provider a failed run had committed to, which the failure is attributed to.
223
+ let committedProvider: ProviderAdapter | undefined;
193
224
 
194
225
  try {
195
226
  // 1. Validate request payload using schema contracts and resolve its model profile
196
227
  const modelProfile = this.validateRequest(request, runId);
228
+ await this.checkDestination(request, runId);
197
229
 
198
230
  // 2. Select the route based on constraints, model profile, and host capabilities
199
231
  const routeSelection = await this.router.selectRoute(
@@ -204,21 +236,50 @@ export class IndeRun implements IndeRunApi {
204
236
  );
205
237
  const providers = [routeSelection.provider, ...routeSelection.fallbackProviders];
206
238
  provenance.routed(routeSelection.routePlan);
239
+ const delivery = taskProfileFor(request.task.kind)!.delivery(request);
207
240
 
208
241
  this.emitRouteDecided(runId, request, routeSelection, providers.length);
209
242
 
210
- // 3. Execute the run task using the planned provider chain
243
+ // 3. Execute the run task using the planned provider chain. An attempt that committed
244
+ // (see RunContext.commit) ends the chain: its failure is the run's failure.
211
245
  const attemptedProviderIds: string[] = [];
212
246
  let lastError: unknown;
213
247
  for (const [index, provider] of providers.entries()) {
214
248
  const providerId = provider.describe().id;
249
+ const voice = routeSelection.candidates[index]?.voice;
215
250
  attemptedProviderIds.push(providerId);
251
+ let committed = false;
252
+ // Closed when the attempt ends: a provider callback that fires after the run moved
253
+ // on must not be told it may start, say, audible playback.
254
+ let attemptOpen = true;
255
+ const commit = () => {
256
+ if (!attemptOpen) return false;
257
+ committed = true;
258
+ return true;
259
+ };
260
+ const attemptArtifacts = this.artifacts.attempt(commit);
261
+ let keptArtifactId: string | undefined;
216
262
 
217
263
  try {
264
+ const refusal = recheckBoundVoice(request, providerId, voice);
265
+ if (refusal !== undefined) throw refusal;
266
+
218
267
  const result = await provider.run(request, {
219
268
  runId,
220
- hostServices: this.hostServices
269
+ hostServices: this.hostServices,
270
+ ...(voice !== undefined ? { voice } : {}),
271
+ commit,
272
+ artifacts: attemptArtifacts
221
273
  });
274
+ result.output = this.normalizeOutput(
275
+ request,
276
+ delivery,
277
+ result.output,
278
+ providerId,
279
+ attemptArtifacts
280
+ );
281
+ result.output = await this.deliverToDestination(request, result.output, providerId);
282
+ keptArtifactId = engineOwnedArtifactId(result.output);
222
283
 
223
284
  const endTime = this.now();
224
285
  const totalMs = endTime - startTime;
@@ -229,7 +290,7 @@ export class IndeRun implements IndeRunApi {
229
290
  providerUsed: providerId,
230
291
  totalMs
231
292
  };
232
- provenance.attempted(providerId, "succeeded");
293
+ provenance.attempted(providerId, "succeeded", undefined, committed);
233
294
  result.provenance = provenance.build(provider.describe(), result.telemetry.reportedModel);
234
295
 
235
296
  this.safeEmit({
@@ -255,8 +316,15 @@ export class IndeRun implements IndeRunApi {
255
316
  routePlan: routeSelection.routePlan
256
317
  }
257
318
  });
258
- provenance.attempted(providerId, "failed", attemptError.errorClass);
319
+ provenance.attempted(providerId, "failed", attemptError.errorClass, committed);
259
320
  lastError = attemptError;
321
+ if (committed) {
322
+ committedProvider = provider;
323
+ break;
324
+ }
325
+ } finally {
326
+ attemptOpen = false;
327
+ attemptArtifacts.release(keptArtifactId);
260
328
  }
261
329
  }
262
330
 
@@ -285,8 +353,9 @@ export class IndeRun implements IndeRunApi {
285
353
  });
286
354
  // A routing refusal throws before any attempt; its rejections travel on the details.
287
355
  provenance.routingFailed(normalized.details);
288
- // No attempt produced the result, so the outcome is attributed to no provider.
289
- const exception = normalized.withProvenance(provenance.build());
356
+ // No attempt produced the result, so the outcome is attributed to no provider -- unless
357
+ // the run had committed to one, which then owns the failure.
358
+ const exception = normalized.withProvenance(provenance.build(committedProvider?.describe()));
290
359
  const endTime = this.now();
291
360
  const totalMs =
292
361
  typeof (exception as { details?: { totalMs?: number } }).details?.totalMs === "number"
@@ -336,6 +405,7 @@ export class IndeRun implements IndeRunApi {
336
405
  let modelProfile: ResolvedModelProfile;
337
406
  try {
338
407
  modelProfile = this.validateRequest(request, runId);
408
+ await this.checkDestination(request, runId);
339
409
  } catch (error) {
340
410
  throw toIndeRunException(error, { runId }).withProvenance(provenance.build());
341
411
  }
@@ -384,24 +454,40 @@ export class IndeRun implements IndeRunApi {
384
454
  providerId: providers[0]!.describe().id
385
455
  };
386
456
 
457
+ const delivery = taskProfileFor(request.task.kind)!.delivery(request);
458
+ const textRun = delivery === "inline";
459
+ const artifacts = this.artifacts;
460
+ const normalizeOutput = this.normalizeOutput.bind(this);
461
+ const deliverToDestination = this.deliverToDestination.bind(this);
462
+
387
463
  async function* run(): AsyncGenerator<StreamEvent> {
388
464
  const attemptedProviderIds: string[] = [];
465
+ // Set once, by whichever comes first in an attempt: the provider's commit(), its first
466
+ // admitted content event, or its result handoff. After it no other provider is tried.
389
467
  let committed = false;
468
+ // Whether the committed attempt was managed playback that may have been heard.
469
+ let playbackCommitted = false;
390
470
 
391
471
  const cancelledOutcome = (
392
472
  partialText: string,
393
- cancelledProvider?: ProviderAdapter
473
+ cancelledProvider?: ProviderAdapter,
474
+ delivered?: TaskOutput
394
475
  ): StreamTerminalOutcome => {
395
476
  const reason =
396
477
  typeof controller.signal.reason === "string" ? controller.signal.reason : undefined;
397
478
  if (cancelledProvider !== undefined) {
398
- provenance.attempted(cancelledProvider.describe().id, "cancelled");
479
+ provenance.attempted(cancelledProvider.describe().id, "cancelled", undefined, committed);
399
480
  }
400
481
  return {
401
482
  outcome: "cancelled",
402
483
  runId,
403
484
  schemaVersion: "1.0",
404
- partialText,
485
+ // A text run reports what it delivered; committed playback reports that it played; an
486
+ // artifact already written to its destination is reported, since the application
487
+ // owns those bytes now. An uncommitted non-text run produced nothing to report.
488
+ ...(textRun ? { partialText } : {}),
489
+ ...(playbackCommitted ? { output: { type: "playback" as const, playback: {} } } : {}),
490
+ ...(delivered !== undefined ? { output: delivered } : {}),
405
491
  ...(reason !== undefined ? { reason } : {}),
406
492
  provenance: provenance.build(cancelledProvider?.describe())
407
493
  };
@@ -413,6 +499,7 @@ export class IndeRun implements IndeRunApi {
413
499
  }
414
500
 
415
501
  const providerId = provider.describe().id;
502
+ const voice = routeSelection.candidates[index]?.voice;
416
503
  attemptedProviderIds.push(providerId);
417
504
  safeEmit({
418
505
  type: "stream_attempt_started",
@@ -421,12 +508,41 @@ export class IndeRun implements IndeRunApi {
421
508
  payload: { providerId, fallbackOccurred: index > 0 }
422
509
  });
423
510
 
511
+ const markCommitted = () => {
512
+ if (committed) return;
513
+ committed = true;
514
+ playbackCommitted = delivery === "playback";
515
+ safeEmit({
516
+ type: "stream_attempt_succeeded",
517
+ runId,
518
+ timestamp: now(),
519
+ payload: { providerId, attemptedProviderIds }
520
+ });
521
+ };
522
+
523
+ // Closed when the attempt ends. A provider callback that fires after the run fell back
524
+ // must neither start audio nor commit the attempt that replaced this one.
525
+ let attemptOpen = true;
526
+ const commit = () => {
527
+ if (!attemptOpen || controller.signal.aborted || gate.isTerminated()) return false;
528
+ markCommitted();
529
+ return true;
530
+ };
531
+ const attemptArtifacts = artifacts.attempt(commit);
532
+ let keptArtifactId: string | undefined;
533
+
424
534
  let partialText = "";
425
535
  try {
536
+ const refusal = recheckBoundVoice(request, providerId, voice);
537
+ if (refusal !== undefined) throw refusal;
538
+
426
539
  const iterable = (provider as Required<Pick<ProviderAdapter, "stream">>).stream(request, {
427
540
  runId,
428
541
  hostServices: hostServices,
429
- signal: controller.signal
542
+ signal: controller.signal,
543
+ ...(voice !== undefined ? { voice } : {}),
544
+ commit,
545
+ artifacts: attemptArtifacts
430
546
  });
431
547
 
432
548
  let terminatedInLoop = false;
@@ -440,16 +556,12 @@ export class IndeRun implements IndeRunApi {
440
556
  }
441
557
 
442
558
  if (ev.kind === "delta" || ev.kind === "snapshot") {
443
- partialText = ev.kind === "delta" ? partialText + ev.text : ev.text;
444
- if (!committed) {
445
- committed = true;
446
- safeEmit({
447
- type: "stream_attempt_succeeded",
448
- runId,
449
- timestamp: now(),
450
- payload: { providerId, attemptedProviderIds }
451
- });
559
+ // Content events are text-only (ADR 016 §5).
560
+ if (!textRun) {
561
+ throw checkOutput(request, delivery, { type: "text", text: "" }, providerId)!;
452
562
  }
563
+ partialText = ev.kind === "delta" ? partialText + ev.text : ev.text;
564
+ markCommitted();
453
565
  const streamEvent = gate.admit({
454
566
  timestamp: now(),
455
567
  type: ev.kind === "delta" ? "content_delta" : "content_snapshot",
@@ -459,16 +571,49 @@ export class IndeRun implements IndeRunApi {
459
571
  continue;
460
572
  }
461
573
 
462
- // ev.kind === "done"
463
- provenance.attempted(providerId, "succeeded");
574
+ // ev.kind === "done" (text) or "result" (any task): the run's final output.
575
+ const output = normalizeOutput(
576
+ request,
577
+ delivery,
578
+ ev.kind === "done" ? { type: "text" as const, text: ev.finalText } : ev.output,
579
+ providerId,
580
+ attemptArtifacts
581
+ );
582
+ markCommitted();
583
+ const delivered = await deliverToDestination(request, output, providerId);
584
+ // A cancel that landed during the destination write did not interrupt it, but the
585
+ // run still ends cancelled. Bytes already at the destination are the application's,
586
+ // so the outcome reports them; anything else the usual cancel path below releases.
587
+ if (controller.signal.aborted) {
588
+ if (delivered.artifact?.owner !== "application") break;
589
+ const terminal = gate.terminate(
590
+ cancelledOutcome(partialText, provider, delivered),
591
+ now()
592
+ );
593
+ safeEmit({
594
+ type: "stream_cancelled",
595
+ runId,
596
+ timestamp: now(),
597
+ payload: { providerId, attemptedProviderIds }
598
+ });
599
+ terminatedInLoop = true;
600
+ if (terminal) yield terminal;
601
+ return;
602
+ }
603
+ keptArtifactId = engineOwnedArtifactId(delivered);
604
+ provenance.attempted(providerId, "succeeded", undefined, committed);
464
605
  const outcome: StreamTerminalOutcome = {
465
606
  outcome: "completed",
466
607
  runId,
467
608
  schemaVersion: "1.0",
468
- finalText: ev.finalText,
609
+ ...(ev.kind === "done"
610
+ ? { finalText: ev.finalText }
611
+ : output.type === "text"
612
+ ? { finalText: output.text!, output }
613
+ : { output }),
469
614
  telemetry: { providerUsed: providerId, totalMs: now() - startTime },
470
615
  ...(ev.finishReason !== undefined ? { finishReason: ev.finishReason } : {}),
471
- ...(ev.usage !== undefined ? { usage: ev.usage } : {}),
616
+ ...(ev.kind === "done" && ev.usage !== undefined ? { usage: ev.usage } : {}),
472
617
  provenance: provenance.build(provider.describe(), ev.reportedModel)
473
618
  };
474
619
  const terminal = gate.terminate(outcome, now());
@@ -488,6 +633,7 @@ export class IndeRun implements IndeRunApi {
488
633
  }
489
634
 
490
635
  if (controller.signal.aborted) {
636
+ attemptArtifacts.release();
491
637
  const terminal = gate.terminate(cancelledOutcome(partialText, provider), now());
492
638
  safeEmit({
493
639
  type: "stream_cancelled",
@@ -499,10 +645,11 @@ export class IndeRun implements IndeRunApi {
499
645
  return;
500
646
  }
501
647
 
502
- // Provider's iterable ended without a terminal "done"/"error" event.
648
+ // Provider's iterable ended without a terminal "done"/"result"/"error" event.
503
649
  throw new Error(`Provider '${providerId}' stream ended without a terminal event.`);
504
650
  } catch (err) {
505
651
  if (controller.signal.aborted) {
652
+ attemptArtifacts.release();
506
653
  const terminal = gate.terminate(cancelledOutcome(partialText, provider), now());
507
654
  safeEmit({
508
655
  type: "stream_cancelled",
@@ -519,7 +666,7 @@ export class IndeRun implements IndeRunApi {
519
666
  runId,
520
667
  details: { attemptedProviderIds }
521
668
  });
522
- provenance.attempted(providerId, "failed", exception.errorClass);
669
+ provenance.attempted(providerId, "failed", exception.errorClass, committed);
523
670
 
524
671
  if (!committed) {
525
672
  safeEmit({
@@ -536,12 +683,13 @@ export class IndeRun implements IndeRunApi {
536
683
  }
537
684
 
538
685
  // Committed: the failure belongs to this provider, not to the chain.
686
+ attemptArtifacts.release();
539
687
  const outcome: StreamTerminalOutcome = {
540
688
  outcome: "error",
541
689
  runId,
542
690
  schemaVersion: "1.0",
543
691
  error: exception.toContractError(),
544
- partialText,
692
+ ...(textRun ? { partialText } : {}),
545
693
  provenance: provenance.build(provider.describe())
546
694
  };
547
695
  const terminal = gate.terminate(outcome, now());
@@ -558,6 +706,9 @@ export class IndeRun implements IndeRunApi {
558
706
  });
559
707
  if (terminal) yield terminal;
560
708
  return;
709
+ } finally {
710
+ attemptOpen = false;
711
+ attemptArtifacts.release(keptArtifactId);
561
712
  }
562
713
  }
563
714
 
@@ -583,7 +734,7 @@ export class IndeRun implements IndeRunApi {
583
734
  runId,
584
735
  schemaVersion: "1.0",
585
736
  error: exception.toContractError(),
586
- partialText: "",
737
+ ...(textRun ? { partialText: "" } : {}),
587
738
  provenance: provenance.build()
588
739
  };
589
740
  const terminal = gate.terminate(outcome, now());
@@ -607,6 +758,104 @@ export class IndeRun implements IndeRunApi {
607
758
  * Reports each registered provider's static descriptor and current dynamic capability check,
608
759
  * without executing a task. Useful for UI that shows live provider availability before a run.
609
760
  */
761
+ async readArtifact(request: ArtifactReadRequest): Promise<ArtifactChunk> {
762
+ const validationIssues = getArtifactReadRequestValidationIssues(request);
763
+ if (validationIssues.length > 0) {
764
+ const message = `Validation failed for ArtifactReadRequest: ${validationIssues
765
+ .map((i) => `${i.path} - ${i.message}`)
766
+ .join("; ")}`;
767
+ throw createInternal(message, { details: { validationIssues } });
768
+ }
769
+ return this.artifacts.read(request);
770
+ }
771
+
772
+ async disposeArtifact(artifact: MediaArtifact): Promise<void> {
773
+ if (artifact.owner === "engine") {
774
+ this.artifacts.dispose(artifact.id);
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Refuses, before routing, an artifact request whose `destinationRef` this host cannot
780
+ * write to: there is no point running a provider whose result has nowhere to go.
781
+ */
782
+ private async checkDestination(request: TaskRequest, runId: string): Promise<void> {
783
+ const destinationRef = request.delivery?.destinationRef;
784
+ if (destinationRef === undefined) return;
785
+ const destinations = this.hostServices.artifactDestinations;
786
+ if (destinations === undefined) {
787
+ throw createCapabilityMismatch("This host has no application artifact destinations.", {
788
+ runId,
789
+ details: { code: "artifact_destination_unsupported" }
790
+ });
791
+ }
792
+ if (!(await destinations.accepts(destinationRef, request.delivery?.mediaTypes))) {
793
+ throw createCapabilityMismatch("The host refused the requested artifact destination.", {
794
+ runId,
795
+ details: { code: "artifact_destination_refused" }
796
+ });
797
+ }
798
+ }
799
+
800
+ /**
801
+ * Writes a finished artifact to the request's application destination, if it names one,
802
+ * and drops the engine's copy: the result's artifact is then application-owned. The run
803
+ * has committed by now, so a failed write is the run's failure, never a fallback.
804
+ */
805
+ private async deliverToDestination(
806
+ request: TaskRequest,
807
+ output: TaskOutput,
808
+ providerId: string
809
+ ): Promise<TaskOutput> {
810
+ const destinationRef = request.delivery?.destinationRef;
811
+ const artifact = output.artifact;
812
+ if (destinationRef === undefined || artifact === undefined) return output;
813
+ try {
814
+ await this.hostServices.artifactDestinations!.write(
815
+ destinationRef,
816
+ artifact,
817
+ this.artifacts.bytes(artifact.id)!
818
+ );
819
+ } catch {
820
+ // The host's error stays out of the run's error: it may name the destination.
821
+ this.artifacts.dispose(artifact.id);
822
+ throw createInternal("Writing the artifact to its application destination failed.", {
823
+ providerId,
824
+ details: { code: "artifact_destination_write_failed" }
825
+ });
826
+ }
827
+ this.artifacts.dispose(artifact.id);
828
+ return { ...output, artifact: { ...artifact, owner: "application", destinationRef } };
829
+ }
830
+
831
+ /**
832
+ * Checks that an artifact in a provider's output is one this attempt produced, swaps in the
833
+ * reference the store holds, and only then checks the output against the requested delivery:
834
+ * the artifact's media type, size and ownership are the engine's record rather than the
835
+ * provider's claim.
836
+ */
837
+ private normalizeOutput(
838
+ request: TaskRequest,
839
+ delivery: OutputDelivery,
840
+ output: TaskOutput | undefined,
841
+ providerId: string,
842
+ attemptArtifacts: AttemptArtifacts
843
+ ): TaskOutput {
844
+ let resolved = output;
845
+ if (output?.artifact !== undefined) {
846
+ if (!attemptArtifacts.owns(output.artifact.id)) {
847
+ throw createInternal(
848
+ `Provider '${providerId}' returned an artifact it did not hand over in this attempt.`,
849
+ { providerId, details: { code: "output_mismatch" } }
850
+ );
851
+ }
852
+ resolved = { ...output, artifact: this.artifacts.reference(output.artifact.id)! };
853
+ }
854
+ const mismatch = checkOutput(request, delivery, resolved, providerId);
855
+ if (mismatch !== undefined) throw mismatch;
856
+ return resolved!;
857
+ }
858
+
610
859
  async checkCapabilities(): Promise<ProviderCapabilitySnapshot[]> {
611
860
  return Promise.all(
612
861
  this.registry.list().map(async (provider) => ({
@@ -617,3 +866,8 @@ export class IndeRun implements IndeRunApi {
617
866
  );
618
867
  }
619
868
  }
869
+
870
+ /** The id of an artifact output the engine still holds, which an attempt must keep. */
871
+ function engineOwnedArtifactId(output: TaskOutput): string | undefined {
872
+ return output.artifact?.owner === "engine" ? output.artifact.id : undefined;
873
+ }
@@ -1,19 +1,27 @@
1
1
  /* This file was generated from contracts/api/inderun-api.json. Do not edit by hand. */
2
2
 
3
- import type { TaskRequest, TaskResult, ProviderCapabilitySnapshot } from "@independo/inderun-contracts";
4
- import type { StreamRun } from "../provider.js";
3
+ import type { TaskRequest, TaskResult, ProviderCapabilitySnapshot, ArtifactReadRequest, MediaArtifact } from "@independo/inderun-contracts";
4
+ import type { StreamRun, ArtifactChunk } from "../provider.js";
5
5
 
6
6
  export interface IndeRunApi {
7
7
  /**
8
- * Executes a Mode 1 (request/response) text-to-text task against a routed provider and returns the completed TaskResult. Routing (local vs. cloud, which provider) is decided internally from TaskRequest.constraints/preferences — the caller does not select a provider directly. Rejects the request up front on validation failure; otherwise throws an IndeRunException (carrying an IndeRunError) if no eligible provider is found or every attempted provider fails. There is no partial or streaming result in this mode: run() either resolves with a complete TaskResult or throws.
8
+ * Executes a Mode 1 (request/response) task of any supported kind against a routed provider and returns the completed TaskResult, whose output is text, a managed-playback completion, or a media artifact as the request's delivery asks. Routing (local vs. cloud, which provider) is decided internally from TaskRequest.constraints/preferences — the caller does not select a provider directly. Rejects the request up front on validation failure; otherwise throws an IndeRunException (carrying an IndeRunError) if no eligible provider is found or every attempted provider fails. There is no partial or streaming result in this mode: run() either resolves with a complete TaskResult or throws. A provider failure falls back to the next planned provider unless the attempt had committed (for managed playback, no later than its first possible audible effect); a committed attempt's failure is the run's failure.
9
9
  */
10
10
  run(request: TaskRequest): Promise<TaskResult>;
11
11
  /**
12
- * Executes a Mode 2 (streaming) text-to-text task and returns a handle to the run, its canonical StreamEvent sequence, and a cancel hook. The requested interaction mode is a routing input rather than a filter applied after routing: providers that cannot stream are rejected during planning with a normalized reason, so a stream and a run over the same registry may legitimately resolve to different provider chains. Two failure surfaces exist deliberately — validation and route-selection failures reject this call, since there is no handle to correlate them with yet, while provider failure, cancellation, and completion all arrive as the single terminal StreamEvent in the returned sequence. Fallback is narrower than run()'s: a provider failure is only fallback-eligible before the first content event has been delivered; once one has, the run is committed to that provider and a later failure becomes a terminal error rather than a silent provider swap. Cancellation forecloses both at any commit state and always produces exactly one cancelled outcome.
12
+ * Executes a Mode 2 (streaming) task of any supported kind and returns a handle to the run, its canonical StreamEvent sequence, and a cancel hook. The requested interaction mode is a routing input rather than a filter applied after routing: providers that cannot stream are rejected during planning with a normalized reason, so a stream and a run over the same registry may legitimately resolve to different provider chains. Two failure surfaces exist deliberately — validation and route-selection failures reject this call, since there is no handle to correlate them with yet, while provider failure, cancellation, and completion all arrive as the single terminal StreamEvent in the returned sequence. Fallback is narrower than run()'s: a provider failure is only fallback-eligible before the run commits to that provider -- its first content event, its explicit commit (managed playback, before the first possible audible effect), or its result handoff, whichever comes first; once committed, a later failure becomes a terminal error rather than a silent provider swap. Cancellation forecloses both at any commit state and always produces exactly one cancelled outcome.
13
13
  */
14
14
  stream(request: TaskRequest): Promise<StreamRun>;
15
15
  /**
16
16
  * Returns a live snapshot of every registered provider's static descriptor and current dynamic availability, without executing a task or producing any side effects. Call this before run() when you need to show which providers/models are currently available — for example a settings screen or provider picker. Availability can change between calls (a local model can unload, cloud credentials can expire), so don't cache the result across a run() call.
17
17
  */
18
18
  checkCapabilities(): Promise<ProviderCapabilitySnapshot[]>;
19
+ /**
20
+ * Reads a byte range of an engine-owned media artifact a run produced (ADR 017): the bytes in the platform's native byte type, with their offset, the artifact's total size, and whether the range reaches its end. Reads are repeatable until the artifact is disposed, and each returns at most the engine's configured maximum read size. Throws an IndeRunException with errorClass Unavailable and details.code 'artifact_not_found' for an unknown, disposed or application-owned artifact, and a validation error for an offset beyond the end.
21
+ */
22
+ readArtifact(request: ArtifactReadRequest): Promise<ArtifactChunk>;
23
+ /**
24
+ * Releases the bytes of an engine-owned media artifact. Idempotent: disposing an artifact twice, or one the engine does not hold, does nothing. After disposal readArtifact reports 'artifact_not_found'. An artifact outlives the run that produced it, so an application that keeps none should dispose each one it receives.
25
+ */
26
+ disposeArtifact(artifact: MediaArtifact): Promise<void>;
19
27
  }
package/src/core/host.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type {
2
2
  AbortSignalLike,
3
+ ArtifactDestinationService,
3
4
  ClockService,
4
5
  ConnectivityService,
5
6
  DeviceConstraintsService,
@@ -35,15 +35,20 @@ export class ProvenanceRecorder {
35
35
  }
36
36
  }
37
37
 
38
- /** Records how one provider attempt ended. */
38
+ /**
39
+ * Records how one provider attempt ended, and whether the run had committed to it
40
+ * (first content delivered, first possible audible effect, or an artifact handed over).
41
+ */
39
42
  attempted(
40
43
  providerId: string,
41
44
  outcome: ExecutionProvenanceAttempt["outcome"],
42
- errorClass?: IndeRunErrorClass
45
+ errorClass?: IndeRunErrorClass,
46
+ committed = false
43
47
  ): void {
44
48
  this.attempts.push({
45
49
  providerId,
46
50
  outcome,
51
+ ...(committed ? { committed: true } : {}),
47
52
  ...(errorClass !== undefined ? { errorClass } : {})
48
53
  });
49
54
  }
@@ -1,12 +1,23 @@
1
1
  import type {
2
2
  ProviderDescriptor,
3
3
  ProviderDynamicCapabilities,
4
+ SharedRoutePlan,
4
5
  StreamEvent,
5
6
  StreamRunHandle,
7
+ TaskOutput,
6
8
  TaskRequest,
7
9
  TaskResult
8
10
  } from "@independo/inderun-contracts";
9
11
  import type { HostServices } from "./host.js";
12
+ import type { ArtifactSink } from "./artifacts.js";
13
+
14
+ export type {
15
+ ArtifactChunk,
16
+ ArtifactLimits,
17
+ ArtifactSink,
18
+ ArtifactSpec,
19
+ ArtifactWriter
20
+ } from "./artifacts.js";
10
21
 
11
22
  /**
12
23
  * A provider's static declaration, live capability check, and the pair as
@@ -34,6 +45,37 @@ export interface RunContext {
34
45
  * resolve authContextRef through secure storage and dispatch HTTP requests.
35
46
  */
36
47
  hostServices: HostServices;
48
+ /**
49
+ * The voice the route plan bound this provider to for a speech task: provider-neutral
50
+ * traits the provider must honour when it picks the voice that speaks. Absent for tasks
51
+ * without a voice and for providers that do not list their voices, which then choose
52
+ * under the same rule (ADR 016 §4). The engine has re-checked it against the request's
53
+ * privacy policy immediately before the attempt.
54
+ */
55
+ voice?: NonNullable<SharedRoutePlan["candidates"][number]["voice"]>;
56
+ /**
57
+ * Commits the run to this provider. Call it immediately before the attempt's first
58
+ * externally observable effect — for managed playback, no later than the first moment
59
+ * sound may reach the user. After commit, a failure ends the run instead of falling back
60
+ * to another provider, because a second provider would repeat what the user may already
61
+ * have heard.
62
+ *
63
+ * Returns `false` when the run can no longer commit (it was cancelled or has already
64
+ * ended) and when this attempt is over, e.g. a late callback after the engine fell back to
65
+ * another provider; the provider must then not start the effect and should end the attempt.
66
+ * Idempotent: calling it again after a successful commit returns `true`.
67
+ *
68
+ * Providers whose output is only delivered at the end (text from `run()`) need not call
69
+ * it. Text streams commit implicitly on their first content event.
70
+ */
71
+ commit(): boolean;
72
+ /**
73
+ * Where the provider writes retrievable media for an `artifact` request (ADR 017). The
74
+ * engine holds the bytes; the provider returns the reference `finish()` gives it in an
75
+ * `artifact` output. Finishing commits the run. Artifacts this attempt does not return,
76
+ * or that a failed or cancelled attempt wrote, are discarded by the engine.
77
+ */
78
+ artifacts: ArtifactSink;
37
79
  }
38
80
 
39
81
  /**
@@ -78,6 +120,17 @@ export type ProviderStreamEvent =
78
120
  */
79
121
  reportedModel?: string;
80
122
  }
123
+ | {
124
+ /**
125
+ * The run's normalized output, for tasks whose output is not streamed text
126
+ * (playback completion, a media artifact). Ends the stream like `done`; handing it
127
+ * over commits the run if the provider has not already called `commit()`.
128
+ */
129
+ kind: "result";
130
+ output: TaskOutput;
131
+ finishReason?: TaskResult["finishReason"];
132
+ reportedModel?: string;
133
+ }
81
134
  | { kind: "error"; error: unknown };
82
135
 
83
136
  /**