@hsb3/carbon-agui-adapter 0.1.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 ADDED
@@ -0,0 +1,102 @@
1
+ # carbon-agui-adapter
2
+
3
+ Drives IBM **Carbon AI Chat** (`@carbon/ai-chat`) from an **AG-UI** event stream. Zero runtime deps.
4
+
5
+ - Carbon `customSendMessage` → AG-UI `RunAgentInput` (thread + history + tools + state)
6
+ - 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
8
+ - Tool calls → Carbon **chain-of-thought** steps (`message_options.chain_of_thought`) with args/result/status
9
+ - 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
11
+
12
+ ## Run
13
+
14
+ ```bash
15
+ npm install
16
+ npm run check # typecheck + tests
17
+ npm run build # dist/
18
+ ```
19
+
20
+ ## Use
21
+
22
+ ```ts
23
+ import { createAgUiSendMessage, createSseRunner } from 'carbon-agui-adapter';
24
+
25
+ const config = {
26
+ messaging: {
27
+ customSendMessage: createAgUiSendMessage({
28
+ run: createSseRunner({ url: 'https://my-agent/run', headers: { authorization: 'Bearer …' } }),
29
+ tools: [], // AG-UI tool defs forwarded each run
30
+ onToolCall: (c) => ({ response_type: 'system', text: `⚙ ${c.name}` }), // optional live feedback; steps land in chain-of-thought regardless
31
+ onStateChange: (s) => console.log(s),
32
+ }),
33
+ },
34
+ };
35
+ ```
36
+
37
+ Need history/state/reset access? Use the class:
38
+
39
+ ```ts
40
+ const adapter = new CarbonAgUiAdapter({ run });
41
+ config.messaging.customSendMessage = adapter.sendMessage;
42
+ adapter.messages; adapter.state; adapter.reset();
43
+ ```
44
+
45
+ Using `@ag-ui/client` instead of raw SSE:
46
+
47
+ ```ts
48
+ import { HttpAgent } from '@ag-ui/client';
49
+ import { fromObservable } from 'carbon-agui-adapter';
50
+
51
+ const agent = new HttpAgent({ url: 'https://my-agent/run' });
52
+ const run = (input, { signal }) => fromObservable(agent.run(input), signal);
53
+ ```
54
+
55
+ ## Event mapping
56
+
57
+ | AG-UI | Carbon |
58
+ |---|---|
59
+ | `TEXT_MESSAGE_CONTENT` | `partial_item` (text delta) |
60
+ | `TEXT_MESSAGE_END` | `complete_item` (full text) + history |
61
+ | `TOOL_CALL_START/ARGS/END` | history; `chain_of_thought` step (`tool_name`, `request.args`); `complete_item` if `onToolCall` returns an item |
62
+ | `TOOL_CALL_RESULT` | history (`role: tool`); step `response.content` + `status: success` |
63
+ | `STATE_SNAPSHOT` / `STATE_DELTA` | `adapter.state` + `onStateChange` |
64
+ | `MESSAGES_SNAPSHOT` | replaces history |
65
+ | `RUN_ERROR` | throws `AgUiRunError` |
66
+ | `RUN_FINISHED` (no outcome) / stream end / abort | `final_response` (aborted text gets `stream_stopped: true`) |
67
+ | `RUN_FINISHED` with `outcome.type === 'interrupt'` | `user_defined` decision item (`InterruptDecisionData`); interrupt retained for resume |
68
+ | `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 |
70
+
71
+ ## HITL: interrupt → approve/reject/edit → resume
72
+
73
+ 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:
78
+
79
+ ```ts
80
+ await adapter.respondToInterrupt(decision, instance);
81
+ // decision: { type: 'approve' } | { type: 'edit', args } | { type: 'reject' }
82
+ ```
83
+
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
86
+ mapping (see `docs/hitl-interrupt-resume.md`):
87
+
88
+ | Decision | `resume[]` entry |
89
+ |---|---|
90
+ | `approve` | `{ status: 'resolved', payload: { approved: true } }` |
91
+ | `edit` | `{ status: 'resolved', payload: { approved: true, args } }` |
92
+ | `reject` | `{ status: 'cancelled', payload: null }` |
93
+
94
+ Register the card with the web component's `renderUserDefinedResponse` and read
95
+ `state.messageItem.user_defined`; see `examples/langgraph-carbon/web/src/main.ts`.
96
+
97
+ ## Notes
98
+
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`.
100
+ - 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
+ - `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`.
@@ -0,0 +1,104 @@
1
+ import type { AgUiContext, AgUiEvent, AgUiMessage, AgUiRunner, AgUiTool, CarbonChatInstanceLike, CarbonCustomSendMessage, CarbonGenericItem, CarbonMessageFeedbackOptions, CarbonMessageRequest, Decision, Interrupt, JsonPatchOp, RunAgentInput, RunOutcome } from './types.js';
2
+ export interface ToolCallInfo {
3
+ id: string;
4
+ name: string;
5
+ /** Raw JSON string, concatenated from TOOL_CALL_ARGS deltas. */
6
+ args: string;
7
+ parentMessageId?: string;
8
+ }
9
+ export interface AdapterOptions {
10
+ run: AgUiRunner;
11
+ threadId?: string;
12
+ tools?: AgUiTool[];
13
+ context?: AgUiContext[];
14
+ initialState?: unknown;
15
+ forwardedProps?: unknown;
16
+ idGenerator?: () => string;
17
+ /** Every AG-UI event, before translation. */
18
+ onEvent?: (event: AgUiEvent) => void;
19
+ onStateChange?: (state: unknown) => void;
20
+ /** Called after each ACTIVITY_SNAPSHOT/ACTIVITY_DELTA with the reconciled progress/status content. */
21
+ onActivity?: (activityType: string, content: Record<string, unknown>, messageId: string) => void;
22
+ /** Tool calls are rendered as Carbon chain-of-thought steps on the final response. Set false to disable. */
23
+ chainOfThought?: boolean;
24
+ /** Called on TOOL_CALL_END. Optionally return a Carbon item to render immediately (e.g. a system message). */
25
+ onToolCall?: (call: ToolCallInfo) => CarbonGenericItem | null | void | Promise<CarbonGenericItem | null | void>;
26
+ /**
27
+ * Called once when RUN_FINISHED is processed, with its `outcome` and any legacy
28
+ * top-level `result`. A `success` outcome carries no renderable payload, and a
29
+ * `result` has no obvious Carbon target — this callback is how a host observes
30
+ * a returned value instead of being blind to it. The interrupt path renders its
31
+ * own decision card; this still fires so a host sees every completion.
32
+ */
33
+ onRunFinished?: (info: {
34
+ outcome?: RunOutcome;
35
+ result?: unknown;
36
+ }) => void;
37
+ /**
38
+ * When set, Carbon feedback (thumbs) config attached to each assistant TEXT
39
+ * response item so the UI renders the controls. Feedback attaches per-item
40
+ * (`message_item_options.feedback`), never per-response, and only to text
41
+ * items — not decision cards, tool renders, or CUSTOM-dispatched items.
42
+ * Capturing the click is Carbon→host via the bus and out of this adapter's scope.
43
+ */
44
+ feedback?: CarbonMessageFeedbackOptions;
45
+ /**
46
+ * Runtime validation at the AG-UI/Carbon trust boundaries (issue gmb9).
47
+ * Default TRUE: incoming runner events are validated against the AG-UI
48
+ * protocol schema and outgoing CUSTOM Carbon items against the known-type +
49
+ * required-field checks; malformed ones are skipped (never thrown), so one
50
+ * bad event cannot kill the stream. Set false to skip validation entirely
51
+ * (blind cast for events, allowlist-only for items — the pre-gmb9 behavior).
52
+ */
53
+ validate?: boolean;
54
+ /** Called when an incoming AG-UI event fails schema validation (then skipped). */
55
+ onInvalidEvent?: (raw: unknown, error: string) => void;
56
+ /** Called when an outgoing CUSTOM Carbon item fails validation (then skipped). */
57
+ onInvalidItem?: (raw: unknown, reason: string) => void;
58
+ }
59
+ export declare class AgUiRunError extends Error {
60
+ readonly code?: string | undefined;
61
+ constructor(message: string, code?: string | undefined);
62
+ }
63
+ export declare class CarbonAgUiAdapter {
64
+ private readonly opts;
65
+ readonly threadId: string;
66
+ state: unknown;
67
+ /** Progress/status state per messageId, reconciled from ACTIVITY_SNAPSHOT/ACTIVITY_DELTA. */
68
+ activities: Map<string, {
69
+ activityType: string;
70
+ content: Record<string, unknown>;
71
+ }>;
72
+ messages: AgUiMessage[];
73
+ /** The interrupt awaiting a decision, or undefined when none is pending. */
74
+ pendingInterrupt?: Interrupt;
75
+ private readonly genId;
76
+ /** The Carbon instance from the most recent run, reused for resume streaming. */
77
+ private lastInstance?;
78
+ constructor(opts: AdapterOptions);
79
+ /** Pass as `messaging.customSendMessage` in Carbon's PublicConfig. */
80
+ readonly sendMessage: CarbonCustomSendMessage;
81
+ buildRunInput(request: CarbonMessageRequest): RunAgentInput;
82
+ reset(): void;
83
+ /**
84
+ * Resume the interrupted run with the user's decision. Builds a resume
85
+ * `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.
88
+ *
89
+ * @param instance The Carbon instance to stream into. Defaults to the one
90
+ * from the most recent run (e.g. the interrupted turn).
91
+ */
92
+ respondToInterrupt(decision: Decision, instance?: CarbonChatInstanceLike): Promise<void>;
93
+ private upsertMessage;
94
+ private handle;
95
+ /**
96
+ * Run one `RunAgentInput` through the runner and translate its AG-UI events
97
+ * into Carbon chunks on `instance`. Shared by the initial turn and by
98
+ * `respondToInterrupt`, so a resumed continuation streams identically.
99
+ */
100
+ private runAndStream;
101
+ }
102
+ /** Convenience: returns just the customSendMessage function. */
103
+ export declare function createAgUiSendMessage(opts: AdapterOptions): CarbonCustomSendMessage;
104
+ export declare function applyJsonPatch<T>(doc: T, ops: JsonPatchOp[]): T;