@hsb3/carbon-agui-adapter 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ devDependency only — the consuming app owns its own Carbon version.
13
13
  - LangGraph interrupts → an approve/reject/edit decision card; `respondToInterrupt` resumes the same thread
14
14
  - `CUSTOM` `carbon.item`/`carbon.items` events → native Carbon items (the generative-UI seam)
15
15
  - Validated at both seams at runtime: AG-UI events via `@ag-ui/core` schemas, Carbon items via an allowlist
16
- - `test/carbon-compat.ts` typechecks the adapter against the real `@carbon/ai-chat` (1.19) types
16
+ - `test/carbon-compat.ts` typechecks the adapter against the real `@carbon/ai-chat` types (version pinned: see `docs/SPEC.md` §3)
17
17
  - `bun run coverage` prints the AG-UI x Carbon matrix and exits nonzero on any gap
18
18
 
19
19
  ## Run
@@ -42,6 +42,8 @@ const config = {
42
42
  };
43
43
  ```
44
44
 
45
+ `createSseRunner` also takes `fetch` (inject your own) and `onParseError(raw, err)` — a `data:` frame that is not valid JSON is reported there and skipped, never thrown, so the rest of the stream survives.
46
+
45
47
  Need history/state/reset access? Use the class:
46
48
 
47
49
  ```ts
@@ -73,24 +75,44 @@ const run = (input, { signal }) => fromObservable(agent.run(input), signal);
73
75
  | `RUN_ERROR` | throws `AgUiRunError` |
74
76
  | `RUN_FINISHED` (no outcome) / stream end / abort | `final_response` (aborted text gets `stream_stopped: true`) |
75
77
  | `RUN_FINISHED` with `outcome.type === 'interrupt'` | `user_defined` decision item (`InterruptDecisionData`); interrupt retained for resume |
78
+ | `RUN_FINISHED`, no interrupts, `detectClarification` accepts the state | `user_defined` question item (`ClarificationData`) + `onClarification`; no interrupt state touched |
76
79
  | `MESSAGES_SNAPSHOT` with a new assistant message | rendered as a `text` item (covers non-streaming graphs, e.g. a resume continuation) |
77
80
  | `RUN_STARTED`, `STEP_*`, `SUBAGENT_*`, `RAW`, `CUSTOM` | `onEvent` only |
78
81
 
79
82
  ## HITL: interrupt → approve/reject/edit → resume
80
83
 
81
84
  A LangGraph interrupt (via `ag-ui-langgraph`, `emit_interrupt_outcome=True`) arrives on
82
- `RUN_FINISHED.outcome`. The adapter emits a Carbon `user_defined` item carrying
83
- `InterruptDecisionData` (`kind: 'interrupt'`, `interruptId`, `message`, `action`, `args`,
84
- `responseSchema`, `toolCallId`) so a host renderer can draw a decision card, and retains the
85
- interrupt on `adapter.pendingInterrupt`. The host resolves it:
85
+ `RUN_FINISHED.outcome`. The adapter emits one Carbon `user_defined` item per interrupt in
86
+ that outcome, carrying `InterruptDecisionData` (`kind: 'interrupt'`, `interruptId`,
87
+ `message`, `action`, `args`, `responseSchema`, `toolCallId`) so a host renderer can draw a
88
+ decision card, and retains **every** interrupt awaiting its resume in
89
+ `adapter.pendingInterrupts` (keyed by id, arrival order) — a later interrupt is added,
90
+ never assigned over an unanswered one, and a repeat of an id already pending is ignored.
91
+ `adapter.pendingInterrupt` reads the oldest pending one, which may already carry a
92
+ decision that has not been resumed yet. The host resolves them:
86
93
 
87
94
  ```ts
88
- await adapter.respondToInterrupt(decision, instance);
95
+ await adapter.respondToInterrupt(decision, instance, { signal }, interruptId);
89
96
  // decision: { type: 'approve' } | { type: 'edit', args } | { type: 'reject' }
97
+ // interruptId defaults to the oldest interrupt that has no decision yet. When every
98
+ // pending interrupt is already decided (a failed resume put them back), it defaults to
99
+ // the only one if there is exactly one and throws otherwise, rather than guess which
100
+ // card a bare retry meant.
90
101
  ```
91
102
 
