@hsb3/carbon-agui-adapter 0.1.0 → 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
@@ -1,26 +1,34 @@
1
1
  # carbon-agui-adapter
2
2
 
3
- Drives IBM **Carbon AI Chat** (`@carbon/ai-chat`) from an **AG-UI** event stream. Zero runtime deps.
3
+ Drives IBM **Carbon AI Chat** (`@carbon/ai-chat`) from an **AG-UI** event stream.
4
+
5
+ One runtime dependency (`@ag-ui/core`, for event schemas). `@carbon/ai-chat` is a
6
+ devDependency only — the consuming app owns its own Carbon version.
4
7
 
5
8
  - Carbon `customSendMessage` → AG-UI `RunAgentInput` (thread + history + tools + state)
6
9
  - AG-UI events → Carbon `addMessageChunk` (`partial_item` / `complete_item` / `final_response`)
7
- - Keeps thread history and shared state across turns; applies `STATE_DELTA` JSON Patches
10
+ - Keeps thread history and shared state across turns; applies `STATE_DELTA` as full RFC 6902 JSON Patch
8
11
  - Tool calls → Carbon **chain-of-thought** steps (`message_options.chain_of_thought`) with args/result/status
9
12
  - Cancel via Carbon's `AbortSignal`; `RUN_ERROR` throws so Carbon shows the retry UI
10
- - `test/carbon-compat.ts` typechecks the adapter against the real `@carbon/ai-chat` (1.19) types
13
+ - LangGraph interrupts an approve/reject/edit decision card; `respondToInterrupt` resumes the same thread
14
+ - `CUSTOM` `carbon.item`/`carbon.items` events → native Carbon items (the generative-UI seam)
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` types (version pinned: see `docs/SPEC.md` §3)
17
+ - `bun run coverage` prints the AG-UI x Carbon matrix and exits nonzero on any gap
11
18
 
12
19
  ## Run
13
20
 
14
21
  ```bash
15
- npm install
16
- npm run check # typecheck + tests
17
- npm run build # dist/
22
+ bun install
23
+ bun run check # typecheck + tests
24
+ bun run coverage # AG-UI x Carbon coverage matrix; must exit 0
25
+ bun run build # dist/
18
26
  ```
19
27
 
20
28
  ## Use
21
29
 
22
30
  ```ts
23
- import { createAgUiSendMessage, createSseRunner } from 'carbon-agui-adapter';
31
+ import { createAgUiSendMessage, createSseRunner } from '@hsb3/carbon-agui-adapter';
24
32
 
25
33
  const config = {
26
34
  messaging: {
@@ -34,6 +42,8 @@ const config = {
34
42
  };
35
43
  ```
36
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
+
37
47
  Need history/state/reset access? Use the class:
38
48
 
39
49
  ```ts
@@ -46,7 +56,7 @@ Using `@ag-ui/client` instead of raw SSE:
46
56
 
47
57
  ```ts
48
58
  import { HttpAgent } from '@ag-ui/client';
49
- import { fromObservable } from 'carbon-agui-adapter';
59
+ import { fromObservable } from '@hsb3/carbon-agui-adapter';
50
60
 
51
61
  const agent = new HttpAgent({ url: 'https://my-agent/run' });
52
62
  const run = (input, { signal }) => fromObservable(agent.run(input), signal);
@@ -65,24 +75,44 @@ const run = (input, { signal }) => fromObservable(agent.run(input), signal);
65
75
  | `RUN_ERROR` | throws `AgUiRunError` |
66
76
  | `RUN_FINISHED` (no outcome) / stream end / abort | `final_response` (aborted text gets `stream_stopped: true`) |
67
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 |
68
79
  | `MESSAGES_SNAPSHOT` with a new assistant message | rendered as a `text` item (covers non-streaming graphs, e.g. a resume continuation) |
69
- | `RUN_STARTED`, `STEP_*`, `RAW`, `CUSTOM` | `onEvent` only |
80
+ | `RUN_STARTED`, `STEP_*`, `SUBAGENT_*`, `RAW`, `CUSTOM` | `onEvent` only |
70
81
 
71
82
  ## HITL: interrupt → approve/reject/edit → resume
72
83
 
73
84
  A LangGraph interrupt (via `ag-ui-langgraph`, `emit_interrupt_outcome=True`) arrives on
74
- `RUN_FINISHED.outcome`. The adapter emits a Carbon `user_defined` item carrying
75
- `InterruptDecisionData` (`kind: 'interrupt'`, `interruptId`, `message`, `action`, `args`,
76
- `responseSchema`, `toolCallId`) so a host renderer can draw a decision card, and retains the
77
- 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:
78
93
 
79
94
  ```ts
80
- await adapter.respondToInterrupt(decision, instance);
95
+ await adapter.respondToInterrupt(decision, instance, { signal }, interruptId);
81
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.
82
101
  ```
83
102
 
84
- This issues a resume run — same `threadId`, empty `messages`, one `resume[]` entry — through
85
- 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
86
116
  mapping (see `docs/hitl-interrupt-resume.md`):
87
117
 
88
118
  | Decision | `resume[]` entry |
@@ -94,9 +124,42 @@ mapping (see `docs/hitl-interrupt-resume.md`):
94
124
  Register the card with the web component's `renderUserDefinedResponse` and read
95
125
  `state.messageItem.user_defined`; see `examples/langgraph-carbon/web/src/main.ts`.
96
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
+
97
160
  ## Notes
98
161
 
99
- - 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`.
100
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).
101
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.
102
- - 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, 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,26 +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).
144
+ * @param options Forwarded to the runner — pass `signal` so the host's stop
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.
91
150
  */
