@dogpile/sdk 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +1 -0
  3. package/dist/browser/index.js +2328 -237
  4. package/dist/browser/index.js.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/providers/openai-compatible.d.ts +11 -0
  10. package/dist/providers/openai-compatible.d.ts.map +1 -1
  11. package/dist/providers/openai-compatible.js +88 -2
  12. package/dist/providers/openai-compatible.js.map +1 -1
  13. package/dist/runtime/audit.d.ts +42 -0
  14. package/dist/runtime/audit.d.ts.map +1 -0
  15. package/dist/runtime/audit.js +73 -0
  16. package/dist/runtime/audit.js.map +1 -0
  17. package/dist/runtime/broadcast.d.ts.map +1 -1
  18. package/dist/runtime/broadcast.js +39 -36
  19. package/dist/runtime/broadcast.js.map +1 -1
  20. package/dist/runtime/cancellation.d.ts +26 -0
  21. package/dist/runtime/cancellation.d.ts.map +1 -1
  22. package/dist/runtime/cancellation.js +38 -1
  23. package/dist/runtime/cancellation.js.map +1 -1
  24. package/dist/runtime/coordinator.d.ts +79 -1
  25. package/dist/runtime/coordinator.d.ts.map +1 -1
  26. package/dist/runtime/coordinator.js +979 -61
  27. package/dist/runtime/coordinator.js.map +1 -1
  28. package/dist/runtime/decisions.d.ts +25 -3
  29. package/dist/runtime/decisions.d.ts.map +1 -1
  30. package/dist/runtime/decisions.js +241 -3
  31. package/dist/runtime/decisions.js.map +1 -1
  32. package/dist/runtime/defaults.d.ts +37 -1
  33. package/dist/runtime/defaults.d.ts.map +1 -1
  34. package/dist/runtime/defaults.js +359 -4
  35. package/dist/runtime/defaults.js.map +1 -1
  36. package/dist/runtime/engine.d.ts +17 -4
  37. package/dist/runtime/engine.d.ts.map +1 -1
  38. package/dist/runtime/engine.js +770 -35
  39. package/dist/runtime/engine.js.map +1 -1
  40. package/dist/runtime/health.d.ts +51 -0
  41. package/dist/runtime/health.d.ts.map +1 -0
  42. package/dist/runtime/health.js +85 -0
  43. package/dist/runtime/health.js.map +1 -0
  44. package/dist/runtime/introspection.d.ts +96 -0
  45. package/dist/runtime/introspection.d.ts.map +1 -0
  46. package/dist/runtime/introspection.js +31 -0
  47. package/dist/runtime/introspection.js.map +1 -0
  48. package/dist/runtime/metrics.d.ts +44 -0
  49. package/dist/runtime/metrics.d.ts.map +1 -0
  50. package/dist/runtime/metrics.js +12 -0
  51. package/dist/runtime/metrics.js.map +1 -0
  52. package/dist/runtime/model.d.ts.map +1 -1
  53. package/dist/runtime/model.js +34 -7
  54. package/dist/runtime/model.js.map +1 -1
  55. package/dist/runtime/provenance.d.ts +25 -0
  56. package/dist/runtime/provenance.d.ts.map +1 -0
  57. package/dist/runtime/provenance.js +13 -0
  58. package/dist/runtime/provenance.js.map +1 -0
  59. package/dist/runtime/sequential.d.ts.map +1 -1
  60. package/dist/runtime/sequential.js +47 -37
  61. package/dist/runtime/sequential.js.map +1 -1
  62. package/dist/runtime/shared.d.ts.map +1 -1
  63. package/dist/runtime/shared.js +39 -36
  64. package/dist/runtime/shared.js.map +1 -1
  65. package/dist/runtime/tracing.d.ts +31 -0
  66. package/dist/runtime/tracing.d.ts.map +1 -0
  67. package/dist/runtime/tracing.js +18 -0
  68. package/dist/runtime/tracing.js.map +1 -0
  69. package/dist/runtime/validation.d.ts +10 -0
  70. package/dist/runtime/validation.d.ts.map +1 -1
  71. package/dist/runtime/validation.js +73 -0
  72. package/dist/runtime/validation.js.map +1 -1
  73. package/dist/types/events.d.ts +339 -12
  74. package/dist/types/events.d.ts.map +1 -1
  75. package/dist/types/replay.d.ts +7 -1
  76. package/dist/types/replay.d.ts.map +1 -1
  77. package/dist/types.d.ts +255 -6
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js.map +1 -1
  80. package/package.json +39 -1
  81. package/src/index.ts +15 -0
  82. package/src/providers/openai-compatible.ts +83 -3
  83. package/src/runtime/audit.ts +121 -0
  84. package/src/runtime/broadcast.ts +40 -37
  85. package/src/runtime/cancellation.ts +59 -1
  86. package/src/runtime/coordinator.ts +1221 -61
  87. package/src/runtime/decisions.ts +307 -4
  88. package/src/runtime/defaults.ts +389 -4
  89. package/src/runtime/engine.ts +1004 -35
  90. package/src/runtime/health.ts +136 -0
  91. package/src/runtime/introspection.ts +122 -0
  92. package/src/runtime/metrics.ts +45 -0
  93. package/src/runtime/model.ts +38 -6
  94. package/src/runtime/provenance.ts +43 -0
  95. package/src/runtime/sequential.ts +49 -38
  96. package/src/runtime/shared.ts +40 -37
  97. package/src/runtime/tracing.ts +35 -0
  98. package/src/runtime/validation.ts +81 -0
  99. package/src/types/events.ts +369 -12
  100. package/src/types/replay.ts +14 -1
  101. package/src/types.ts +279 -4
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export { consoleLogger, loggerFromEvents, noopLogger } from "./runtime/logger.js
12
12
  export type { Logger, LoggerFromEventsOptions, LogLevel } from "./runtime/logger.js";