92
- This issues a resume run — same `threadId`, empty `messages`, one `resume[]` entry — through
93
- the same runner and streams the continuation back into the conversation. The decision → wire
103
+ Once every pending interrupt has a decision this issues ONE resume run — same `threadId`,
104
+ empty `messages`, one `resume[]` entry per interrupt in arrival order through the same
105
+ runner and streams the continuation back into the conversation. Answering one of several
106
+ pending interrupts resolves without running anything.
107
+
108
+ A failed resume is retryable **if and only if the run failed before its first event**. If
109
+ it yields no event at all — it threw before the first one, an already-aborted signal
110
+ swallowed it, or it simply completed empty — the interrupts and decisions are restored, so
111
+ the decision can be taken again; `respondToInterrupt` still rejects, so the host can show
112
+ the error and re-arm its card. Once the run has yielded any event — `RUN_ERROR` included —
113
+ the server has consumed the interrupt and it is **not** restored: the host should report
114
+ the error and leave the card disabled. (A `reset()` during an in-flight resume also wins:
115
+ nothing is restored into a conversation the host has cleared.) The decision → wire
94
116
  mapping (see `docs/hitl-interrupt-resume.md`):
95
117
 
96
118
  | Decision | `resume[]` entry |
@@ -102,9 +124,42 @@ mapping (see `docs/hitl-interrupt-resume.md`):
102
124
  Register the card with the web component's `renderUserDefinedResponse` and read
103
125
  `state.messageItem.user_defined`; see `examples/langgraph-carbon/web/src/main.ts`.
104
126
 
127
+ ## Clarification without an interrupt
128
+
129
+ A graph that has not adopted `interrupt()` can still be **asking**: it ends the run
130
+ normally, leaves a marker in state, and waits for a fresh turn on the same thread. Every
131
+ sink renders that as a finished answer unless told how to spot the marker — and the marker
132
+ is deployment-specific, so it is yours to supply:
133
+
134
+ ```ts
135
+ new CarbonAgUiAdapter({
136
+ run,
137
+ detectClarification: (state) =>
138
+ (state as { phase?: string }).phase === 'needs_detail'
139
+ ? { question: (state as { ask?: string }).ask }
140
+ : false,
141
+ onClarification: (data) => console.log(data.question, data.threadId), // optional
142
+ });
143
+ ```
144
+
145
+ `detectClarification` is called **at most once per run**, on `RUN_FINISHED`, with the
146
+ state the adapter already tracks, and **only when the outcome carries no interrupts** — an
147
+ interrupt outcome already is a question. A truthy verdict emits a `user_defined` item
148
+ carrying `ClarificationData` (`kind: 'clarification'`, `question?`, `threadId`) through the
149
+ same `complete_item` path as the decision card, so one `renderUserDefinedResponse` branches
150
+ on `kind`; `onClarification` fires with the same payload for a host that wants the flag
151
+ without rendering. Nothing else moves: no interrupt state is read or written,
152
+ `respondToInterrupt` is unaffected, and the thread stays live by construction — the
153
+ follow-up is an ordinary send on the same `threadId`. Supply no predicate and behavior is
154
+ byte for byte what it was before. A predicate that throws is a host bug and propagates,
155
+ like a throwing `onEvent`.
156
+
157
+ Deliberately temporary: this is the bridge for pre-`interrupt()` graphs (contract and
158
+ deletion plan in `docs/hitl-interrupt-resume.md`).
159
+
105
160
  ## Notes
106
161
 
107
- - Verified against `@carbon/ai-chat@1.19.0`: `PartialItemChunk` / `CompleteItemChunk` (`streaming_metadata.response_id`) / `FinalResponseChunk` (`final_response.id` = `response_id`), `ItemStreamingMetadata.stream_stopped`, `CustomSendMessageOptions.signal`, `ChainOfThoughtStep`.
162
+ - Verified against the pinned `@carbon/ai-chat` (`docs/SPEC.md` §3): `PartialItemChunk` / `CompleteItemChunk` (`streaming_metadata.response_id`) / `FinalResponseChunk` (`final_response.id` = `response_id`), `ItemStreamingMetadata.stream_stopped`, `CustomSendMessageOptions.signal`, `ChainOfThoughtStep`.
108
163
  - Runtime is dependency-free; `@carbon/ai-chat` is a devDependency only for the compat typecheck (pulls ~200 MB of Carbon peers — delete `test/carbon-compat.ts` and the devDep if you don't want that).
109
164
  - `response_type` is a string enum in Carbon (`MessageResponseTypes`); the adapter emits the plain string `"text"`, which is the enum's runtime value. Chain-of-thought is only attached to `final_response` (no live per-step updates) — return a `system` item from `onToolCall` if you need immediate feedback.
110
- - JSON Patch supports `add` / `replace` / `remove` only. Use `fast-json-patch` if your agent emits `move` / `copy` / `test`.
165
+ - `applyJsonPatch` implements the full RFC 6902 op set — `add` / `remove` / `replace` / `move` / `copy` / `test`.
package/dist/adapter.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgUiContext, AgUiEvent, AgUiMessage, AgUiRunner, AgUiTool, CarbonChatInstanceLike, CarbonCustomSendMessage, CarbonGenericItem, CarbonMessageFeedbackOptions, CarbonMessageRequest, CarbonSendMessageOptions, Decision, Interrupt, JsonPatchOp, RunAgentInput, RunOutcome } from './types.js';
1
+ import type { AgUiContext, AgUiEvent, AgUiMessage, AgUiRunner, AgUiTool, CarbonChatInstanceLike, CarbonCustomSendMessage, CarbonGenericItem, CarbonMessageFeedbackOptions, CarbonMessageRequest, CarbonSendMessageOptions, ClarificationData, Decision, Interrupt, JsonPatchOp, RunAgentInput, RunOutcome } from './types.js';
2
2
  export interface ToolCallInfo {
3
3
  id: string;
4
4
  name: string;
@@ -34,6 +34,33 @@ export interface AdapterOptions {
34
34
  outcome?: RunOutcome;
35
35
  result?: unknown;
36
36
  }) => void;
