@jxsuite/ai 0.34.0 → 0.36.1

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,177 @@
1
+ # @jxsuite/ai
2
+
3
+ The provider-agnostic substrate Jx Studio's assistant is built on: a streaming LLM client
4
+ abstraction that normalizes provider SSE into six event shapes, a tool registry that speaks OpenAI
5
+ function-calling, and a reactive chat store. It has no Jx-domain dependencies — only a type-only
6
+ import of `ProblemDetails` from `@jxsuite/protocol` and `@vue/reactivity` — and its only platform
7
+ APIs are `fetch`, `TextDecoder` and `AbortSignal`, so the same modules run in the browser and in
8
+ Bun/Node.
9
+
10
+ Governing spec: [`specs/ai.md`](../../specs/ai.md) §1-§2 (Status: Partial). User-facing
11
+ documentation for the Studio feature lives at [`docs/studio/ai.md`](../../docs/studio/ai.md).
12
+
13
+ | Entrypoint | Exports |
14
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, `STREAM_EVENT_TYPES`, and the streaming types |
16
+ | `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its six members, the three factories and their option types, `STREAM_EVENT_TYPES` |
17
+ | `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, and their types |
18
+ | `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `Message`, `ToolCallRecord`, `ChatState`, `MessageRole` |
19
+
20
+ `toolSuccess` / `toolError` are **not** re-exported by the barrel — import them from
21
+ `@jxsuite/ai/tools`. Importing the subpaths rather than the root is also what makes the package
22
+ tree-shakeable.
23
+
24
+ ## The streaming client
25
+
26
+ `StreamingClient` is one method:
27
+
28
+ ```ts
29
+ streamChat(messages: object[], tools: object[], systemPrompt: string, signal: AbortSignal)
30
+ : AsyncGenerator<StreamEvent>;
31
+ ```
32
+
33
+ Every implementation yields the same discriminated union — `delta {content}`,
34
+ `tool_call_start {id, name}`, `tool_call_delta {id, args}`, `tool_call_end {id}`,
35
+ `done {stopReason}`, `error {message, code?, problem?}` — whose type strings are also published as
36
+ `STREAM_EVENT_TYPES`. This union is the contract a third-party Studio backend must satisfy for the
37
+ `ai/chat` route (see [`packages/protocol/README.md`](../protocol/README.md)); `@jxsuite/server`
38
+ emits the same format without depending on this package. `StreamEvent` and each member are
39
+ exported, so an implementer can import the type rather than reconstruct it.
40
+
41
+ ```ts
42
+ import { createProxyStreamingClient } from "@jxsuite/ai";
43
+
44
+ const client = createProxyStreamingClient({
45
+ chatUrl: "/__studio/ai/chat",
46
+ model: "gpt-4o",
47
+ });
48
+ for await (const event of client.streamChat(messages, tools, systemPrompt, signal)) {
49
+ switch (event.type) {
50
+ /* … */
51
+ }
52
+ }
53
+ ```
54
+
55
+ - **`createOpenAIStreamingClient({ baseUrl, apiKey, model = "gpt-4o", temperature })`** POSTs to
56
+ `${baseUrl}/chat/completions` with `Authorization: Bearer`, `stream: true`,
57
+ `stream_options: { include_usage: true }`, and `{ role: "system", content: systemPrompt }`
58
+ prepended to your messages. A non-empty `tools` array also sets `tool_choice: "auto"` and
59
+ `parallel_tool_calls: true`; an empty one sends no `tools` key. `temperature` is forwarded **only
60
+ when defined**, because reasoning models (GPT-5.x, o-series) reject a custom temperature — a
61
+ provider behaviour recorded in the source comment, not covered by a test here. It does
62
+ its own SSE framing and reassembles fragmented tool-call argument JSON keyed by the provider's
63
+ `delta.tool_calls[].index`.
64
+ - **`createAnthropicStreamingClient({ baseUrl, apiKey })`** is a stub. It makes no network call and
65
+ yields exactly one `error` event with `code: "NOT_IMPLEMENTED"`. Only the OpenAI-compatible path
66
+ ships (`specs/ai.md` §2).
67
+ - **`createProxyStreamingClient({ chatUrl, model = "gpt-4o", apiKey, baseUrl })`** POSTs
68
+ `{ messages, tools, systemPrompt, model }` to an endpoint that already speaks the normalized
69
+ format — the dev/desktop server's `/__studio/ai/chat`. It handles no credentials of its own;
70
+ `apiKey` and `baseUrl` are passed through as the `X-Api-Key` and `X-Api-Base-URL` headers only
71
+ when truthy, and the proxy owns the provider key. It is a pass-through, **not** a translator: it
72
+ re-yields whatever parses out of each `data:` line and returns at the first `done` or `error`, so
73
+ frames after `done` are dropped.
74
+
75
+ Three behaviours are easy to get wrong when consuming the stream:
76
+
77
+ 1. **Cancellation is a `done`, not an `error`.** An `AbortError` from `fetch` or mid-read becomes
78
+ `{ type: "done", stopReason: "cancelled" }`. Treating abort as failure paints a user's Stop
79
+ button as a crash. The OpenAI client's other stop reasons are `"stop"`, `"tool_calls"` and
80
+ `"length"`.
81
+ 2. **The OpenAI client drains pending tool calls on every termination path.** `[DONE]`, a
82
+ `finish_reason` of `stop`, `length` or `tool_calls` (any other value is ignored and the stream
83
+ continues), and a body that simply closes all emit `tool_call_end` for anything still open, then
84
+ exactly one `done`. The proxy client tracks nothing of its own, so a body that closes without a
85
+ `done` frame ends its generator with no `done` at all.
86
+ 3. **`error` frames may carry an RFC 9457 `problem` beside the human `message`,** because the
87
+ response has already begun with a 200 and the status can no longer change (a committed standards
88
+ row in `specs/ai.md` §5). The field is optional and no client in this package sets it —
89
+ `@jxsuite/server`'s proxy is what populates it and `createProxyStreamingClient` passes it
90
+ through. Read `problem.type` for machine dispatch when it is present; keep showing `message`.
91
+
92
+ ## The tool registry
93
+
94
+ `createToolRegistry()` returns `{ register, list, listForLLM, validate, execute, getDefinition }`.
95
+ `listForLLM()` emits `{ type: "function", function: { name, description, parameters } }`, ready to
96
+ hand straight to `streamChat`'s `tools` argument.
97
+
98
+ ```ts
99
+ import { createToolDefinition, createToolRegistry, toolSuccess } from "@jxsuite/ai/tools";
100
+
101
+ const registry = createToolRegistry();
102
+ registry.register(
103
+ createToolDefinition({
104
+ name: "set_property",
105
+ description: "Set a property on a node",
106
+ parameters: {
107
+ type: "object",
108
+ properties: { path: { type: "string" } },
109
+ required: ["path"],
110
+ },
111
+ execute: (args) => toolSuccess(args, "set"),
112
+ }),
113
+ );
114
+ ```
115
+
116
+ - **`strict` and `llmStrict` are unrelated despite the names.** `strict` (default `true`) drives the
117
+ registry's own argument validation. `llmStrict` (default `false`) is the only one that reaches the
118
+ wire, adding OpenAI's `strict: true` to the emitted function schema. OpenAI strict mode demands
119
+ `additionalProperties: false` and every property in `required`, which Jx's own tools deliberately
120
+ violate — GPT-5.x rejects such a request outright (again, committed rationale rather than a test).
121
+ - **`validate()` is a lightweight structural check, not a JSON Schema validator.** It reads only
122
+ `type`, `properties` and `required`: nested schemas, `enum`, `pattern`, `minimum` and every other
123
+ keyword are ignored. Unknown properties are ignored too (the model may send extra); a required key
124
+ that is present but `null`/`undefined` counts as missing; numeric strings coerce, so `"42"`
125
+ validates against `number`. `strict: false`, or a schema without `properties`, skips it entirely.
126
+ - **`execute()` never throws.** An unknown tool, a validation failure, and an exception inside the
127
+ tool all return `{ success: false, error }` — which is what lets an agent loop feed the failure
128
+ back to the model and self-correct. It calls `this.validate(...)`, so it is `this`-bound: do not
129
+ destructure it off the registry.
130
+ - Re-registering a name `console.warn`s and overwrites.
131
+
132
+ To vary the tool set per turn, wrap rather than mutate — Studio's `createGatedToolRegistry` returns
133
+ a `ToolRegistry` that filters `list()`/`listForLLM()` through per-tool predicates
134
+ (`packages/studio/src/services/gated-registry.ts`).
135
+
136
+ ## The reactive chat store
137
+
138
+ `createChatState(options?: { model?: string })` returns a `@vue/reactivity` `reactive()` store —
139
+ `messages`, `status`, `streamingContent`, `pendingToolCalls`, `error`, `model`, `tokenCount`,
140
+ `contextWarning` — with its mutators `Object.assign`ed onto the same proxy, so the returned value is
141
+ both the state and the API. The model falls back to `"gpt-4o"` via `||`, so an empty string takes
142
+ the default too. `status` is `"idle" | "streaming" | "error"`; `toMessagesArray()`
143
+ serializes back to OpenAI wire shape (assistant `tool_calls`, `tool` messages with `tool_call_id`,
144
+ everything else `{ role, content }`).
145
+
146
+ Two ordering rules inside `beginAssistantTurn` are load-bearing, and any reimplementation must keep
147
+ them: `status` is set to `"streaming"` **before** the placeholder message is pushed (the push itself
148
+ triggers effects, and a reader seeing `"idle"` renders the empty placeholder as a finished message),
149
+ and the pushed object is immediately re-read back through the proxy (`store.messages.at(-1)`) —
150
+ mutating the raw object would notify nothing. A regression test asserts that an `effect()` reading
151
+ `messages.at(-1).content` re-runs on every `appendDelta`.
152
+
153
+ Further surprises:
154
+
155
+ - **`sendMessage(text)` produces two messages**, not one: the user message plus an empty assistant
156
+ placeholder, and status flips to `"streaming"`. There is no way to append a bare user message with
157
+ it. `beginAssistantTurn()` exists separately so an agent loop can open the next round after
158
+ pushing tool results, with no new user message.
159
+ - **`sendMessage` / `beginAssistantTurn` are no-ops while streaming**; `appendDelta` /
160
+ `appendToolCallStart` are no-ops while not streaming.
161
+ - **`setError` and `cancelStream` delete the partial streaming message**, because it may hold
162
+ incomplete `tool_calls` that would poison history on the next send. That deletion is why
163
+ `specs/ai.md` §3.2 forbids reporting an exhausted tool-call budget as an error: the error path
164
+ destroys the only account of the edits that did land.
165
+ - **`appendToolCallEnd(id)` is an intentional no-op.** Arguments are already fully accumulated; the
166
+ caller parses the JSON and executes the tool.
167
+ - Message ids come from a module-level counter (`msg_${Date.now()}_${n}`), not a UUID.
168
+
169
+ ## Versioning
170
+
171
+ Published to npm as `@jxsuite/ai` — TypeScript source, like every published `@jxsuite` library,
172
+ following the monorepo's release train; every `exports` subpath resolves to `./src/*.ts`, so a
173
+ consumer must be
174
+ able to compile TypeScript out of `node_modules`. Within the monorepo, `@jxsuite/studio` depends on
175
+ it via `workspace:^` and is its only consumer. `@vue/reactivity` is pinned to an **exact** version,
176
+ matching `@jxsuite/studio`, `@jxsuite/runtime` and `@jxsuite/compiler`: reactive proxies from one
177
+ copy do not track in effects from another, and the failure is silent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.34.0",
3
+ "version": "0.36.1",
4
4
  "description": "AI infrastructure for Jx Suite — streaming LLM client abstraction, tool registry, and reactive chat state",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,9 +28,10 @@
28
28
  "upgrade": "bunx npm-check-updates -u && bun install"
29
29
  },
30
30
  "dependencies": {
31
- "@vue/reactivity": "3.5.40"
31
+ "@jxsuite/protocol": "^1.1.2",
32
+ "@vue/reactivity": "3.5.41"
32
33
  },
33
34
  "devDependencies": {
34
- "@happy-dom/global-registrator": "^20.11.0"
35
+ "@happy-dom/global-registrator": "^20.11.6"
35
36
  }
36
37
  }
package/src/index.ts CHANGED
@@ -19,3 +19,17 @@ export {
19
19
  createAnthropicStreamingClient,
20
20
  createProxyStreamingClient,
21
21
  } from "./streaming-client.js";
22
+
23
+ export type {
24
+ AnthropicStreamingClientOptions,
25
+ OpenAIStreamingClientOptions,
26
+ ProxyStreamingClientOptions,
27
+ StreamDeltaEvent,
28
+ StreamDoneEvent,
29
+ StreamErrorEvent,
30
+ StreamEvent,
31
+ StreamingClient,
32
+ StreamToolCallDeltaEvent,
33
+ StreamToolCallEndEvent,
34
+ StreamToolCallStartEvent,
35
+ } from "./streaming-client.js";
@@ -1,15 +1,21 @@
1
1
  /**
2
2
  * Streaming-client.js — Provider-agnostic streaming LLM client abstraction
3
3
  *
4
- * Defines the StreamingClient interface (as JSDoc typedefs), the StreamEvent union type,
4
+ * Defines the StreamingClient interface, the StreamEvent union every implementation yields,
5
5
  * and concrete implementations for OpenAI and Anthropic. Designed upfront so switching
6
6
  * providers is a new implementation, not a refactor.
7
7
  *
8
+ * The union and its six members are exported because they are the contract a third-party Studio
9
+ * backend implements for the `ai/chat` route, not an internal detail — see the docs page below.
10
+ *
8
11
  * @license MIT
9
12
  * @module @jxsuite/ai/streaming-client
13
+ * @docs extending/embedding/backend-protocol
10
14
  */
