@jxsuite/ai 0.35.0 → 0.36.2

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.35.0",
3
+ "version": "0.36.2",
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,10 +28,10 @@
28
28
  "upgrade": "bunx npm-check-updates -u && bun install"
29
29
  },
30
30
  "dependencies": {
31
- "@jxsuite/protocol": "^1.1.0",
31
+ "@jxsuite/protocol": "^1.2.0",
32
32
  "@vue/reactivity": "3.5.41"
33
33
  },
34
34
  "devDependencies": {
35
- "@happy-dom/global-registrator": "^20.11.2"
35
+ "@happy-dom/global-registrator": "^20.11.6"
36
36
  }
37
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,17 +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
16
  import type { ProblemDetails } from "@jxsuite/protocol";
13
17
 
14
- type StreamEvent =
18
+ export type StreamEvent =
15
19
  | StreamDeltaEvent
16
20
  | StreamToolCallStartEvent
17
21
  | StreamToolCallDeltaEvent
@@ -19,29 +23,29 @@ type StreamEvent =
19
23
  | StreamDoneEvent
20
24
  | StreamErrorEvent;
21
25
 
22
- interface StreamDeltaEvent {
26
+ export interface StreamDeltaEvent {
23
27
  type: "delta";
24
28
  content: string;
25
29
  }
26
30
 
27
- interface StreamToolCallStartEvent {
31
+ export interface StreamToolCallStartEvent {
28
32
  type: "tool_call_start";
29
33
  id: string;
30
34
  name: string;
31
35
  }
32
36
 
33
- interface StreamToolCallDeltaEvent {
37
+ export interface StreamToolCallDeltaEvent {
34
38
  type: "tool_call_delta";
35
39
  id: string;
36
40
  args: string;
37
41
  }
38
42
 
39
- interface StreamToolCallEndEvent {
43
+ export interface StreamToolCallEndEvent {
40
44
  type: "tool_call_end";
41
45
  id: string;
42
46
  }
43
47
 
44
- interface StreamDoneEvent {
48
+ export interface StreamDoneEvent {
45
49
  type: "done";
46
50
  stopReason: string;
47
51
  }
@@ -56,7 +60,7 @@ interface StreamDoneEvent {
56
60
  * half (a `type` a client can key on), `message` stays the human half every existing reader already
57
61
  * shows, and `code` remains for the providers that send one.
58
62
  */
59
- interface StreamErrorEvent {
63
+ export interface StreamErrorEvent {
60
64
  type: "error";
61
65
  message: string;
62
66
  code?: string;
@@ -162,17 +166,19 @@ export const STREAM_EVENT_TYPES = {
162
166
  * near-deterministic eval runs.
163
167
  * @returns {StreamingClient}
164
168
  */
169
+ export interface OpenAIStreamingClientOptions {
170
+ baseUrl: string;
171
+ apiKey: string;
172
+ model?: string;
173
+ temperature?: number | undefined;
174
+ }
175
+
165
176
  export function createOpenAIStreamingClient({
166
177
  baseUrl,
167
178
  apiKey,
168
179
  model = "gpt-4o",
169
180
  temperature,
170
- }: {
171
- baseUrl: string;
172
- apiKey: string;
173
- model?: string;
174
- temperature?: number | undefined;
175
- }): StreamingClient {
181
+ }: OpenAIStreamingClientOptions): StreamingClient {
176
182
  /**
177
183
  * @param {object[]} messages
178
184
  * @param {object[]} tools
@@ -398,10 +404,14 @@ export function createOpenAIStreamingClient({
398
404
  * @param {string} _opts.apiKey
399
405
  * @returns {StreamingClient}
400
406
  */
401
- export function createAnthropicStreamingClient(_opts: {
407
+ export interface AnthropicStreamingClientOptions {
402
408
  baseUrl: string;
403
409
  apiKey: string;
404
- }): StreamingClient {
410
+ }
411
+
412
+ export function createAnthropicStreamingClient(
413
+ _opts: AnthropicStreamingClientOptions,
414
+ ): StreamingClient {
405
415
  /**
406
416
  * @yields {StreamEvent} Normalized stream events.
407
417
  * @returns {AsyncGenerator<StreamEvent>}
@@ -431,17 +441,19 @@ export function createAnthropicStreamingClient(_opts: {
431
441
  * @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
432
442
  * @returns {StreamingClient}
433
443
  */
444
+ export interface ProxyStreamingClientOptions {
445
+ chatUrl: string;
446
+ model?: string;
447
+ apiKey?: string | undefined;
448
+ baseUrl?: string | undefined;
449
+ }
450
+
434
451
  export function createProxyStreamingClient({
435
452
  chatUrl,
436
453
  model = "gpt-4o",
437
454
  apiKey,
438
455
  baseUrl,
439
- }: {
440
- chatUrl: string;
441
- model?: string;
442
- apiKey?: string | undefined;
443
- baseUrl?: string | undefined;
444
- }): StreamingClient {
456
+ }: ProxyStreamingClientOptions): StreamingClient {
445
457
  /**
446
458
  * @param {object[]} messages
447
459
  * @param {object[]} tools