37
+ /**
38
+ * Clarification-as-state (kata 73gc). A graph with no `interrupt()` can still
39
+ * be ASKING something: it ends the run normally and leaves a marker in state,
40
+ * expecting a fresh turn on the same thread. Every marker is deployment
41
+ * specific, so the rule is a caller-supplied predicate — nothing here knows
42
+ * any marker string.
43
+ *
44
+ * Called at most ONCE per run, on `RUN_FINISHED`, with the latest tracked
45
+ * `state`, and only when the outcome carries no interrupts (an interrupt
46
+ * outcome already IS a question, and its decision card is the affordance).
47
+ * Return `false` for a normal completion, or `{ question? }` to render the
48
+ * turn as an open question: a `user_defined` item carrying
49
+ * `ClarificationData`, sibling of the interrupt card. It touches no interrupt
50
+ * state, so `respondToInterrupt` is unaffected and the thread simply stays
51
+ * live for the next send.
52
+ *
53
+ * A throwing predicate is a host bug and propagates like a throwing `onEvent`
54
+ * — it is not routed to `onInvalidEvent`, which reports bad data from the wire.
55
+ *
56
+ * ponytail: bridge to real `interrupt()` adoption; delete option, callback and
57
+ * `ClarificationData` together once graphs interrupt properly.
58
+ */
59
+ detectClarification?: (state: unknown) => false | {
60
+ question?: string;
61
+ };
62
+ /** Fires with the same payload the clarification item carries, for a host that wants the flag without rendering a card. */
63
+ onClarification?: (data: ClarificationData) => void;
37
64
  /**
38
65
  * When set, Carbon feedback (thumbs) config attached to each assistant TEXT
39
66
  * response item so the UI renders the controls. Feedback attaches per-item
@@ -70,28 +97,58 @@ export declare class CarbonAgUiAdapter {
70
97
  content: Record<string, unknown>;
71
98
  }>;
72
99
  messages: AgUiMessage[];
73
- /** The interrupt awaiting a decision, or undefined when none is pending. */
74
- pendingInterrupt?: Interrupt;
100
+ /**
101
+ * Every interrupt still awaiting its resume, keyed by interrupt id in arrival
102
+ * order — one RUN_FINISHED outcome can carry several, and a later outcome adds
103
+ * to the collection rather than replacing it. Insertion order is the order the
104
+ * resume `resume[]` entries are built in. An entry stays here after it has been
105
+ * answered, until the resume run actually goes out; read `decisions` state via
106
+ * `respondToInterrupt` rather than tracking it yourself.
107
+ */
108
+ readonly pendingInterrupts: Map<string, Interrupt>;
109
+ /** Decisions collected so far, keyed by interrupt id. The resume run fires once every pending interrupt has one. */
110
+ private readonly decisions;
75
111
  private readonly genId;
112
+ /** Bumped by reset(); an in-flight resume that restores compares against it. */
113
+ private resetGeneration;
76
114
  /** The Carbon instance from the most recent run, reused for resume streaming. */
77
115
  private lastInstance?;
116
+ /**
117
+ * The oldest pending interrupt — which may already carry a decision that has
118
+ * not been resumed yet — or undefined when none is pending. `pendingInterrupts`
119
+ * is the full set; this is the convenience read for the single-interrupt case.
120
+ */
121
+ get pendingInterrupt(): Interrupt | undefined;
78
122
  constructor(opts: AdapterOptions);
79
123
  /** Pass as `messaging.customSendMessage` in Carbon's PublicConfig. */
80
124
  readonly sendMessage: CarbonCustomSendMessage;
81
125
  buildRunInput(request: CarbonMessageRequest): RunAgentInput;
82
126
  reset(): void;
