@openwop/openwop 1.1.1 → 1.1.3

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 (46) hide show
  1. package/README.md +4 -0
  2. package/dist/client.d.ts +80 -1
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +186 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/cost-attribution.d.ts +49 -0
  7. package/dist/cost-attribution.d.ts.map +1 -0
  8. package/dist/cost-attribution.js +65 -0
  9. package/dist/cost-attribution.js.map +1 -0
  10. package/dist/envelope-directive.d.ts +77 -0
  11. package/dist/envelope-directive.d.ts.map +1 -0
  12. package/dist/envelope-directive.js +89 -0
  13. package/dist/envelope-directive.js.map +1 -0
  14. package/dist/event-helpers.d.ts +95 -0
  15. package/dist/event-helpers.d.ts.map +1 -0
  16. package/dist/event-helpers.js +160 -0
  17. package/dist/event-helpers.js.map +1 -0
  18. package/dist/index.d.ts +14 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +34 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/parse-refusal.d.ts +114 -0
  23. package/dist/parse-refusal.d.ts.map +1 -0
  24. package/dist/parse-refusal.js +216 -0
  25. package/dist/parse-refusal.js.map +1 -0
  26. package/dist/registry-helpers.d.ts +118 -0
  27. package/dist/registry-helpers.d.ts.map +1 -0
  28. package/dist/registry-helpers.js +82 -0
  29. package/dist/registry-helpers.js.map +1 -0
  30. package/dist/types.d.ts +376 -1
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js.map +1 -1
  33. package/dist/webhook-helpers.d.ts +73 -0
  34. package/dist/webhook-helpers.d.ts.map +1 -0
  35. package/dist/webhook-helpers.js +97 -0
  36. package/dist/webhook-helpers.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/client.ts +218 -0
  39. package/src/cost-attribution.ts +72 -0
  40. package/src/envelope-directive.ts +110 -0
  41. package/src/event-helpers.ts +238 -0
  42. package/src/index.ts +117 -0
  43. package/src/parse-refusal.ts +311 -0
  44. package/src/registry-helpers.ts +173 -0
  45. package/src/types.ts +424 -0
  46. package/src/webhook-helpers.ts +131 -0
package/src/client.ts CHANGED
@@ -22,11 +22,25 @@ import {
22
22
  type ErrorEnvelope,
23
23
  type ForkRunRequest,
24
24
  type ForkRunResponse,
25
+ type GetPromptRequest,
25
26
  type InterruptByTokenInspection,
27
+ type DebugBundle,
28
+ type DebugBundleOptions,
29
+ type ListPromptsRequest,
30
+ type ListPromptsResponse,
31
+ type PromptTemplate,
32
+ type RegisterWebhookRequest,
33
+ type RegisterWebhookResponse,
34
+ type PauseRunRequest,
35
+ type PauseRunResponse,
26
36
  type PollEventsResponse,
37
+ type RenderPromptRequest,
38
+ type RenderPromptResponse,
27
39
  type ResolveInterruptByTokenResponse,
28
40
  type ResolveInterruptRequest,
29
41
  type ResolveInterruptResponse,
42
+ type ResumeRunRequest,
43
+ type ResumeRunResponse,
30
44
  type RunEventDoc,
31
45
  type RunSnapshot,
32
46
  } from './types.js';
@@ -106,6 +120,33 @@ export class OpenwopClient {
106
120
  path: `/v1/runs/${encodeURIComponent(runId)}`,
107
121
  }),
108
122
 
