@jxsuite/ai 0.39.0 → 0.39.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Kevin Shenk
3
+ Copyright (c) 2026 Avunu LLC and Jx Suite contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.39.0",
3
+ "version": "0.39.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": {
@@ -18,7 +18,9 @@
18
18
  "./streaming-client": "./src/streaming-client.ts",
19
19
  "./tools": "./src/tools.ts",
20
20
  "./chat-state": "./src/chat-state.ts",
21
- "./gateway": "./src/gateway/index.ts"
21
+ "./gateway": "./src/gateway/index.ts",
22
+ "./harness": "./src/harness/index.ts",
23
+ "./messages": "./src/messages/index.ts"
22
24
  },
23
25
  "publishConfig": {
24
26
  "provenance": true
@@ -29,11 +31,11 @@
29
31
  "upgrade": "bun update --latest && bun install"
30
32
  },
31
33
  "dependencies": {
32
- "@jxsuite/protocol": "^2.5.0",
34
+ "@jxsuite/protocol": "^2.6.0",
33
35
  "@vue/reactivity": "3.5.43"
34
36
  },
35
37
  "devDependencies": {
36
- "@cloudflare/workers-types": "^5.20260928.1",
38
+ "@cloudflare/workers-types": "^5.20261002.1",
37
39
  "@happy-dom/global-registrator": "^20.14.5"
38
40
  }
39
41
  }
package/src/chat-state.ts CHANGED
@@ -1,45 +1,32 @@
1
1
  /**
2
2
  * Chat-state.js — Provider-agnostic reactive chat state management
3
3
  *
4
- * Manages the lifecycle of a chat conversation: messages, streaming status,
5
- * tool calls, and errors. Built on @vue/reactivity for fine-grained updates.
6
- * No Studio or Jx dependencies — reusable in any chat UI context.
4
+ * Manages the lifecycle of a chat conversation: messages, streaming status, tool calls, and errors.
5
+ * Built on @vue/reactivity for fine-grained updates. No Studio or Jx dependencies — reusable in any
6
+ * chat UI context.
7
7
  *
8
- * @license MIT
9
8
  * @module @jxsuite/ai/chat-state
9
+ * @license MIT
10
10
  */
11
11
 
12
12
  import { reactive } from "@vue/reactivity";
13
13
 
14
+ import { nextMessageId } from "./message-id.ts";
15
+ import { toChatMessages } from "./messages/convert.ts";
16
+ import { toOpenAIMessages } from "./messages/openai.ts";
17
+ import type { LiveMessage, LiveToolCall, OpenAIMessage, Role } from "./messages/types.ts";
14
18
  import type { StreamUsageEvent } from "./streaming-client.ts";
15
19
  import type { ToolResult } from "./tools.ts";
16
20
 
17
21
  export type ChatState = "idle" | "streaming" | "error";
18
22
 
19
- export type MessageRole = "user" | "assistant" | "system" | "tool";
23
+ export type MessageRole = Role;
20
24
 
21
- export interface Message {
22
- id: string;
23
- role: MessageRole;
24
- content: string;
25
- /**
26
- * The turn's chain-of-thought, when the provider streamed one (`reasoning_content` /
27
- * `reasoning`). Kept because a thinking model's own history is not optional: DeepSeek's thinking
28
- * mode REQUIRES every prior turn's `reasoning_content` back on any request that carries `tools`,
29
- * which is every request the agent loop makes. See {@link toMessagesArray}.
30
- */
31
- reasoningContent?: string;
32
- toolCalls?: ToolCallRecord[];
33
- toolCallId?: string;
34
- timestamp: number;
35
- }
25
+ /** A transcript message: the v1 shape `@jxsuite/ai/messages` reads (specs/ai.md §2.3). */
26
+ export type Message = LiveMessage;
36
27
 
37
- export interface ToolCallRecord {
38
- id: string;
39
- name: string;
40
- arguments: string;
41
- result?: ToolResult | null;
42
- }
28
+ /** A tool call on a transcript message, carrying the outcome its chip renders. */
29
+ export type ToolCallRecord = LiveToolCall;
43
30
 
44
31
  /**
45
32
  * The provider's own count for the last request, and where in the transcript it was taken.
@@ -86,13 +73,8 @@ export interface ChatStore {
86
73
 
87
74
  // ─── Helpers ─────────────────────────────────────────────────────────────────
88
75
 
89
- let _idCounter = 0;
90
-
91
- /** @returns {string} */
92
- function uid() {
93
- _idCounter += 1;
94
- return `msg_${Date.now()}_${_idCounter}`;
95
- }
76
+ /** A fresh message id, from the counter the harness shares. */
77
+ const uid = nextMessageId;
96
78
 