83
127
  /**
84
- * Resume the interrupted run with the user's decision. Builds a resume
128
+ * Record the user's decision for one pending interrupt and, once every
129
+ * pending interrupt has one, resume the interrupted run. Builds a resume
85
130
  * `RunAgentInput` per docs/hitl-interrupt-resume.md (same threadId, empty
86
- * messages, one `resume[]` entry), runs it through the same runner, and
87
- * streams the continuation back into the conversation.
131
+ * messages, one `resume[]` entry per pending interrupt in arrival order),
132
+ * runs it through the same runner, and streams the continuation back into
133
+ * the conversation.
134
+ *
135
+ * Answering one of several pending interrupts resolves without running — the
136
+ * graph is resumed once, with the whole batch of decisions.
137
+ *
138
+ * If the resume run yields no events at all (it threw before the first one,
139
+ * or an already-aborted signal swallowed it), the interrupts and decisions
140
+ * are restored so the decision can be retried; the error still rejects.
88
141
  *
89
142
  * @param instance The Carbon instance to stream into. Defaults to the one
90
143
  * from the most recent run (e.g. the interrupted turn).
91
144
  * @param options Forwarded to the runner — pass `signal` so the host's stop
92
145
  * control can abort the resumed continuation like a normal send.
146
+ * @param interruptId Which pending interrupt this decision answers. Defaults
147
+ * to the oldest one still undecided; when every pending interrupt already
148
+ * has a decision (a failed resume restored them) it defaults to the only
149
+ * one if there is exactly one, and throws otherwise rather than guess.
93
150
  */
94
- respondToInterrupt(decision: Decision, instance?: CarbonChatInstanceLike, options?: CarbonSendMessageOptions): Promise<void>;
151
+ respondToInterrupt(decision: Decision, instance?: CarbonChatInstanceLike, options?: CarbonSendMessageOptions, interruptId?: string): Promise<void>;
95
152
  private upsertMessage;
96
153
  private handle;
97
154
  /**
package/dist/adapter.js CHANGED
@@ -20,11 +20,32 @@ export class CarbonAgUiAdapter {
20
20
  /** Progress/status state per messageId, reconciled from ACTIVITY_SNAPSHOT/ACTIVITY_DELTA. */
21
21
  activities = new Map();
22
22
  messages = [];
23
- /** The interrupt awaiting a decision, or undefined when none is pending. */
24
- pendingInterrupt;
23
+ /**
24
+ * Every interrupt still awaiting its resume, keyed by interrupt id in arrival
25
+ * order — one RUN_FINISHED outcome can carry several, and a later outcome adds
26
+ * to the collection rather than replacing it. Insertion order is the order the
27
+ * resume `resume[]` entries are built in. An entry stays here after it has been
28
+ * answered, until the resume run actually goes out; read `decisions` state via
29
+ * `respondToInterrupt` rather than tracking it yourself.
30
+ */
31
+ pendingInterrupts = new Map();
32
+ /** Decisions collected so far, keyed by interrupt id. The resume run fires once every pending interrupt has one. */
33
+ decisions = new Map();
25
34
  genId;
35
+ /** Bumped by reset(); an in-flight resume that restores compares against it. */
36
+ resetGeneration = 0;
26
37
  /** The Carbon instance from the most recent run, reused for resume streaming. */
27
38
  lastInstance;
