@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 +81 -18
- package/dist/adapter.d.ts +66 -7
- package/dist/adapter.js +167 -33
- package/dist/coverage.js +3 -0
- package/dist/transport.d.ts +8 -2
- package/dist/transport.js +24 -8
- package/dist/types.d.ts +20 -0
- package/package.json +2 -2
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.
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
75
|
-
`InterruptDecisionData` (`kind: 'interrupt'`, `interruptId`,
|
|
76
|
-
`responseSchema`, `toolCallId`) so a host renderer can draw a
|
|
77
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
-
*
|
|
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
|
|
87
|
-
* streams the continuation back into
|
|
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
|
-
/**
|
|
24
|
-
|
|
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.
|
|
72
|
+
this.pendingInterrupts.clear();
|
|
73
|
+
this.decisions.clear();
|
|
74
|
+
this.resetGeneration += 1;
|
|
52
75
|
}
|
|
53
76
|
/**
|
|
54
|
-
*
|
|
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
|
|
57
|
-
* streams the continuation back into
|
|
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
|
|
64
|
-
if (
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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.' },
|
package/dist/transport.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
10
|
-
|
|
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
|
|
12
|
+
if (!res.ok)
|
|
13
13
|
throw new Error(`AG-UI request failed: HTTP ${res.status}`);
|
|
14
|
-
|
|
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
|
-
/**
|
|
18
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
33
|
+
"@ag-ui/core": "0.0.59"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@carbon/ai-chat": "1.19.0",
|