97
79
  /** A token count a budget can use: a finite, non-negative number. */
98
80
  function isCount(value: unknown): value is number {
@@ -126,7 +108,7 @@ function isCount(value: unknown): value is number {
126
108
  * recordUsage: (usage: StreamUsageEvent, opts?: { systemTokens?: number }) => void;
127
109
  * clearUsage: () => void;
128
110
  * setContextWarning: (warning: boolean) => void;
129
- * toMessagesArray: () => object[];
111
+ * toMessagesArray: () => OpenAIMessage[];
130
112
  * }}
131
113
  */
132
114
  export function createChatState(opts: { model?: string } = {}) {
@@ -434,11 +416,11 @@ export function createChatState(opts: { model?: string } = {}) {
434
416
  }
435
417
  const { type: _type, ...counts } = usage;
436
418
  const counted = _streamingMessage ?? store.messages.at(-1);
437
- /* Replayed means SENT: `toMessagesArray` drops an assistant turn carrying neither text nor
438
- tool calls, reasoning and all, so a turn that only thought is not replayed either. */
439
- const replayed = Boolean(
440
- counted?.reasoningContent && (counted.content || (counted.toolCalls?.length ?? 0) > 0),
441
- );
419
+ /* Replayed means SENT, so the projection that builds the request is asked: it drops an
420
+ assistant turn carrying neither text nor tool calls, reasoning and all, so a turn that only
421
+ thought is not replayed either. */
422
+ const [sent] = counted ? toOpenAIMessages(toChatMessages([counted])) : [];
423
+ const replayed = sent?.role === "assistant" && Boolean(sent.reasoning_content);
442
424
  const unreplayed = replayed || !isCount(usage.reasoningTokens) ? 0 : usage.reasoningTokens;
443
425
  const contextTokens = Math.max(0, usage.inputTokens + usage.outputTokens - unreplayed);
444
426
  store.usage = {
@@ -466,74 +448,14 @@ export function createChatState(opts: { model?: string } = {}) {
466
448
  }
467
449
 
468
450
  /**
469
- * Convert the current chat state to an array suitable for sending to an LLM API. Includes the
470
- * system prompt as the first message and all conversation turns.
471
- *
472
- * Two things it does NOT do verbatim, both because the array is a transcript for the UI and this
473
- * is the wire:
474
- *
475
- * 1. **An assistant turn carrying neither text nor tool calls is dropped.** The one that always
476
- * exists is {@link beginAssistantTurn}'s placeholder — the message being generated by the very
477
- * request this builds, so it is a turn that has not happened yet. It reached providers as a
478
- * trailing `{"role":"assistant","content":""}`, which most read as an empty prefill and
479
- * ignore; DeepSeek's thinking mode instead answers 400 `The reasoning_content in the thinking
480
- * mode must be passed back to the API`, because a thinking-mode assistant turn owes it one.
481
- * `persistChat` in Studio already skipped the same message for the same reason — it just
482
- * skipped it on the way to storage, and nothing skipped it on the way to the provider.
483
- * 2. **`reasoning_content` is replayed when the turn has one.** DeepSeek requires the reasoning of
484
- * all previous turns back on any request carrying `tools` — which is every request the agent
485
- * loop makes — and ignores it on requests that carry none, so echoing what the provider itself
486
- * streamed is safe for providers that never send it (they get no field at all).
451
+ * The conversation as the OpenAI chat-completions `messages` array the next request carries:
452
+ * `toOpenAIMessages(toChatMessages(messages))` from `@jxsuite/ai/messages`, which says what the
453
+ * wire leaves out and why (specs/ai.md §2.2). The system prompt travels separately.
487
454
  *
488
- * @returns {object[]}
455
+ * @returns {OpenAIMessage[]}
489
456
  */
490
- function toMessagesArray() {
491
- const out = [];
492
-
493
- for (const msg of store.messages) {
494
- if (msg.role === "assistant") {
495
- const calls = msg.toolCalls ?? [];
496
- if (!msg.content && calls.length === 0) {
497
- continue;
498
- }
499
- const entry: {
500
- role: string;
501
- content: string | null;
502
- reasoning_content?: string;
503
- tool_calls?: object[];
504
- } = {
505
- role: "assistant",
506
- content: calls.length > 0 ? msg.content || null : msg.content,
507
- };
508
- if (msg.reasoningContent) {
509
- entry.reasoning_content = msg.reasoningContent;
510
- }
511
- if (calls.length > 0) {
512
- entry.tool_calls = calls.map((tc) => ({
513
- id: tc.id,
514
- type: "function",
515
- function: {
516
- name: tc.name,
517
- arguments: tc.arguments,
518
- },
519
- }));
520
- }
521
- out.push(entry);
522
- } else if (msg.role === "tool") {
523
- out.push({
524
- role: "tool",
525
- tool_call_id: msg.toolCallId,
526
- content: msg.content,
527
- });
528
- } else {
529
- out.push({
530
- role: msg.role,
531
- content: msg.content,
532
- });
533
- }
534
- }
535
-
536
- return out;
457
+ function toMessagesArray(): OpenAIMessage[] {
458
+ return toOpenAIMessages(toChatMessages(store.messages));
537
459
  }
538
460
 
539
461
  return Object.assign(store, {
package/src/core-types.ts CHANGED
@@ -10,18 +10,29 @@
10
10
  * running one turn. A {@link ToolContext} carries those facts with the call instead, so the same
11
11
  * tool can run in a page, a Worker or an MCP server (specs/ai.md §3.7).
12
12
  *
13
- * @license MIT
14
13
  * @module @jxsuite/ai/tools
14
+ * @license MIT
15
+ */
16
+
17
+ /**
18
+ * A value that survives a JSON round trip unchanged.
19
+ *
20
+ * The array and object members are named interfaces rather than inline types on purpose. Vue's
21
+ * `reactive()` unwraps a store's type member by member, and an inline recursive alias gives it a
22
+ * fresh type at every level, so a store holding one (chat-state's messages can carry a block's
23
+ * opaque data) fails to compile as "excessively deep". A named interface is the same type at every
24
+ * level, and the walk stops.
15
25
  */
26
+ export type JsonValue = null | boolean | number | string | JsonArray | JsonObject;
16
27
 
17
- /** A value that survives a JSON round trip unchanged. */
18
- export type JsonValue =
19
- | null
20
- | boolean
21
- | number
22
- | string
23
- | JsonValue[]
24
- | { [key: string]: JsonValue };
28
+ /** A JSON array. */
29
+ // oxlint-disable-next-line typescript/no-empty-interface, typescript/no-empty-object-type -- named, see JsonValue
30
+ export interface JsonArray extends Array<JsonValue> {}
31
+
32
+ /** A JSON object. */
33
+ export interface JsonObject {
34
+ [key: string]: JsonValue;
35
+ }
25
36
 
26
37
  /** What a tool call answers. */
27
38
  export interface ToolResult {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `@jxsuite/ai/harness`: the turn engine (specs/ai.md §3.8).
3
+ *
4
+ * `runTurn` takes a conversation, a model function and a tool registry, and runs one user turn to
5
+ * its end: it streams each round, runs the calls the model made, feeds their results back, and
6
+ * stops when the model stops calling tools or the work budget runs out. Everything it does is an
7
+ * event a host can apply to its own transcript, and how the turn ended is a `TurnOutcome`.
8
+ *
9
+ * Worker-safe: it imports `./tools` and `./messages` and nothing host-bound
10
+ * (`tests/worker-safety.test.ts`, `tsconfig.worker.json`).
11
+ *
12
+ * @module @jxsuite/ai/harness
13
+ * @docs extending/embedding/assistant-harness
14
+ */
15
+
16
+ export { LaneBusyError, createTurnLock } from "./lock.ts";
17
+ export { fromStreamingClient, joinSystem } from "./model.ts";
18
+ export { DEFAULT_TURN_POLICY } from "./policy.ts";
19
+ export { runTurn } from "./run.ts";
20
+
21
+ export type {
22
+ HarnessEvent,
23
+ ModelFn,
24
+ ModelRequest,
25
+ TurnError,
26
+ TurnHooks,
27
+ TurnInput,
28
+ TurnLock,
29
+ TurnOutcome,
30
+ TurnOutcomeKind,
31
+ TurnPolicy,
32
+ TurnRun,
33
+ Usage,
34
+ } from "./types.ts";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Lock.ts — one turn at a time.
3
+ *
4
+ * @module @jxsuite/ai/harness
5
+ * @license MIT
6
+ */
7
+
8
+ import type { TurnLock } from "./types.ts";
9
+
10
+ /** Thrown, synchronously, by `runTurn` when its lock is already held by another turn. */
11
+ export class LaneBusyError extends Error {
12
+ constructor(active: string) {
13
+ super(`A turn is already running (${active}).`);
14
+ this.name = "LaneBusyError";
15
+ }
16
+ }
17
+
18
+ /**
19
+ * A lock one turn holds at a time. A release function releases only the hold it was returned for,
20
+ * so a late call from a turn that already ended cannot free a newer turn's hold.
21
+ *
22
+ * @returns {TurnLock}
23
+ */
24
+ export function createTurnLock(): TurnLock {
25
+ let active: string | null = null;
26
+ let hold = 0;
27
+ return {
28
+ acquire(turnId) {
29
+ if (active !== null) {
30
+ return null;
31
+ }
32
+ active = turnId;
33
+ hold += 1;
34
+ const mine = hold;
35
+ return () => {
36
+ if (hold === mine) {
37
+ active = null;
38
+ }
39
+ };
40
+ },
41
+ get active() {
42
+ return active;
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Model.ts — a model function over a v1 streaming client.
3
+ *
4
+ * @module @jxsuite/ai/harness
5
+ * @license MIT
6
+ */
7
+
8
+ import { toOpenAIMessages, toOpenAITools } from "../messages/openai.ts";
9
+ import type { SystemBlock } from "../messages/types.ts";
10
+ import type { StreamingClient } from "../streaming-client.ts";
11
+ import type { ModelFn } from "./types.ts";
12
+
13
+ /**
14
+ * A request's system prompt as one text: its parts joined in order, with nothing between.
15
+ *
16
+ * @param {readonly SystemBlock[]} system
17
+ * @returns {string}
18
+ */
19
+ export function joinSystem(system: readonly SystemBlock[]): string {
20
+ return system.map((block) => block.text).join("");
21
+ }
22
+
23
+ /**
24
+ * A model function that sends each round through `client.streamChat`, projected onto the OpenAI
25
+ * chat-completions wire (specs/ai.md §2.3). `streamChat` is read off the client on every call, so a
26
+ * host that swaps it between rounds is honoured, and it is called exactly once per round.
27
+ *
28
+ * @param {StreamingClient} client
29
+ * @returns {ModelFn}
30
+ */
31
+ export function fromStreamingClient(client: StreamingClient): ModelFn {
32
+ return (request, signal) => {
33
+ const messages = toOpenAIMessages(request.messages);
34
+ const tools = toOpenAITools(request.tools);
35
+ return client.streamChat(messages, tools, joinSystem(request.system), signal);
36
+ };
37
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Policy.ts — the numbers and words a turn is bounded and reported by.
3
+ *
4
+ * @module @jxsuite/ai/harness
5
+ * @license MIT
6
+ */
7
+
8
+ import type { TurnPolicy } from "./types.ts";
9
+
10
+ /** Average characters per token: the estimate every budget used before providers reported counts. */
11
+ const CHARS_PER_TOKEN = 4;
12
+
13
+ /**
14
+ * The defaults: five rounds of work, twenty-five rounds in all, and the texts the agent loop has
15
+ * always used (specs/ai.md §3.2, §3.4).
16
+ *
17
+ * Five bounds AUTONOMOUS work. A round that ends by blocking on a person is the opposite of runaway
18
+ * (it cannot advance without them), so a round whose calls were all interactive does not spend that
19
+ * budget. Twenty-five is the backstop that keeps a turn provably finite anyway.
20
+ */
21
+ export const DEFAULT_TURN_POLICY: TurnPolicy = {
22
+ maxWorkRounds: 5,
23
+ maxRounds: 25,
24
+ estimateTokens: (text) => Math.ceil(text.length / CHARS_PER_TOKEN),
25
+ capText: ({ maxWorkRounds, applied, errors }) => {
26
+ const unique = [...new Set(errors)];
27
+ const appliedPart =
28
+ applied.length > 0
29
+ ? `\n\nChanges applied so far:\n${applied.map((s) => `- ${s}`).join("\n")}`
30
+ : "";
31
+ const errorPart =
32
+ unique.length > 0 ? `\n\nErrors encountered:\n${unique.map((e) => `- ${e}`).join("\n")}` : "";
33
+ return (
34
+ `I ran out of tool-call rounds (${maxWorkRounds}) before finishing.${appliedPart}${errorPart}` +
35
+ `\n\nYou can continue by sending another message, or try a more specific request.`
36
+ );
37
+ },
38
+ emptyText: "The model sent back an empty reply.",
39
+ };