39
+ /**
40
+ * The oldest pending interrupt — which may already carry a decision that has
41
+ * not been resumed yet — or undefined when none is pending. `pendingInterrupts`
42
+ * is the full set; this is the convenience read for the single-interrupt case.
43
+ */
44
+ get pendingInterrupt() {
45
+ for (const interrupt of this.pendingInterrupts.values())
46
+ return interrupt;
47
+ return undefined;
48
+ }
28
49
  constructor(opts) {
29
50
  this.opts = opts;
30
51
  this.genId = opts.idGenerator ?? (() => crypto.randomUUID());
@@ -48,27 +69,63 @@ export class CarbonAgUiAdapter {
48
69
  reset() {
49
70
  this.messages = [];
50
71
  this.state = this.opts.initialState ?? {};
51
- this.pendingInterrupt = undefined;
72
+ this.pendingInterrupts.clear();
73
+ this.decisions.clear();
74
+ this.resetGeneration += 1;
52
75
  }
53
76
  /**
54
- * Resume the interrupted run with the user's decision. Builds a resume
77
+ * Record the user's decision for one pending interrupt and, once every
78
+ * pending interrupt has one, resume the interrupted run. Builds a resume
55
79
  * `RunAgentInput` per docs/hitl-interrupt-resume.md (same threadId, empty
56
- * messages, one `resume[]` entry), runs it through the same runner, and
57
- * streams the continuation back into the conversation.
80
+ * messages, one `resume[]` entry per pending interrupt in arrival order),
81
+ * runs it through the same runner, and streams the continuation back into
82
+ * the conversation.
83
+ *
84
+ * Answering one of several pending interrupts resolves without running — the
85
+ * graph is resumed once, with the whole batch of decisions.
86
+ *
87
+ * If the resume run yields no events at all (it threw before the first one,
88
+ * or an already-aborted signal swallowed it), the interrupts and decisions
89
+ * are restored so the decision can be retried; the error still rejects.
58
90
  *
59
91
  * @param instance The Carbon instance to stream into. Defaults to the one
60
92
  * from the most recent run (e.g. the interrupted turn).
61
93
  * @param options Forwarded to the runner — pass `signal` so the host's stop
62
94
  * control can abort the resumed continuation like a normal send.
95
+ * @param interruptId Which pending interrupt this decision answers. Defaults
96
+ * to the oldest one still undecided; when every pending interrupt already
97
+ * has a decision (a failed resume restored them) it defaults to the only
98
+ * one if there is exactly one, and throws otherwise rather than guess.
63
99
  */
64
- async respondToInterrupt(decision, instance, options = {}) {
65
- const interrupt = this.pendingInterrupt;
66
- if (!interrupt)
100
+ async respondToInterrupt(decision, instance, options = {}, interruptId) {
101
+ const ids = [...this.pendingInterrupts.keys()];
102
+ if (interruptId === undefined && ids.length === 0) {
67
103
  throw new Error('respondToInterrupt: no pending interrupt');
104
+ }
105
+ const targetId = interruptId ??
106
+ ids.find((id) => !this.decisions.has(id)) ??
107
+ // Everything pending is already decided — a restored failure. One card is an
108
+ // unambiguous retry; with several, picking the oldest would silently rewrite a
109
+ // decision the user made on a different card and resume on the spot.
110
+ (ids.length === 1 ? ids[0] : undefined);
111
+ if (targetId === undefined) {
112
+ throw new Error('respondToInterrupt: several interrupts pending — pass interruptId');
113
+ }
114
+ if (!this.pendingInterrupts.has(targetId)) {
115
+ throw new Error(`respondToInterrupt: unknown interrupt ${targetId}`);
116
+ }
68
117
  const target = instance ?? this.lastInstance;
69
118
  if (!target)
70
119
  throw new Error('respondToInterrupt: no Carbon instance to stream into');
71
- this.pendingInterrupt = undefined;
120
+ this.decisions.set(targetId, decision);
121
+ // Every pending card must be answered before the graph can be resumed once.
122
+ if (this.decisions.size < this.pendingInterrupts.size)
123
+ return;
124
+ const resume = ids.map((id) => decisionToResumeEntry(this.decisions.get(id), id));
125
+ const heldInterrupts = new Map(this.pendingInterrupts);
126
+ const heldDecisions = new Map(this.decisions);
127
+ this.pendingInterrupts.clear();
128
+ this.decisions.clear();
72
129
  const input = {
73
130
  threadId: this.threadId,
74
131
  runId: this.genId(),
@@ -77,9 +134,37 @@ export class CarbonAgUiAdapter {
77
134
  tools: this.opts.tools ?? [],
78
135
  context: this.opts.context ?? [],
79
136
  forwardedProps: this.opts.forwardedProps ?? {},
80
- resume: [decisionToResumeEntry(decision, interrupt.id)],
137
+ resume,
138
+ };
139
+ // ponytail: "no events yielded" is the retryable test — a successful but eventless
140
+ // resume restores too. Once the server yields ANY event (RUN_ERROR included) it has
141
+ // consumed the interrupt, so restoring there would double-resume the graph.
142
+ const progress = { started: false };
143
+ const generation = this.resetGeneration;
144
+ const restore = () => {
145
+ // A reset() during the run means the host threw the conversation away; putting a
146
+ // decision card back into it would resurrect an interrupt with no thread behind it.
147
+ if (progress.started || this.resetGeneration !== generation)
148
+ return;
149
+ for (const [id, interrupt] of heldInterrupts) {
150
+ if (!this.pendingInterrupts.has(id))
151
+ this.pendingInterrupts.set(id, interrupt);
152
+ }
153
+ for (const [id, held] of heldDecisions) {
154
+ if (!this.decisions.has(id))
155
+ this.decisions.set(id, held);
156
+ }
81
157
  };
82
- await this.runAndStream(input, options, target);
158
+ try {
159
+ await this.runAndStream(input, options, target, undefined, progress);
160
+ }
161
+ catch (err) {
162
+ restore();
163
+ throw err;
164
+ }
165
+ // runAndStream swallows the throw when options.signal is aborted; that path leaves
166
+ // the decision unsent too, so it restores just the same.
167
+ restore();
83
168
  }
84
169
  upsertMessage(msg) {
85
170
  const i = this.messages.findIndex((m) => m.id === msg.id);
@@ -97,7 +182,7 @@ export class CarbonAgUiAdapter {
97
182
  * into Carbon chunks on `instance`. Shared by the initial turn and by
98
183
  * `respondToInterrupt`, so a resumed continuation streams identically.
99
184
  */
100
- async runAndStream(input, options, instance, requestId) {
185
+ async runAndStream(input, options, instance, requestId, progress) {
101
186
  this.lastInstance = instance;
102
187
  const { runId } = input;
103
188
  const meta = { response_id: runId };
@@ -110,6 +195,8 @@ export class CarbonAgUiAdapter {
110
195
  // Chunk variants carry the id only on their first chunk; later chunks continue the current one.
111
196
  let lastChunkTextId;
112
197
  let lastChunkToolId;
198
+ // detectClarification is evaluated at most once per run (see RUN_FINISHED).
199
+ let clarificationChecked = false;
113
200
  // Reasoning text accumulates per messageId (THINKING_TEXT_* has none → fixed 'thinking'
114
201
  // key). One ReasoningStep per key at close; empties are dropped. Not streamed live.
115
202
  const THINKING_KEY = 'thinking';
@@ -182,6 +269,10 @@ export class CarbonAgUiAdapter {
182
269
  };
183
270
  try {
184
271
  for await (const rawEv of this.opts.run(input, { signal: options.signal })) {
272
+ // The run has produced something, so a resume can no longer be retried (see
273
+ // respondToInterrupt): the server has consumed the interrupt.
274
+ if (progress)
275
+ progress.started = true;
185
276
  // IN seam (issue gmb9): validate against the AG-UI protocol schema before
186
277
  // switching. A malformed event is reported and skipped, NEVER thrown —
187
278
  // one bad event must not kill the stream. `validate: false` passes through.
@@ -278,8 +369,19 @@ export class CarbonAgUiAdapter {
278
369
  this.opts.onStateChange?.(this.state);
279
370
  break;
280
371
  case 'STATE_DELTA':
281
- this.state = applyJsonPatch(this.state, ev.delta);
282
- this.opts.onStateChange?.(this.state);
372
+ try {
373
+ this.state = applyJsonPatch(this.state, ev.delta);
374
+ this.opts.onStateChange?.(this.state);
375
+ }
376
+ catch (err) {
377
+ // A patch that is schema-valid but fails to apply (stale JSON Pointer,
378
+ // failed `test` op) is reported and skipped, exactly like a malformed
379
+ // event: one bad event must not kill the stream. `applyJsonPatch` works
380
+ // on a clone, so state keeps its last good value — it may diverge from
381
+ // the server until the next STATE_SNAPSHOT.
382
+ const msg = err instanceof Error ? err.message : String(err);
383
+ this.opts.onInvalidEvent?.(ev, `STATE_DELTA failed to apply: ${msg}; ops: ${JSON.stringify(ev.delta)}`);
384
+ }
283
385
  break;
284
386
  case 'ACTIVITY_SNAPSHOT':
285
387
  // A snapshot is authoritative; it replaces any prior activity for this messageId.
@@ -289,9 +391,19 @@ export class CarbonAgUiAdapter {
289
391
  break;
290
392
  case 'ACTIVITY_DELTA': {
291
393
  const entry = this.activities.get(ev.messageId) ?? { activityType: ev.activityType, content: {} };
292
- entry.content = applyJsonPatch(entry.content, ev.patch);
293
- this.activities.set(ev.messageId, entry);
294
- this.opts.onActivity?.(entry.activityType, entry.content, ev.messageId);
394
+ try {
395
+ entry.content = applyJsonPatch(entry.content, ev.patch);
396
+ this.activities.set(ev.messageId, entry);
397
+ this.opts.onActivity?.(entry.activityType, entry.content, ev.messageId);
398
+ }
399
+ catch (err) {
400
+ // Same contract as STATE_DELTA above: a patch that is schema-valid but
401
+ // fails to apply is reported and skipped, never thrown. `applyJsonPatch`
402
+ // works on a clone, so the activity keeps its last good content — it may
403
+ // diverge from the server until the next ACTIVITY_SNAPSHOT.
404
+ const msg = err instanceof Error ? err.message : String(err);
405
+ this.opts.onInvalidEvent?.(ev, `ACTIVITY_DELTA failed to apply: ${msg}; ops: ${JSON.stringify(ev.patch)}`);
406
+ }
295
407
  break;
296
408
  }
297
409
  case 'MESSAGES_SNAPSHOT': {
@@ -345,19 +457,45 @@ export class CarbonAgUiAdapter {
345
457
  // turn as a plain final_response, emit a user_defined decision item so
346
458
  // the host can render an approve/reject/edit card, and retain the
347
459
  // interrupt for the resume. Non-interrupt outcomes fall through.
460
+ // The only non-interrupt outcome is `{ type: 'success' }` (or an absent
461
+ // outcome): a clean completion that carries no payload, so the
462
+ // already-streamed content stands and there is nothing extra to render.
463
+ // A host reads any returned `result` via onRunFinished above.
348
464
  this.opts.onRunFinished?.({ outcome: ev.outcome, result: ev.result });
349
- const interrupt = firstInterrupt(ev.outcome);
350
- if (interrupt) {
351
- this.pendingInterrupt = interrupt;
465
+ const interrupts = ev.outcome?.type === 'interrupt' ? ev.outcome.interrupts ?? [] : [];
466
+ for (const interrupt of interrupts) {
467
+ // Never clobber an interrupt already awaiting a decision — that is exactly
468
+ // how a card ends up on screen with no way to answer it — and never draw a
469
+ // second card for it either: the duplicate's click could only fail.
470
+ if (this.pendingInterrupts.has(interrupt.id))
471
+ continue;
472
+ this.pendingInterrupts.set(interrupt.id, interrupt);
352
473
  const item = interruptItem(interrupt);
353
474
  items.push(item);
354
475
  await emit({ complete_item: item, streaming_metadata: meta });
355
476
  }
356
- else {
357
- // The only non-interrupt outcome is `{ type: 'success' }` (or an
358
- // absent outcome): a clean completion that carries no payload, so
359
- // the already-streamed content stands and there is nothing extra to
360
- // render. A host reads any returned `result` via onRunFinished above.
477
+ // Clarification-as-state (kata 73gc), deliberately AFTER and disjoint from
478
+ // the interrupt path: a graph with no interrupt() can still be asking
479
+ // something, and only the host knows its marker. Interrupt outcomes are
480
+ // excluded — the decision card already is the question and the predicate
481
+ // runs at most once per run, so a repeated RUN_FINISHED cannot draw a
482
+ // second card. Nothing here reads or writes pendingInterrupts, which is
483
+ // what makes the whole path deletable once interrupt() is adopted.
484
+ if (!clarificationChecked && interrupts.length === 0 && this.opts.detectClarification) {
485
+ clarificationChecked = true;
486
+ // A throwing predicate is a host bug: let it propagate like onEvent does.
487
+ const verdict = this.opts.detectClarification(this.state);
488
+ if (verdict) {
489
+ const data = { kind: 'clarification', question: verdict.question, threadId: this.threadId };
490
+ this.opts.onClarification?.(data);
491
+ const item = {
492
+ response_type: 'user_defined',
493
+ user_defined: data,
494
+ streaming_metadata: { id: this.genId() },
495
+ };
496
+ items.push(item);
497
+ await emit({ complete_item: item, streaming_metadata: meta });
498
+ }
361
499
  }
362
500
  break;
363
501
  }
@@ -426,12 +564,6 @@ function parseJsonLoose(s) {
426
564
  return s;
427
565
  }
428
566
  }
429
- /** The first interrupt in a RUN_FINISHED outcome, or undefined if not an interrupt. */
430
- function firstInterrupt(outcome) {
431
- if (outcome?.type !== 'interrupt')
432
- return undefined;
433
- return outcome.interrupts?.[0];
434
- }
435
567
  /** Build the Carbon `user_defined` decision-card item from an interrupt. */
436
568
  function interruptItem(interrupt) {
437
569
  const raw = interrupt.metadata?.langgraph?.raw ?? {};
@@ -3,11 +3,17 @@ export interface SseRunnerOptions {
3
3
  url: string;
4
4
  headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
5
5
  fetch?: typeof fetch;
6
+ /** Called when a `data:` payload is not valid JSON; the frame is skipped and the stream continues. */
7
+ onParseError?: (raw: string, err: unknown) => void;
6
8
  }
7
9
  /** POST RunAgentInput as JSON, read back `text/event-stream` of AG-UI events. */
8
10
  export declare function createSseRunner(opts: SseRunnerOptions): AgUiRunner;
9
- /** Parse an SSE byte stream into AG-UI events (one JSON object per `data:` block). */
10
- export declare function parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<AgUiEvent>;
11
+ /**
12
+ * Parse an SSE byte stream into AG-UI events (one JSON object per `data:` block).
13
+ * A block whose payload is not valid JSON is reported to `onParseError` and skipped —
14
+ * one bad frame must not kill the stream.
15
+ */
16
+ export declare function parseSse(body: ReadableStream<Uint8Array>, onParseError?: (raw: string, err: unknown) => void): AsyncGenerator<AgUiEvent>;
11
17
  /** Minimal rxjs-compatible shape, so `@ag-ui/client`'s `agent.run(input)` plugs in without importing rxjs here. */
12
18
  export interface ObservableLike<T> {
13
19
  subscribe(observer: {
package/dist/transport.js CHANGED
@@ -9,13 +9,19 @@ export function createSseRunner(opts) {
9
9
  body: JSON.stringify(input),
10
10
  signal,
11
11
  });
12
- if (!res.ok || !res.body)
12
+ if (!res.ok)
13
13
  throw new Error(`AG-UI request failed: HTTP ${res.status}`);
14
- yield* parseSse(res.body);
14
+ if (!res.body)
15
+ throw new Error('AG-UI response had no body');
16
+ yield* parseSse(res.body, opts.onParseError);
15
17
  };
16
18
  }
17
- /** Parse an SSE byte stream into AG-UI events (one JSON object per `data:` block). */
18
- export async function* parseSse(body) {
19
+ /**
20
+ * Parse an SSE byte stream into AG-UI events (one JSON object per `data:` block).
21
+ * A block whose payload is not valid JSON is reported to `onParseError` and skipped —
22
+ * one bad frame must not kill the stream.
23
+ */
24
+ export async function* parseSse(body, onParseError) {
19
25
  const reader = body.getReader();
20
26
  const decoder = new TextDecoder();
21
27
  let buf = '';
@@ -24,12 +30,12 @@ export async function* parseSse(body) {
24
30
  while ((idx = buf.indexOf('\n\n')) !== -1) {
25
31
  const block = buf.slice(0, idx);
26
32
  buf = buf.slice(idx + 2);
27
- const ev = parseBlock(block);
33
+ const ev = parseBlock(block, onParseError);
28
34
  if (ev)
29
35
  yield ev;
30
36
  }
31
37
  if (final && buf.trim()) {
32
- const ev = parseBlock(buf);
38
+ const ev = parseBlock(buf, onParseError);
33
39
  buf = '';
34
40
  if (ev)
35
41
  yield ev;
@@ -50,13 +56,23 @@ export async function* parseSse(body) {
50
56
  reader.releaseLock();
51
57
  }
52
58
  }
53
- function parseBlock(block) {
59
+ function parseBlock(block, onParseError) {
54
60
  const data = block
55
61
  .split('\n')
56
62
  .filter((l) => l.startsWith('data:'))
57
63
  .map((l) => l.slice(5).replace(/^ /, ''))
58
64
  .join('\n');
59
- return data ? JSON.parse(data) : null;
65
+ if (!data)
66
+ return null;
67
+ try {
68
+ return JSON.parse(data);
69
+ }
70
+ catch (err) {
71
+ // Reported and skipped, via the same "no event" contract as a block with no
72
+ // `data:` lines. Throwing here would discard every event already parsed.
73
+ onParseError?.(data, err);
74
+ return null;
75
+ }
60
76
  }
61
77
  /** Bridge an Observable (e.g. `new HttpAgent({url}).run(input)`) to the AsyncIterable the adapter consumes. */
62
78
  export async function* fromObservable(obs, signal) {
package/dist/types.d.ts CHANGED
@@ -90,6 +90,26 @@ export interface InterruptDecisionData {
90
90
  responseSchema?: unknown;
91
91
  toolCallId?: string;
92
92
  }
93
+ /**
94
+ * Payload carried in the Carbon `user_defined` item emitted when a run finished
95
+ * WITHOUT an interrupt but the host's `detectClarification` predicate says the
96
+ * agent is asking a question (kata 73gc). Sibling of `InterruptDecisionData`:
97
+ * same `user_defined` seam, different `kind`, and no decision to send back —
98
+ * the user simply types the next message on the same thread.
99
+ *
100
+ * ponytail: deliberately temporary. This is the bridge for graphs that signal a
101
+ * clarification by completing normally with a marker in state; once they adopt
102
+ * real `interrupt()` the question arrives as an interrupt and this whole path
103
+ * (type, option, callback) deletes cleanly, because it never touches the
104
+ * interrupt state.
105
+ */
106
+ export interface ClarificationData {
107
+ kind: 'clarification';
108
+ /** The question, when the predicate could extract one from state. */
109
+ question?: string;
110
+ /** The still-live thread the follow-up turn goes to. */
111
+ threadId: string;
112
+ }
93
113
  export interface JsonPatchOp {
94
114
  op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
95
115
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hsb3/carbon-agui-adapter",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Adapter that drives IBM Carbon AI Chat from an AG-UI event stream",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",