123
+ /**
124
+ * Fetch the portable JSON diagnostic export for a single run per
125
+ * `spec/v1/debug-bundle.md`. The bundle's `redactionMode` reflects
126
+ * the host's advertised `capabilities.compliance.defaultMode`; the
127
+ * caller MUST treat masked/omitted/hashed fields as the
128
+ * spec-canonical value. The `truncated` + `truncatedReason` fields
129
+ * indicate the host hit its size cap.
130
+ *
131
+ * Returns `null` when the host doesn't advertise
132
+ * `capabilities.debugBundle.supported: true` (the endpoint returns
133
+ * 404 in that case per `debug-bundle.md` §"Authorization").
134
+ */
135
+ debugBundle: async (runId: string, opts: DebugBundleOptions = {}): Promise<DebugBundle | null> => {
136
+ const params = new URLSearchParams();
137
+ if (opts.maxEvents !== undefined) params.set('maxEvents', String(opts.maxEvents));
138
+ const query = params.toString();
139
+ const path = `/v1/runs/${encodeURIComponent(runId)}/debug-bundle${query ? `?${query}` : ''}`;
140
+ try {
141
+ return await this.#request<DebugBundle>({ method: 'GET', path });
142
+ } catch (err) {
143
+ // Host doesn't advertise the capability → 404. Surface as null so callers
144
+ // can branch on capability discovery without try/catch.
145
+ if (err instanceof Error && /\b404\b/.test(err.message)) return null;
146
+ throw err;
147
+ }
148
+ },
149
+
109
150
  cancel: (
110
151
  runId: string,
111
152
  body: CancelRunRequest = {},
@@ -118,6 +159,30 @@ export class OpenwopClient {
118
159
  headers: this.#mutationHeaders(opts),
119
160
  }),
120
161
 