92
- respondToInterrupt(decision: Decision, instance?: CarbonChatInstanceLike): Promise<void>;
151
+ respondToInterrupt(decision: Decision, instance?: CarbonChatInstanceLike, options?: CarbonSendMessageOptions, interruptId?: string): Promise<void>;
93
152
  private upsertMessage;
94
153
  private handle;
95
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,25 +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).
93
+ * @param options Forwarded to the runner — pass `signal` so the host's stop
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.
61
99
  */
62
- async respondToInterrupt(decision, instance) {
63
- const interrupt = this.pendingInterrupt;
64
- if (!interrupt)
100
+ async respondToInterrupt(decision, instance, options = {}, interruptId) {
101
+ const ids = [...this.pendingInterrupts.keys()];
102
+ if (interruptId === undefined && ids.length === 0) {
65
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
+ }
66
117
  const target = instance ?? this.lastInstance;
67
118
  if (!target)
68
119
  throw new Error('respondToInterrupt: no Carbon instance to stream into');
69
- 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();
70
129
  const input = {
71
130
  threadId: this.threadId,
72
131
  runId: this.genId(),
@@ -75,9 +134,37 @@ export class CarbonAgUiAdapter {
75
134
  tools: this.opts.tools ?? [],
76
135
  context: this.opts.context ?? [],
77
136
  forwardedProps: this.opts.forwardedProps ?? {},
78
- 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
+ }
79
157
  };
80
- await this.runAndStream(input, {}, 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();
81
168
  }
82
169
  upsertMessage(msg) {
83
170
  const i = this.messages.findIndex((m) => m.id === msg.id);
@@ -95,7 +182,7 @@ export class CarbonAgUiAdapter {
95
182
  * into Carbon chunks on `instance`. Shared by the initial turn and by
96
183
  * `respondToInterrupt`, so a resumed continuation streams identically.
97
184
  */
98
- async runAndStream(input, options, instance, requestId) {
185
+ async runAndStream(input, options, instance, requestId, progress) {
99
186
  this.lastInstance = instance;
100
187
  const { runId } = input;
101
188
  const meta = { response_id: runId };
@@ -108,6 +195,8 @@ export class CarbonAgUiAdapter {
108
195
  // Chunk variants carry the id only on their first chunk; later chunks continue the current one.
109
196
  let lastChunkTextId;
110
197
  let lastChunkToolId;
198
+ // detectClarification is evaluated at most once per run (see RUN_FINISHED).
199
+ let clarificationChecked = false;
111
200
  // Reasoning text accumulates per messageId (THINKING_TEXT_* has none → fixed 'thinking'
112
201
  // key). One ReasoningStep per key at close; empties are dropped. Not streamed live.
113
202
  const THINKING_KEY = 'thinking';
@@ -180,6 +269,10 @@ export class CarbonAgUiAdapter {
180
269
  };
181
270
  try {
182
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;
183
276
  // IN seam (issue gmb9): validate against the AG-UI protocol schema before
184
277
  // switching. A malformed event is reported and skipped, NEVER thrown —
185
278
  // one bad event must not kill the stream. `validate: false` passes through.
@@ -276,8 +369,19 @@ export class CarbonAgUiAdapter {
276
369
  this.opts.onStateChange?.(this.state);
277
370
  break;
278
371
  case 'STATE_DELTA':
279
- this.state = applyJsonPatch(this.state, ev.delta);
280
- 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
+ }
281
385
  break;
282
386
  case 'ACTIVITY_SNAPSHOT':
283
387
  // A snapshot is authoritative; it replaces any prior activity for this messageId.
@@ -287,9 +391,19 @@ export class CarbonAgUiAdapter {
287
391
  break;
288
392
  case 'ACTIVITY_DELTA': {
289
393
  const entry = this.activities.get(ev.messageId) ?? { activityType: ev.activityType, content: {} };
290
- entry.content = applyJsonPatch(entry.content, ev.patch);
291
- this.activities.set(ev.messageId, entry);
292
- 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
+ }
293
407
  break;
294
408
  }
295
409
  case 'MESSAGES_SNAPSHOT': {
@@ -343,19 +457,45 @@ export class CarbonAgUiAdapter {
343
457
  // turn as a plain final_response, emit a user_defined decision item so
344
458
  // the host can render an approve/reject/edit card, and retain the
345
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.
346
464
  this.opts.onRunFinished?.({ outcome: ev.outcome, result: ev.result });
347
- const interrupt = firstInterrupt(ev.outcome);
348
- if (interrupt) {
349
- 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);
350
473
  const item = interruptItem(interrupt);
351
474
  items.push(item);
352
475
  await emit({ complete_item: item, streaming_metadata: meta });
353
476
  }
354
- else {
355
- // The only non-interrupt outcome is `{ type: 'success' }` (or an
356
- // absent outcome): a clean completion that carries no payload, so
357
- // the already-streamed content stands and there is nothing extra to
358
- // 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
+ }
359
499
  }
360
500
  break;
361
501
  }
@@ -372,7 +512,7 @@ export class CarbonAgUiAdapter {
372
512
  }
373
513
  break;
374
514
  default:
375
- // RUN_STARTED / STEP_* / RAW / other CUSTOM: surfaced via onEvent only.
515
+ // RUN_STARTED / STEP_* / SUBAGENT_* / RAW / other CUSTOM: surfaced via onEvent only.
376
516
  break;
377
517
  }
378
518
  }
@@ -424,12 +564,6 @@ function parseJsonLoose(s) {
424
564
  return s;
425
565
  }
426
566
  }
427
- /** The first interrupt in a RUN_FINISHED outcome, or undefined if not an interrupt. */
428
- function firstInterrupt(outcome) {
429
- if (outcome?.type !== 'interrupt')
430
- return undefined;
431
- return outcome.interrupts?.[0];
432
- }
433
567
  /** Build the Carbon `user_defined` decision-card item from an interrupt. */
434
568
  function interruptItem(interrupt) {
435
569
  const raw = interrupt.metadata?.langgraph?.raw ?? {};
package/dist/coverage.js CHANGED
@@ -23,6 +23,9 @@ export const AG_UI_COVERAGE = {
23
23
  RUN_ERROR: { status: 'handled', note: 'Thrown as AgUiRunError.' },
24
24
  STEP_STARTED: { status: 'observed', note: 'Lifecycle; onEvent only.' },
25
25
  STEP_FINISHED: { status: 'observed', note: 'Lifecycle; onEvent only.' },
26
+ SUBAGENT_STARTED: { status: 'observed', note: 'Lifecycle; onEvent only.' },
27
+ SUBAGENT_FINISHED: { status: 'observed', note: 'Lifecycle; onEvent only.' },
28
+ SUBAGENT_ERROR: { status: 'observed', note: 'Lifecycle; onEvent only (distinct from top-level RUN_ERROR, which throws).' },
26
29
  TEXT_MESSAGE_START: { status: 'handled', note: 'Opens a streamed text item.' },
27
30
  TEXT_MESSAGE_CONTENT: { status: 'handled', note: 'Partial text chunk.' },
28
31
  TEXT_MESSAGE_END: { status: 'handled', note: 'Completes the text item.' },
@@ -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.0",
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",
@@ -30,7 +30,7 @@
30
30
  "build": "tsc -p tsconfig.build.json"
31
31
  },
32
32
  "dependencies": {
33
- "@ag-ui/core": "0.0.58"
33
+ "@ag-ui/core": "0.0.59"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@carbon/ai-chat": "1.19.0",