13
13
  export { DEFAULT_RETRYABLE_DOGPILE_CODES, withRetry } from "./runtime/retry.js";
14
14
  export type { RetryAttemptInfo, RetryJitterMode, RetryPolicy } from "./runtime/retry.js";
15
+ export { DOGPILE_SPAN_NAMES } from "./runtime/tracing.js";
16
+ export type { DogpileSpan, DogpileSpanOptions, DogpileTracer } from "./runtime/tracing.js";
15
17
  export { DogpileError } from "./types.js";
16
18
  export type {
17
19
  OpenAICompatibleChatCompletionChoice,
@@ -75,9 +77,11 @@ export {
75
77
  judge
76
78
  } from "./runtime/termination.js";
77
79
  export type {
80
+ AbortedEvent,
78
81
  AgentSpec,
79
82
  AgentDecision,
80
83
  AgentParticipation,
84
+ AnomalyCode,
81
85
  BroadcastContribution,
82
86
  BroadcastEvent,
83
87
  BroadcastProtocolConfig,
@@ -93,12 +97,15 @@ export type {
93
97
  CoordinationProtocolSelection,
94
98
  CostSummary,
95
99
  CoordinatorProtocolConfig,
100
+ DelegateAgentDecision,
96
101
  DogpileErrorCode,
97
102
  DogpileErrorOptions,
98
103
  DogpileOptions,
99
104
  Engine,
100
105
  EngineOptions,
106
+ RunCallOptions,
101
107
  FinalEvent,
108
+ HealthAnomaly,
102
109
  FirstOfTerminationCondition,
103
110
  FirstOfTerminationConditions,
104
111
  FirstOfTerminationInput,
@@ -147,6 +154,7 @@ export type {
147
154
  RunEvaluator,
148
155
  RunEventLog,
149
156
  RunEvent,
157
+ RunHealthSummary,
150
158
  RunMetadata,
151
159
  RunResult,
152
160
  RunUsage,
@@ -184,6 +192,13 @@ export type {
184
192
  StreamOutputEvent,
185
193
  StreamSubscription,
186
194
  StopTerminationDecision,
195
+ SubRunBudgetClampedEvent,
196
+ SubRunCompletedEvent,
197
+ SubRunConcurrencyClampedEvent,
198
+ SubRunFailedEvent,
199
+ SubRunParentAbortedEvent,
200
+ SubRunQueuedEvent,
201
+ SubRunStartedEvent,
187
202
  TerminationCondition,
188
203
  TerminationDecision,
189
204
  TerminationEvaluationContext,
@@ -37,6 +37,8 @@ export interface OpenAICompatibleProviderOptions {
37
37
  readonly maxOutputTokens?: number;
38
38
  readonly extraBody?: JsonObject;
39
39
  readonly costEstimator?: OpenAICompatibleProviderCostEstimator;
40
+ /** Locality hint override; if omitted, auto-detected from baseURL. Explicit "local" always wins; explicit "remote" on a detected-local host throws. */
41
+ readonly locality?: "local" | "remote";
40
42
  }
41
43
 
42
44
  export interface OpenAICompatibleChatCompletionResponse {
@@ -68,6 +70,10 @@ export function createOpenAICompatibleProvider(options: OpenAICompatibleProvider
68
70
 
69
71
  const providerId = options.id ?? `openai-compatible:${options.model}`;
70
72
  const fetchImplementation = options.fetch ?? globalThis.fetch?.bind(globalThis);
73
+ const baseURLForLocality = new URL(String(options.baseURL ?? defaultBaseURL));
74
+ const detectedLocality = classifyHostLocality(baseURLForLocality.hostname);
75
+ const resolvedLocality: "local" | "remote" =
76
+ options.locality === "local" ? "local" : options.locality === "remote" ? "remote" : detectedLocality;
71
77
 
72
78
  if (!fetchImplementation) {
73
79
  throw new DogpileError({
@@ -85,6 +91,8 @@ export function createOpenAICompatibleProvider(options: OpenAICompatibleProvider
85
91
 
86
92
  return {
87
93
  id: providerId,
94
+ modelId: options.model,
95
+ metadata: { locality: resolvedLocality },
88
96
  async generate(request: ModelRequest): Promise<ModelResponse> {
89
97
  let response: Response;
90
98
 
@@ -99,12 +107,12 @@ export function createOpenAICompatibleProvider(options: OpenAICompatibleProvider
99
107
  throw normalizeFetchError(error, providerId);
100
108
  }
101
109
 
102
- const payload = await readJson(response, providerId);
103
-
104
110
  if (!response.ok) {
111
+ const payload = await readJsonLenient(response);
105
112
  throw createProviderError(response, payload, providerId);
106
113
  }
107
114
 
115
+ const payload = await readJson(response, providerId);
108
116
  const completion = asChatCompletionResponse(payload, providerId);
109
117
  const text = readAssistantText(completion, providerId);
110
118
  const usage = normalizeUsage(completion.usage);
@@ -154,6 +162,27 @@ function validateOptions(options: OpenAICompatibleProviderOptions): void {
154
162
  if (options.costEstimator !== undefined && typeof options.costEstimator !== "function") {
155
163
  throwInvalid("costEstimator", "a function when provided");
156
164
  }
165
+ if (options.locality !== undefined && options.locality !== "local" && options.locality !== "remote") {
166
+ throwInvalid("locality", "\"local\" | \"remote\" when provided");
167
+ }
168
+ if (options.locality === "remote") {
169
+ const baseURL = new URL(String(options.baseURL ?? defaultBaseURL));
170
+ const detected = classifyHostLocality(baseURL.hostname);
171
+ if (detected === "local") {
172
+ throw new DogpileError({
173
+ code: "invalid-configuration",
174
+ message: `locality "remote" cannot be set when baseURL resolves to a local host (${baseURL.hostname}).`,
175
+ retryable: false,
176
+ detail: {
177
+ kind: "configuration-validation",
178
+ path: "locality",
179
+ expected: "\"local\" (or omit to auto-detect)",
180
+ reason: "remote-override-on-local-host",
181
+ host: baseURL.hostname
182
+ }
183
+ });
184
+ }
185
+ }
157
186
  }
158
187
 
159
188
  function throwInvalid(path: string, expected: string): never {
@@ -169,6 +198,46 @@ function throwInvalid(path: string, expected: string): never {
169
198
  });
170
199
  }
171
200
 
201
+ /**
202
+ * Classify a URL hostname as "local" or "remote" per Phase 3 D-02.
203
+ * Local: localhost, *.local mDNS, IPv4 loopback (127.0.0.0/8), RFC1918
204
+ * (10/8, 172.16/12, 192.168/16), link-local (169.254/16), IPv6 loopback (::1),
205
+ * IPv6 ULA (fc00::/7), IPv6 link-local (fe80::/10).
206
+ *
207
+ * Pure function: no I/O, no side effects. Exported for tests and future reuse.
208
+ */
209
+ export function classifyHostLocality(host: string): "local" | "remote" {
210
+ const lower = host.toLowerCase().replace(/^\[|\]$/g, "");
211
+ const mappedIpv4 = ipv4MappedToDottedQuad(lower);
212
+ if (mappedIpv4 !== undefined) {
213
+ return classifyHostLocality(mappedIpv4);
214
+ }
215
+ if (lower === "localhost") return "local";
216
+ if (lower.endsWith(".local")) return "local";
217
+ if (/^127(?:\.\d{1,3}){3}$/.test(lower)) return "local";
218
+ if (/^10(?:\.\d{1,3}){3}$/.test(lower)) return "local";
219
+ if (/^172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}$/.test(lower)) return "local";
220
+ if (/^192\.168(?:\.\d{1,3}){2}$/.test(lower)) return "local";
221
+ if (/^169\.254(?:\.\d{1,3}){2}$/.test(lower)) return "local";
222
+ if (lower === "::1") return "local";
223
+ if (/^f[cd][0-9a-f]{2}:/.test(lower)) return "local";
224
+ if (/^fe[89ab][0-9a-f]?:/.test(lower)) return "local";
225
+ return "remote";
226
+ }
227
+
228
+ function ipv4MappedToDottedQuad(host: string): string | undefined {
229
+ const match = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
230
+ if (match === null) {
231
+ return undefined;
232
+ }
233
+ const high = Number.parseInt(match[1] ?? "", 16);
234
+ const low = Number.parseInt(match[2] ?? "", 16);
235
+ if (!Number.isFinite(high) || !Number.isFinite(low)) {
236
+ return undefined;
237
+ }
238
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
239
+ }
240
+
172
241
  function createURL(options: OpenAICompatibleProviderOptions): URL {
173
242
  const baseURL = new URL(String(options.baseURL ?? defaultBaseURL));
174
243
  const path = options.path ?? defaultPath;
@@ -232,6 +301,14 @@ async function readJson(response: Response, providerId: string): Promise<unknown
232
301
  }
233
302
  }
234
303
 
304
+ async function readJsonLenient(response: Response): Promise<unknown> {
305
+ try {
306
+ return await response.json();
307
+ } catch {
308
+ return undefined;
309
+ }
310
+ }
311
+
235
312
  function asChatCompletionResponse(payload: unknown, providerId: string): OpenAICompatibleChatCompletionResponse {
236
313
  if (!isRecord(payload)) {
237
314
  throw new DogpileError({
@@ -341,14 +418,17 @@ function responseMetadata(response: OpenAICompatibleChatCompletionResponse): Jso
341
418
  }
342
419
 
343
420
  function createProviderError(response: Response, payload: unknown, providerId: string): DogpileError {
421
+ const code = codeForStatus(response.status);
422
+ const timeoutSource = code === "provider-timeout" ? { source: "provider" as const } : {};
344
423
  return new DogpileError({
345
- code: codeForStatus(response.status),
424
+ code,
346
425
  message: providerResponseErrorMessage(response, payload),
347
426
  retryable: response.status === 408 || response.status === 429 || response.status >= 500,
348
427
  providerId,
349
428
  detail: removeUndefined({
350
429
  statusCode: response.status,
351
430
  statusText: response.statusText,
431
+ ...timeoutSource,
352
432
  response: isJsonValue(payload) ? payload : undefined
353
433
  })
354
434
  });
@@ -0,0 +1,121 @@
1
+ import type { Protocol, Tier, Trace } from "../types.js";
2
+ import type { BudgetStopEvent, FinalEvent, SubRunCompletedEvent, TurnEvent } from "../types/events.js";
3
+
4
+ export type AuditOutcomeStatus = "completed" | "budget-stopped" | "aborted";
5
+
6
+ export interface AuditOutcome {
7
+ readonly status: AuditOutcomeStatus;
8
+ readonly terminationCode?: string;
9
+ }
10
+
11
+ export interface AuditCost {
12
+ readonly usd: number;
13
+ readonly inputTokens: number;
14
+ readonly outputTokens: number;
15
+ }
16
+
17
+ export interface AuditAgentRecord {
18
+ readonly id: string;
19
+ readonly role: string;
20
+ readonly turnCount: number;
21
+ }
22
+
23
+ export interface AuditRecord {
24
+ readonly auditSchemaVersion: "1";
25
+ readonly runId: string;
26
+ readonly intent: string;
27
+ readonly startedAt: string;
28
+ readonly completedAt: string;
29
+ readonly protocol: Protocol;
30
+ readonly tier: Tier;
31
+ readonly modelProviderId: string;
32
+ readonly agentCount: number;
33
+ readonly turnCount: number;
34
+ readonly outcome: AuditOutcome;
35
+ readonly cost: AuditCost;
36
+ readonly agents: readonly AuditAgentRecord[];
37
+ readonly childRunIds?: readonly string[];
38
+ }
39
+
40
+ /**
41
+ * Derive a versioned, schema-stable audit record from a completed run trace.
42
+ *
43
+ * Pure function - no side effects, no I/O, no storage access. Deterministic:
44
+ * given the same trace, always produces the same AuditRecord.
45
+ *
46
+ * @param trace - Completed run trace (from RunResult.trace or a stored/replayed trace).
47
+ */
48
+ export function createAuditRecord(trace: Trace): AuditRecord {
49
+ const finalEvent = trace.events.find((event): event is FinalEvent => event.type === "final");
50
+ const budgetStopEvent = trace.events.find((event): event is BudgetStopEvent => event.type === "budget-stop");
51
+
52
+ const outcome: AuditOutcome = budgetStopEvent
53
+ ? { status: "budget-stopped", terminationCode: budgetStopEvent.reason }
54
+ : finalEvent
55
+ ? { status: "completed" }
56
+ : { status: "aborted" };
57
+
58
+ const lastTurnCost = [...trace.events]
59
+ .reverse()
60
+ .find((event): event is TurnEvent => event.type === "agent-turn")?.cost;
61
+ const costSource = finalEvent?.cost ?? budgetStopEvent?.cost ?? lastTurnCost;
62
+ const cost: AuditCost = {
63
+ usd: costSource?.usd ?? 0,
64
+ inputTokens: costSource?.inputTokens ?? 0,
65
+ outputTokens: costSource?.outputTokens ?? 0
66
+ };
67
+
68
+ const turnEvents = trace.events.filter((event): event is TurnEvent => event.type === "agent-turn");
69
+ const agentTurnMap = new Map<string, { role: string; count: number }>();
70
+ for (const event of turnEvents) {
71
+ const existing = agentTurnMap.get(event.agentId);
72
+ if (existing !== undefined) {
73
+ existing.count++;
74
+ } else {
75
+ agentTurnMap.set(event.agentId, { role: event.role, count: 1 });
76
+ }
77
+ }
78
+
79
+ const agents: AuditAgentRecord[] = [...agentTurnMap.entries()]
80
+ .map(([id, { role, count }]) => ({ id, role, turnCount: count }))
81
+ .sort((a, b) => a.id.localeCompare(b.id));
82
+
83
+ const childRunIds = trace.events
84
+ .filter((event): event is SubRunCompletedEvent => event.type === "sub-run-completed")
85
+ .map((event) => event.childRunId);
86
+
87
+ const startedAt = eventStartedAt(trace.events[0]);
88
+
89
+ return {
90
+ auditSchemaVersion: "1",
91
+ runId: trace.runId,
92
+ intent: trace.inputs.intent,
93
+ startedAt,
94
+ completedAt: trace.finalOutput.completedAt,
95
+ protocol: trace.protocol,
96
+ tier: trace.tier,
97
+ modelProviderId: trace.modelProviderId,
98
+ agentCount: agentTurnMap.size,
99
+ turnCount: turnEvents.length,
100
+ outcome,
101
+ cost,
102
+ agents,
103
+ ...(childRunIds.length > 0 ? { childRunIds } : {})
104
+ };
105
+ }
106
+
107
+ function eventStartedAt(event: Trace["events"][number] | undefined): string {
108
+ if (event === undefined) {
109
+ return "";
110
+ }
111
+
112
+ if ("at" in event) {
113
+ return event.at;
114
+ }
115
+
116
+ if ("startedAt" in event) {
117
+ return event.startedAt;
118
+ }
119
+
120
+ return "";
121
+ }
@@ -17,6 +17,7 @@ import type {
17
17
  TerminationCondition,
18
18
  TerminationStopRecord,
19
19
  Tier,
20
+ Trace,
20
21
  TranscriptEntry
21
22
  } from "../types.js";
22
23
  import { createRunId, elapsedMs, nowMs, providerCallIdFor } from "./ids.js";
@@ -35,6 +36,7 @@ import {
35
36
  createTranscriptLink,
36
37
  emptyCost
37
38
  } from "./defaults.js";
39
+ import { computeHealth, DEFAULT_HEALTH_THRESHOLDS } from "./health.js";
38
40
  import { throwIfAborted } from "./cancellation.js";
39
41
  import { parseAgentDecision } from "./decisions.js";
40
42
  import { generateModelTurn } from "./model.js";
@@ -289,45 +291,46 @@ export async function runBroadcast(options: BroadcastRunOptions): Promise<RunRes
289
291
  transcriptEntryCount: transcript.length
290
292
  });
291
293
  const finalEvent = events.at(-1);
294
+ const trace: Trace = {
295
+ schemaVersion: "1.0",
296
+ runId,
297
+ protocol: "broadcast",
298
+ tier: options.tier,
299
+ modelProviderId: options.model.id,
300
+ agentsUsed: options.agents,
301
+ inputs: createReplayTraceRunInputs({
302
+ intent: options.intent,
303
+ protocol: options.protocol,
304
+ tier: options.tier,
305
+ modelProviderId: options.model.id,
306
+ agents: options.agents,
307
+ temperature: options.temperature
308
+ }),
309
+ budget: createReplayTraceBudget({
310
+ tier: options.tier,
311
+ ...(options.budget ? { caps: options.budget } : {}),
312
+ ...(options.terminate ? { termination: options.terminate } : {})
313
+ }),
314
+ budgetStateChanges: createReplayTraceBudgetStateChanges(events),
315
+ seed: createReplayTraceSeed(options.seed),
316
+ protocolDecisions,
317
+ providerCalls,
318
+ finalOutput: createReplayTraceFinalOutput(output, finalEvent ?? {
319
+ type: "final",
320
+ runId,
321
+ at: "",
322
+ output,
323
+ cost: totalCost,
324
+ transcript: createTranscriptLink(transcript)
325
+ }),
326
+ events,
327
+ transcript
328
+ };
292
329
 
293
330
  return {
294
331
  output,
295
332
  eventLog: createRunEventLog(runId, "broadcast", events),
296
- trace: {
297
- schemaVersion: "1.0",
298
- runId,
299
- protocol: "broadcast",
300
- tier: options.tier,
301
- modelProviderId: options.model.id,
302
- agentsUsed: options.agents,
303
- inputs: createReplayTraceRunInputs({
304
- intent: options.intent,
305
- protocol: options.protocol,
306
- tier: options.tier,
307
- modelProviderId: options.model.id,
308
- agents: options.agents,
309
- temperature: options.temperature
310
- }),
311
- budget: createReplayTraceBudget({
312
- tier: options.tier,
313
- ...(options.budget ? { caps: options.budget } : {}),
314
- ...(options.terminate ? { termination: options.terminate } : {})
315
- }),
316
- budgetStateChanges: createReplayTraceBudgetStateChanges(events),
317
- seed: createReplayTraceSeed(options.seed),
318
- protocolDecisions,
319
- providerCalls,
320
- finalOutput: createReplayTraceFinalOutput(output, finalEvent ?? {
321
- type: "final",
322
- runId,
323
- at: "",
324
- output,
325
- cost: totalCost,
326
- transcript: createTranscriptLink(transcript)
327
- }),
328
- events,
329
- transcript
330
- },
333
+ trace,
331
334
  transcript,
332
335
  usage: createRunUsage(totalCost),
333
336
  metadata: createRunMetadata({
@@ -345,7 +348,8 @@ export async function runBroadcast(options: BroadcastRunOptions): Promise<RunRes
345
348
  cost: totalCost,
346
349
  events
347
350
  }),
348
- cost: totalCost
351
+ cost: totalCost,
352
+ health: computeHealth(trace, DEFAULT_HEALTH_THRESHOLDS)
349
353
  };
350
354
 
351
355
  function stopIfNeeded(): boolean {
@@ -440,4 +444,3 @@ function responseCost(response: ModelResponse): CostSummary {
440
444
  totalTokens: response.usage?.totalTokens ?? 0
441
445
  };
442
446
  }
443
-
@@ -1,5 +1,49 @@
1
1
  import { DogpileError, type JsonObject } from "../types.js";
2
2
 
3
+ /**
4
+ * Documented-convention vocabulary for `DogpileError({ code: "aborted" }).detail.reason`.
5
+ *
6
+ * Internal-only union (D-08): the literal strings are public-by-observation
7
+ * through `detail.reason` and locked via tests in `event-schema.test.ts` and
8
+ * `public-error-api.test.ts`, but we deliberately do NOT export the union
9
+ * type from `src/index.ts` to keep public-surface delta minimal.
10
+ */
11
+ export type AbortReason = "parent-aborted" | "timeout";
12
+ export type ChildTimeoutSource = "provider" | "engine";
13
+
14
+ /**
15
+ * Classify an abort signal's reason into the BUDGET-01 / BUDGET-02
16
+ * `detail.reason` discriminator.
17
+ *
18
+ * - `"timeout"` when the reason is a {@link DogpileError} with `code === "timeout"`
19
+ * (matches the parent-deadline abort path in `engine.ts:createTimeoutAbortLifecycle`).
20
+ * - `"parent-aborted"` for every other reason — explicit caller abort, plain
21
+ * `Error`, `undefined`, or arbitrary primitive.
22
+ */
23
+ export function classifyAbortReason(signalReasonOrError: unknown): AbortReason {
24
+ if (DogpileError.isInstance(signalReasonOrError) && signalReasonOrError.code === "timeout") {
25
+ return "timeout";
26
+ }
27
+ return "parent-aborted";
28
+ }
29
+
30
+ export function classifyChildTimeoutSource(
31
+ _error: unknown,
32
+ context: {
33
+ readonly decisionTimeoutMs?: number;
34
+ readonly engineDefaultTimeoutMs?: number;
35
+ readonly isProviderError: boolean;
36
+ }
37
+ ): ChildTimeoutSource {
38
+ if (context.isProviderError) {
39
+ return "provider";
40
+ }
41
+ if (context.decisionTimeoutMs !== undefined || context.engineDefaultTimeoutMs !== undefined) {
42
+ return "engine";
43
+ }
44
+ return "provider";
45
+ }
46
+
3
47
  export function throwIfAborted(signal: AbortSignal | undefined, providerId: string): void {
4
48
  if (!signal?.aborted) {
5
49
  return;
@@ -24,7 +68,8 @@ export function createAbortErrorFromSignal(signal: AbortSignal, providerId: stri
24
68
  return signal.reason;
25
69
  }
26
70
 
27
- return createAbortError(providerId, undefined, signal.reason);
71
+ const reason = classifyAbortReason(signal.reason);
72
+ return createAbortError(providerId, { reason }, signal.reason);
28
73
  }
29
74
 
30
75
  export function createTimeoutError(providerId: string, timeoutMs: number): DogpileError {
@@ -38,3 +83,16 @@ export function createTimeoutError(providerId: string, timeoutMs: number): Dogpi
38
83
  }
39
84
  });
40
85
  }
86
+
87
+ export function createEngineDeadlineTimeoutError(providerId: string, timeoutMs: number): DogpileError {
88
+ return new DogpileError({
89
+ code: "provider-timeout",
90
+ message: `The child engine deadline expired after ${timeoutMs}ms.`,
91
+ retryable: true,
92
+ providerId,
93
+ detail: {
94
+ timeoutMs,
95
+ source: "engine"
96
+ }
97
+ });
98
+ }