162
+ pause: (
163
+ runId: string,
164
+ body: PauseRunRequest = {},
165
+ opts: MutationOptions = {},
166
+ ): Promise<PauseRunResponse> =>
167
+ this.#request<PauseRunResponse>({
168
+ method: 'POST',
169
+ path: `/v1/runs/${encodeURIComponent(runId)}:pause`,
170
+ body,
171
+ headers: this.#mutationHeaders(opts),
172
+ }),
173
+
174
+ resume: (
175
+ runId: string,
176
+ body: ResumeRunRequest = {},
177
+ opts: MutationOptions = {},
178
+ ): Promise<ResumeRunResponse> =>
179
+ this.#request<ResumeRunResponse>({
180
+ method: 'POST',
181
+ path: `/v1/runs/${encodeURIComponent(runId)}:resume`,
182
+ body,
183
+ headers: this.#mutationHeaders(opts),
184
+ }),
185
+
121
186
  /**
122
187
  * Bulk-cancel a set of in-flight runs in a single request per
123
188
  * `rest-endpoints.md` §"POST /v1/runs:bulk-cancel" (closes R1).
@@ -229,6 +294,159 @@ export class OpenwopClient {
229
294
  ),
230
295
  };
231
296
 
297
+ // ── Webhook subscriptions (per spec/v1/webhooks.md) ─────────────────────
298
+ readonly webhooks = {
299
+ /**
300
+ * Register a webhook subscription. Server signs deliveries with
301
+ * HMAC-SHA256 over `${timestamp}.${rawBody}` using the
302
+ * registration-time secret per `spec/v1/webhooks.md` §"Signature
303
+ * recipe". The secret is returned ONCE in the response — store it
304
+ * server-side for verification; the host cannot recover it.
305
+ */
306
+ register: (
307
+ body: RegisterWebhookRequest,
308
+ opts: MutationOptions = {},
309
+ ): Promise<RegisterWebhookResponse> =>
310
+ this.#request<RegisterWebhookResponse>({
311
+ method: 'POST',
312
+ path: '/v1/webhooks',
313
+ body,
314
+ headers: this.#mutationHeaders(opts),
315
+ }),
316
+
317
+ /**
318
+ * Unregister a webhook subscription. Returns void on success;
319
+ * throws `WopError` with `subscription_not_found` on unknown
320
+ * subscriptionId.
321
+ */
322
+ unregister: async (subscriptionId: string): Promise<void> => {
323
+ await this.#request<unknown>({
324
+ method: 'DELETE',
325
+ path: `/v1/webhooks/${encodeURIComponent(subscriptionId)}`,
326
+ });
327
+ },
328
+ };
329
+
330
+ // ── Prompt library (RFC 0028; gated on capabilities.prompts.*) ──
331
+ //
332
+ // Read endpoints (list, get, render) gate on
333
+ // `capabilities.prompts.endpointsSupported: true`. Mutating endpoints
334
+ // (create, update, delete) additionally require
335
+ // `capabilities.prompts.mutableLibrary: true`. Hosts that don't advertise
336
+ // the relevant capability return `501 capability_not_provided`; the SDK
337
+ // surfaces that as a `WopError`. Clients SHOULD pre-flight via
338
+ // `getCapabilities()` before calling.
339
+ //
340
+ // NOTE: `capabilities.prompts.supported: true` (without
341
+ // `endpointsSupported: true`) ONLY gates node-execution PromptRef
342
+ // resolution per RFC 0027 Phase A; it does NOT imply these endpoints are
343
+ // available. See spec/v1/prompts.md §"Capability advertisement" for the
344
+ // two-axis gating split.
345
+ readonly prompts = {
346
+ /**
347
+ * List prompt templates available to the caller per RFC 0028 §A
348
+ * (operationId `listPromptTemplates`). Supports kind / tag / modelClass
349
+ * / source filters + opaque cursor pagination.
350
+ */
351
+ list: (req: ListPromptsRequest = {}): Promise<ListPromptsResponse> => {
352
+ const search = new URLSearchParams();
353
+ if (req.kind) search.set('kind', req.kind);
354
+ if (req.tag) search.set('tag', req.tag);
355
+ if (req.modelClass) search.set('modelClass', req.modelClass);
356
+ if (req.source) search.set('source', req.source);
357
+ if (req.cursor) search.set('cursor', req.cursor);
358
+ if (req.limit !== undefined) search.set('limit', String(req.limit));
359
+ const query = search.toString();
360
+ return this.#request<ListPromptsResponse>({
361
+ method: 'GET',
362
+ path: `/v1/prompts${query ? `?${query}` : ''}`,
363
+ });
364
+ },
365
+
366
+ /**
367
+ * Fetch a single PromptTemplate by id per RFC 0028 §A
368
+ * (operationId `getPromptTemplate`). Optionally pin a SemVer
369
+ * `version`; supply `libraryId` to disambiguate when multiple installed
370
+ * packs ship the same templateId.
371
+ */
372
+ get: (req: GetPromptRequest): Promise<PromptTemplate> => {
373
+ const search = new URLSearchParams();
374
+ if (req.version) search.set('version', req.version);
375
+ if (req.libraryId) search.set('libraryId', req.libraryId);
376
+ const query = search.toString();
377
+ return this.#request<PromptTemplate>({
378
+ method: 'GET',
379
+ path: `/v1/prompts/${encodeURIComponent(req.templateId)}${query ? `?${query}` : ''}`,
380
+ });
381
+ },
382
+
383
+ /**
384
+ * Render a PromptTemplate with supplied variable bindings per RFC 0028
385
+ * §A (operationId `renderPromptTemplate`). Returns composed body +
386
+ * sha256 hash + per-variable hashes. The deterministic-hash invariant
387
+ * (RFC 0028 §A) requires the `hash` to match what a matching
388
+ * `prompt.composed` event would carry at dispatch time. Does NOT
389
+ * dispatch an LLM call. Secret-source variable values MUST be supplied
390
+ * as `[REDACTED:<credentialRef>]` markers per
391
+ * SECURITY/threat-model-secret-leakage.md §SR-1.
392
+ */
393
+ render: (req: RenderPromptRequest): Promise<RenderPromptResponse> => {
394
+ return this.#request<RenderPromptResponse>({
395
+ method: 'POST',
396
+ path: '/v1/prompts:render',
397
+ body: req,
398
+ });
399
+ },
400
+
401
+ /**
402
+ * Create a new user-source PromptTemplate per RFC 0028 §A
403
+ * (operationId `createPromptTemplate`). Mutating endpoint —
404
+ * requires `capabilities.prompts.mutableLibrary: true`. Supports
405
+ * `Idempotency-Key` per the standard `MutationOptions` pattern.
406
+ */
407
+ create: (template: PromptTemplate, opts: MutationOptions = {}): Promise<void> => {
408
+ return this.#request<void>({
409
+ method: 'POST',
410
+ path: '/v1/prompts',
411
+ body: template,
412
+ headers: this.#mutationHeaders(opts),
413
+ });
414
+ },
415
+
416
+ /**
417
+ * Replace an existing user-source PromptTemplate per RFC 0028 §A
418
+ * (operationId `updatePromptTemplate`). Submitted SemVer MUST be
419
+ * strictly greater than stored. Mutating endpoint — requires
420
+ * `capabilities.prompts.mutableLibrary: true`. Pack-sourced and
421
+ * host-built-in templates are read-only (host returns 403).
422
+ */
423
+ update: (
424
+ templateId: string,
425
+ template: PromptTemplate,
426
+ opts: MutationOptions = {},
427
+ ): Promise<PromptTemplate> => {
428
+ return this.#request<PromptTemplate>({
429
+ method: 'PUT',
430
+ path: `/v1/prompts/${encodeURIComponent(templateId)}`,
431
+ body: template,
432
+ headers: this.#mutationHeaders(opts),
433
+ });
434
+ },
435
+
436
+ /**
437
+ * Delete a user-source PromptTemplate per RFC 0028 §A
438
+ * (operationId `deletePromptTemplate`). Mutating endpoint —
439
+ * requires `capabilities.prompts.mutableLibrary: true`. Pack-sourced
440
+ * and host-built-in templates are read-only (host returns 403).
441
+ */
442
+ delete: (templateId: string): Promise<void> => {
443
+ return this.#request<void>({
444
+ method: 'DELETE',
445
+ path: `/v1/prompts/${encodeURIComponent(templateId)}`,
446
+ });
447
+ },
448
+ };
449
+
232
450
  // ── Audit-log integrity (gated on openwop-audit-log-integrity profile) ──
