@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 +1 -1
- package/package.json +6 -4
- package/src/chat-state.ts +27 -105
- package/src/core-types.ts +20 -9
- package/src/harness/index.ts +34 -0
- package/src/harness/lock.ts +45 -0
- package/src/harness/model.ts +37 -0
- package/src/harness/policy.ts +39 -0
- package/src/harness/run.ts +480 -0
- package/src/harness/types.ts +253 -0
- package/src/message-id.ts +22 -0
- package/src/messages/convert.ts +216 -0
- package/src/messages/index.ts +47 -0
- package/src/messages/openai.ts +160 -0
- package/src/messages/repair.ts +211 -0
- package/src/messages/types.ts +202 -0
- package/src/tools.ts +102 -6
package/LICENSE
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/ai",
|
|
3
|
-
"version": "0.39.
|
|
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.
|
|
34
|
+
"@jxsuite/protocol": "^2.6.0",
|
|
33
35
|
"@vue/reactivity": "3.5.43"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
|
-
"@cloudflare/workers-types": "^5.
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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 =
|
|
23
|
+
export type MessageRole = Role;
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
|
438
|
-
tool calls, reasoning and all, so a turn that only
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
*
|
|
470
|
-
*
|
|
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 {
|
|
455
|
+
* @returns {OpenAIMessage[]}
|
|
489
456
|
*/
|
|
490
|
-
function toMessagesArray() {
|
|
491
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
};
|