11
15
 
12
- type StreamEvent =
16
+ import type { ProblemDetails } from "@jxsuite/protocol";
17
+
18
+ export type StreamEvent =
13
19
  | StreamDeltaEvent
14
20
  | StreamToolCallStartEvent
15
21
  | StreamToolCallDeltaEvent
@@ -17,37 +23,49 @@ type StreamEvent =
17
23
  | StreamDoneEvent
18
24
  | StreamErrorEvent;
19
25
 
20
- interface StreamDeltaEvent {
26
+ export interface StreamDeltaEvent {
21
27
  type: "delta";
22
28
  content: string;
23
29
  }
24
30
 
25
- interface StreamToolCallStartEvent {
31
+ export interface StreamToolCallStartEvent {
26
32
  type: "tool_call_start";
27
33
  id: string;
28
34
  name: string;
29
35
  }
30
36
 
31
- interface StreamToolCallDeltaEvent {
37
+ export interface StreamToolCallDeltaEvent {
32
38
  type: "tool_call_delta";
33
39
  id: string;
34
40
  args: string;
35
41
  }
36
42
 
37
- interface StreamToolCallEndEvent {
43
+ export interface StreamToolCallEndEvent {
38
44
  type: "tool_call_end";
39
45
  id: string;
40
46
  }
41
47
 
42
- interface StreamDoneEvent {
48
+ export interface StreamDoneEvent {
43
49
  type: "done";
44
50
  stopReason: string;
45
51
  }
46
52
 
47
- interface StreamErrorEvent {
53
+ /**
54
+ * A failure that arrived mid-stream.
55
+ *
56
+ * **This is the one place RFC 9457 could not simply replace what was here**, and the reason is
57
+ * structural: a problem document is a _response body_, and by the time this frame is written the
58
+ * response has already begun with a 200. Nothing can change the status any more. So the adoption is
59
+ * that the frame CARRIES a problem rather than being replaced by one — `problem` is the machine
60
+ * half (a `type` a client can key on), `message` stays the human half every existing reader already
61
+ * shows, and `code` remains for the providers that send one.
62
+ */
63
+ export interface StreamErrorEvent {
48
64
  type: "error";
49
65
  message: string;
50
66
  code?: string;
67
+ /** RFC 9457 problem document describing the failure, when the producer knows its type. */
68
+ problem?: ProblemDetails;
51
69
  }
52
70
 
53
71
  /**
@@ -148,17 +166,19 @@ export const STREAM_EVENT_TYPES = {
148
166
  * near-deterministic eval runs.
149
167
  * @returns {StreamingClient}
150
168
  */
169
+ export interface OpenAIStreamingClientOptions {
170
+ baseUrl: string;
171
+ apiKey: string;
172
+ model?: string;
173
+ temperature?: number | undefined;
174
+ }
175
+
151
176
  export function createOpenAIStreamingClient({
152
177
  baseUrl,
153
178
  apiKey,
154
179
  model = "gpt-4o",
155
180
  temperature,
156
- }: {
157
- baseUrl: string;
158
- apiKey: string;
159
- model?: string;
160
- temperature?: number | undefined;
161
- }): StreamingClient {
181
+ }: OpenAIStreamingClientOptions): StreamingClient {
162
182
  /**
163
183
  * @param {object[]} messages
164
184
  * @param {object[]} tools
@@ -384,13 +404,14 @@ export function createOpenAIStreamingClient({
384
404
  * @param {string} _opts.apiKey
385
405
  * @returns {StreamingClient}
386
406
  */
387
- export function createAnthropicStreamingClient({
388
- baseUrl: _,
389
- apiKey: __,
390
- }: {
407
+ export interface AnthropicStreamingClientOptions {
391
408
  baseUrl: string;
392
409
  apiKey: string;
393
- }): StreamingClient {
410
+ }
411
+
412
+ export function createAnthropicStreamingClient(
413
+ _opts: AnthropicStreamingClientOptions,
414
+ ): StreamingClient {
394
415
  /**
395
416
  * @yields {StreamEvent} Normalized stream events.
396
417
  * @returns {AsyncGenerator<StreamEvent>}
@@ -420,17 +441,19 @@ export function createAnthropicStreamingClient({
420
441
  * @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
421
442
  * @returns {StreamingClient}
422
443
  */
444
+ export interface ProxyStreamingClientOptions {
445
+ chatUrl: string;
446
+ model?: string;
447
+ apiKey?: string | undefined;
448
+ baseUrl?: string | undefined;
449
+ }
450
+
423
451
  export function createProxyStreamingClient({
424
452
  chatUrl,
425
453
  model = "gpt-4o",
426
454
  apiKey,
427
455
  baseUrl,
428
- }: {
429
- chatUrl: string;
430
- model?: string;
431
- apiKey?: string | undefined;
432
- baseUrl?: string | undefined;
433
- }): StreamingClient {
456
+ }: ProxyStreamingClientOptions): StreamingClient {
434
457
  /**
435
458
  * @param {object[]} messages
436
459
  * @param {object[]} tools