233
451
  readonly audit = {
234
452
  /**
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cost-attribution allowlist + sanitizer helpers
3
+ * (`spec/v1/observability.md §"Cost attribution attributes"`).
4
+ *
5
+ * The spec MUSTs that hosts emitting `openwop.cost.*` OTel span attrs
6
+ * route them through an allowlist sanitizer that drops any attribute
7
+ * name outside the canonical set AND any non-primitive value. The
8
+ * `cost-attribution-allowlist-redaction` SECURITY invariant + the
9
+ * `cost-attribution.test.ts` conformance scenario are the public test
10
+ * surface; this module is the SDK-side helper that independent hosts
11
+ * (TypeScript / Python / Go) can share so the allowlist has a single
12
+ * source of truth instead of being re-derived in each runtime.
13
+ *
14
+ * Why this lives in the SDK and not just the conformance suite:
15
+ * implementations need the allowlist at HOST EMIT TIME (before spans
16
+ * are written). Importing from `@openwop/openwop` keeps the runtime
17
+ * + the conformance assertion in lockstep — if a future RFC adds an
18
+ * eighth attribute, one PR updates the constant and both surfaces
19
+ * pick it up.
20
+ *
21
+ * @see spec/v1/observability.md §"Cost attribution attributes"
22
+ * @see SECURITY/invariants.yaml row `cost-attribution-allowlist-redaction`
23
+ * @see conformance/src/scenarios/cost-attribution.test.ts
24
+ */
25
+
26
+ /** Canonical allowlist of cost-attribute names. Mutating this list is
27
+ * a wire-shape change — needs an RFC. */
28
+ export const OPENWOP_COST_ATTRIBUTE_NAMES = [
29
+ 'openwop.cost.tokens.input',
30
+ 'openwop.cost.tokens.output',
31
+ 'openwop.cost.tokens.total',
32
+ 'openwop.cost.usd',
33
+ 'openwop.cost.currency',
34
+ 'openwop.cost.estimated',
35
+ 'openwop.cost.provider',
36
+ ] as const;
37
+
38
+ /** Union of the canonical attribute names. Useful for typed
39
+ * sanitizer outputs in callers that pin the shape. */
40
+ export type OpenwopCostAttributeName = (typeof OPENWOP_COST_ATTRIBUTE_NAMES)[number];
41
+
42
+ const ALLOWLIST: ReadonlySet<string> = new Set<string>(OPENWOP_COST_ATTRIBUTE_NAMES);
43
+
44
+ /** Pure-function sanitizer. Returns a NEW object containing only
45
+ * allowlisted keys with primitive-typed values (number / string /
46
+ * boolean). Drops anything else — non-allowlisted keys, nested
47
+ * objects, arrays, functions, symbols, null, undefined.
48
+ *
49
+ * Callers SHOULD invoke this immediately before writing cost attrs
50
+ * to an OTel span:
51
+ *
52
+ * ```ts
53
+ * import { sanitizeCostAttributes } from '@openwop/openwop';
54
+ * for (const [k, v] of Object.entries(sanitizeCostAttributes(rawAttrs))) {
55
+ * span.setAttribute(k, v);
56
+ * }
57
+ * ```
58
+ *
59
+ * The reference workflow-engine host wires this via
60
+ * `apps/workflow-engine/backend/typescript/src/observability/costEmitter.ts`. */
61
+ export function sanitizeCostAttributes(
62
+ input: Record<string, unknown>,
63
+ ): Record<string, number | string | boolean> {
64
+ const out: Record<string, number | string | boolean> = {};
65
+ for (const [key, value] of Object.entries(input)) {
66
+ if (!ALLOWLIST.has(key)) continue;
67
+ if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
68
+ out[key] = value;
69
+ }
70
+ }
71
+ return out;
72
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * envelopeDirective — RFC 0030 §A `reasoning`-field prompt directive synthesis.
3
+ *
4
+ * Hosts that advertise `capabilities.envelopes.reasoning.supported: true`
5
+ * with `promptDirective: "advisory"` or `"mandatory"` inject a system-prompt
6
+ * directive instructing the model to populate the OPTIONAL `reasoning` field
7
+ * on envelope payload schemas that carry it. The directive is informational
8
+ * — hosts MUST NOT reject envelopes where `reasoning` is absent regardless
9
+ * of `promptDirective` strength (RFC 0030 §A).
10
+ *
11
+ * The directive fires only when the envelope's `responseSchema` declares a
12
+ * top-level `reasoning` property. Schemas without `reasoning` (e.g.,
13
+ * `schema.response` per RFC 0030 §A) do NOT receive the directive.
14
+ *
15
+ * Honest separation of concerns:
16
+ * - This module decides WHEN to inject (schema has `reasoning`).
17
+ * - The caller decides WHETHER to inject (read `promptDirective` from
18
+ * the host's discovery advertisement).
19
+ * - The model decides whether to ACTUALLY populate the field (the spec
20
+ * forbids rejecting envelopes where `reasoning` is absent).
21
+ *
22
+ * **Operational note on `"mandatory"`** (per RFC 0030 §A 2026-05-21
23
+ * amendment). Strict-output models may honor the mandatory wording
24
+ * literally and refuse mid-emission when reasoning would be vacuous.
25
+ * Hosts SHOULD prefer `"advisory"` unless empirical testing against the
26
+ * active model class confirms `"mandatory"` doesn't trigger refusals.
27
+ *
28
+ * @see RFCS/0030-envelope-reasoning-and-tier-one-subset.md §A
29
+ * @see spec/v1/ai-envelope.md §"Reasoning field (normative)"
30
+ */
31
+
32
+ /**
33
+ * The strength of the host's `reasoning`-directive prompt injection.
34
+ *
35
+ * `"off"` — no directive injected. Caller skips this module entirely.
36
+ * `"advisory"` — directive is suggestive ("populate `reasoning` with your
37
+ * analytical process if the schema permits it"). The
38
+ * spec-recommended default per RFC 0030 §C.
39
+ * `"mandatory"` — directive is firm ("you MUST populate `reasoning` before
40
+ * emitting the structured fields"). Hosts SHOULD prefer
41
+ * `"advisory"` unless model-class-specific testing shows
42
+ * `"mandatory"` is safe.
43
+ *
44
+ * Both `"advisory"` and `"mandatory"` are prompt-injection postures, NOT
45
+ * wire-level refusal contracts — the host accepts envelopes regardless of
46
+ * whether `reasoning` is populated (RFC 0030 §A normative MUST NOT).
47
+ */
48
+ export type ReasoningDirectiveStrength = 'off' | 'advisory' | 'mandatory';
49
+
50
+ /**
51
+ * Build the directive string to append to the system prompt, OR `null` if
52
+ * the schema does not declare a top-level `reasoning` property.
53
+ *
54
+ * Callers append the returned string to the existing system prompt with a
55
+ * separating newline. When `strength === 'off'`, callers SHOULD short-circuit
56
+ * before invoking this helper (returning `null` here is treated as "no
57
+ * applicable schema," not "directive disabled").
58
+ *
59
+ * The helper inspects only the top-level `properties.reasoning` slot.
60
+ * Nested `reasoning` fields (e.g., inside an `anyOf` branch's payload) are
61
+ * not auto-detected — vendor-kind authors who want per-branch directives
62
+ * synthesize their own.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * import { buildReasoningDirective } from '@openwop/openwop';
67
+ *
68
+ * const directive = buildReasoningDirective(
69
+ * { type: 'object', properties: { reasoning: { type: 'string' }, ... } },
70
+ * 'advisory',
71
+ * );
72
+ * // directive is a string ~80 words; null when schema lacks `reasoning`
73
+ * const systemPrompt = [originalSystemPrompt, schemaHint, directive]
74
+ * .filter((s): s is string => Boolean(s))
75
+ * .join('\n\n');
76
+ * ```
77
+ */
78
+ export function buildReasoningDirective(
79
+ responseSchema: unknown,
80
+ strength: ReasoningDirectiveStrength,
81
+ ): string | null {
82
+ if (strength === 'off') return null;
83
+ if (!responseSchema || typeof responseSchema !== 'object') return null;
84
+
85
+ const schema = responseSchema as { properties?: unknown };
86
+ const properties = schema.properties;
87
+ if (!properties || typeof properties !== 'object') return null;
88
+
89
+ const reasoningProp = (properties as { reasoning?: unknown }).reasoning;
90
+ if (!reasoningProp || typeof reasoningProp !== 'object') return null;
91
+
92
+ if (strength === 'mandatory') {
93
+ return [
94
+ 'BEFORE emitting the structured fields, populate the `reasoning` property with your analytical',
95
+ 'process — explain how you derived each structured field, what assumptions you made, and what',
96
+ 'risks or alternative interpretations you considered. The `reasoning` field is REQUIRED in your',
97
+ 'output; do not skip it. (Note: the host accepts envelopes where `reasoning` is absent per',
98
+ 'RFC 0030 §A, but for this dispatch the host expects it populated.)',
99
+ ].join(' ');
100
+ }
101
+
102
+ // strength === 'advisory'
103
+ return [
104
+ 'If your response schema declares a `reasoning` property, populate it as the first field with',
105
+ 'your analytical process — explain how you derived each structured field. Per Tam et al. (arXiv',
106
+ "2408.02442), models constrained to strict JSON output suffer reasoning-quality collapse when",
107
+ 'no reasoning slot exists; use this field to think before emitting the structured payload. The',
108
+ 'host accepts envelopes where `reasoning` is absent — populate it when it improves clarity.',
109
+ ].join(' ');
110
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Typed helpers for the `agent.*` event family (RFC 0002 §B + RFC 0024).
3
+ *
4
+ * Two layers:
5
+ *
6
+ * 1. **Type guards** (`isAgentReasoned`, `isAgentReasoningDelta`, etc.)
7
+ * — discriminator-based predicates that narrow `RunEventDoc` to a
8
+ * `TypedRunEvent<TPayload>` inside the guarded branch. Use these
9
+ * when you're iterating events yourself (e.g., inside a
10
+ * `for await (const ev of streamEvents(...))` loop) and want
11
+ * compile-time-narrowed access to the payload.
12
+ *
13
+ * 2. **High-level subscription helper** (`subscribeToAgentReasoning`)
14
+ * — fan-outs the `streamEvents()` generator into typed callbacks
15
+ * (`onDelta`, `onClosed`). Composes with the existing
16
+ * `streamEvents` SSE consumer; cleanup via the returned
17
+ * `Unsubscribe` function aborts the underlying fetch.
18
+ *
19
+ * Forward-compat: `RunEventDoc.type` is intentionally `string`-typed
20
+ * (not a closed union) per `COMPATIBILITY.md §2.1` — consumers MUST
21
+ * tolerate unknown event types. The type guards here only narrow when
22
+ * the discriminator AND the required payload fields are present; they
23
+ * return `false` for malformed or unknown events.
24
+ *
25
+ * @see schemas/run-event-payloads.schema.json
26
+ * @see RFCS/0002-agent-identity-and-reasoning-events.md
27
+ * @see RFCS/0024-agent-reasoning-streaming.md
28
+ */
29
+
30
+ import { streamEvents, type EventsStreamContext, type EventsStreamOptions } from './sse.js';
31
+ import type {
32
+ AgentDecidedPayload,
33
+ AgentHandoffPayload,
34
+ AgentReasonedPayload,
35
+ AgentReasoningDeltaPayload,
36
+ AgentToolCalledPayload,
37
+ AgentToolReturnedPayload,
38
+ RunEventDoc,
39
+ TypedRunEvent,
40
+ } from './types.js';
41
+
42
+ // ─── Type guards ────────────────────────────────────────────────────────
43
+
44
+ function hasStringField(
45
+ payload: unknown,
46
+ field: string,
47
+ ): payload is Record<string, unknown> {
48
+ return (
49
+ payload !== null &&
50
+ typeof payload === 'object' &&
51
+ typeof (payload as Record<string, unknown>)[field] === 'string'
52
+ );
53
+ }
54
+
55
+ /** `agent.reasoned` (RFC 0002 §B). Narrows when `type` matches AND
56
+ * payload carries the required `agentId` + `reasoning` strings. */
57
+ export function isAgentReasoned(
58
+ ev: RunEventDoc,
59
+ ): ev is TypedRunEvent<AgentReasonedPayload> {
60
+ return (
61
+ ev.type === 'agent.reasoned' &&
62
+ hasStringField(ev.payload, 'agentId') &&
63
+ hasStringField(ev.payload, 'reasoning')
64
+ );
65
+ }
66
+
67
+ /** `agent.reasoning.delta` (RFC 0024). Narrows when `type` matches AND
68
+ * payload carries the required `agentId` + `delta` strings + numeric
69
+ * `sequence`. */
70
+ export function isAgentReasoningDelta(
71
+ ev: RunEventDoc,
72
+ ): ev is TypedRunEvent<AgentReasoningDeltaPayload> {
73
+ if (ev.type !== 'agent.reasoning.delta') return false;
74
+ if (!hasStringField(ev.payload, 'agentId')) return false;
75
+ if (!hasStringField(ev.payload, 'delta')) return false;
76
+ const seq = (ev.payload as Record<string, unknown>).sequence;
77
+ return typeof seq === 'number' && Number.isInteger(seq) && seq >= 0;
78
+ }
79
+
80
+ /** `agent.toolCalled` (RFC 0002 §B). */
81
+ export function isAgentToolCalled(
82
+ ev: RunEventDoc,
83
+ ): ev is TypedRunEvent<AgentToolCalledPayload> {
84
+ return (
85
+ ev.type === 'agent.toolCalled' &&
86
+ hasStringField(ev.payload, 'agentId') &&
87
+ hasStringField(ev.payload, 'toolName') &&
88
+ hasStringField(ev.payload, 'callId')
89
+ );
90
+ }
91
+
92
+ /** `agent.toolReturned` (RFC 0002 §B). Pairs with `agent.toolCalled`
93
+ * via `callId`; `outcome` and `error` are mutually exclusive but the
94
+ * guard doesn't enforce that (callers inspect after narrowing). */
95
+ export function isAgentToolReturned(
96
+ ev: RunEventDoc,
97
+ ): ev is TypedRunEvent<AgentToolReturnedPayload> {
98
+ return (
99
+ ev.type === 'agent.toolReturned' &&
100
+ hasStringField(ev.payload, 'agentId') &&
101
+ hasStringField(ev.payload, 'toolName') &&
102
+ hasStringField(ev.payload, 'callId')
103
+ );
104
+ }
105
+
106
+ /** `agent.handoff` (RFC 0002 §B). Note the distinct field names —
107
+ * `fromAgentId` / `toAgentId`, NOT a single `agentId`. */
108
+ export function isAgentHandoff(
109
+ ev: RunEventDoc,
110
+ ): ev is TypedRunEvent<AgentHandoffPayload> {
111
+ return (
112
+ ev.type === 'agent.handoff' &&
113
+ hasStringField(ev.payload, 'fromAgentId') &&
114
+ hasStringField(ev.payload, 'toAgentId')
115
+ );
116
+ }
117
+
118
+ /** `agent.decided` (RFC 0002 §B). `decision` is `unknown` per the
119
+ * schema (host-specific shape); guard only validates `agentId` +
120
+ * the presence of a `decision` key. */
121
+ export function isAgentDecided(
122
+ ev: RunEventDoc,
123
+ ): ev is TypedRunEvent<AgentDecidedPayload> {
124
+ return (
125
+ ev.type === 'agent.decided' &&
126
+ hasStringField(ev.payload, 'agentId') &&
127
+ ev.payload !== null &&
128
+ typeof ev.payload === 'object' &&
129
+ 'decision' in (ev.payload as Record<string, unknown>)
130
+ );
131
+ }
132
+
133
+ // ─── High-level subscription helper ─────────────────────────────────────
134
+
135
+ /** Returned by {@link subscribeToAgentReasoning}. Call to cancel the
136
+ * underlying SSE subscription. Idempotent — repeated calls are no-ops. */
137
+ export type Unsubscribe = () => void;
138
+
139
+ /** Per-callback signatures for {@link subscribeToAgentReasoning}. All
140
+ * callbacks are optional; the helper only invokes those provided.
141
+ * Callback exceptions are caught + reported via `onError` (when
142
+ * provided) so one bad handler doesn't tear down the stream. */
143
+ export interface AgentReasoningCallbacks {
144
+ /** Fired for each `agent.reasoning.delta` event in arrival order
145
+ * (RFC 0024). `sequence` starts at 0 and increments by 1 per delta
146
+ * within a single reasoning block. */
147
+ onDelta?: (payload: AgentReasoningDeltaPayload, ev: TypedRunEvent<AgentReasoningDeltaPayload>) => void | Promise<void>;
148
+ /** Fired once per closed reasoning block with the full
149
+ * authoritative content. Consumers that only care about the final
150
+ * trace can subscribe just to this. */
151
+ onClosed?: (payload: AgentReasonedPayload, ev: TypedRunEvent<AgentReasonedPayload>) => void | Promise<void>;
152
+ /** Fired when the SSE stream ends cleanly (server closed or run
153
+ * reached terminal status). */
154
+ onEnd?: () => void;
155
+ /** Fired when the stream fails OR a callback throws. The helper
156
+ * catches handler exceptions so a thrown error in `onDelta` doesn't
157
+ * tear down the whole subscription; the original error is surfaced
158
+ * here. */
159
+ onError?: (err: Error) => void;
160
+ }
161
+
162
+ /**
163
+ * Subscribe to the reasoning event sub-stream for a single run.
164
+ * Convenience wrapper over `streamEvents()` that dispatches the two
165
+ * RFC 0024 event types into typed callbacks.
166
+ *
167
+ * Returns immediately; the SSE consumption runs as a detached async
168
+ * task in the background. Call the returned `Unsubscribe` to cancel.
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * const stop = subscribeToAgentReasoning(ctx, runId, {
173
+ * onDelta: ({ delta, sequence }) => process.stdout.write(delta),
174
+ * onClosed: ({ reasoning }) => console.log('\nfinal:', reasoning),
175
+ * });
176
+ * // ...later
177
+ * stop();
178
+ * ```
179
+ */
180
+ export function subscribeToAgentReasoning(
181
+ ctx: EventsStreamContext,
182
+ runId: string,
183
+ callbacks: AgentReasoningCallbacks,
184
+ options: Omit<EventsStreamOptions, 'signal'> = {},
185
+ ): Unsubscribe {
186
+ const abort = new AbortController();
187
+ let stopped = false;
188
+
189
+ const stop: Unsubscribe = () => {
190
+ if (stopped) return;
191
+ stopped = true;
192
+ abort.abort();
193
+ };
194
+
195
+ void (async () => {
196
+ try {
197
+ // streamModes default to 'updates' — agent.* events flow there
198
+ // per `spec/v1/stream-modes.md`.
199
+ const events = streamEvents(ctx, runId, {
200
+ ...options,
201
+ streamMode: options.streamMode ?? 'updates',
202
+ signal: abort.signal,
203
+ });
204
+
205
+ for await (const ev of events) {
206
+ if (stopped) break;
207
+ try {
208
+ if (isAgentReasoningDelta(ev)) {
209
+ await callbacks.onDelta?.(ev.payload, ev);
210
+ } else if (isAgentReasoned(ev)) {
211
+ await callbacks.onClosed?.(ev.payload, ev);
212
+ }
213
+ // Other event types ignored — forward-compat per
214
+ // COMPATIBILITY.md §2.1. Consumers that want them iterate
215
+ // streamEvents() directly.
216
+ } catch (handlerErr) {
217
+ // Handler exceptions: surface via onError but DO NOT tear
218
+ // down the stream. One bad handler shouldn't kill the rest.
219
+ callbacks.onError?.(
220
+ handlerErr instanceof Error
221
+ ? handlerErr
222
+ : new Error(String(handlerErr)),
223
+ );
224
+ }
225
+ }
226
+ if (!stopped) callbacks.onEnd?.();
227
+ } catch (streamErr) {
228
+ if (stopped) return; // intentional cancellation
229
+ callbacks.onError?.(
230
+ streamErr instanceof Error
231
+ ? streamErr
232
+ : new Error(String(streamErr)),
233
+ );
234
+ }
235
+ })();
236
+
237
+ return stop;
238
+ }