@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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Repair.ts — every tool call followed by its reply, as a provider requires.
3
+ *
4
+ * A provider accepts a request that repeats a tool call only when the call's reply comes directly
5
+ * after the message that made it. Several ordinary things break that: a saved session restored from
6
+ * the middle of a pair, history trimmed for the token budget, a turn stopped between two calls, and
7
+ * transcripts written before one window ran one turn, where a reply could land after a later
8
+ * message. `repairToolPairs` puts every pair back together (specs/ai.md §3.4).
9
+ *
10
+ * @module @jxsuite/ai/messages
11
+ * @license MIT
12
+ */
13
+
14
+ import type { ChatMessage, ToolCallBlock, ToolResultBlock } from "./types.ts";
15
+
16
+ /** What a seal says for a call that was cut off: its arguments never finished streaming. */
17
+ export const SEAL_CUT_OFF =
18
+ "This tool call was never completed — the response was cut off before it finished.";
19
+
20
+ /** What a seal says for any other call with no reply. */
21
+ export const SEAL_RELOADED =
22
+ "This tool call was never completed — the session was reloaded or the history was trimmed.";
23
+
24
+ /** A seal's content: the failed result a Jx tool would have serialized, keys in this order. */
25
+ function sealContent(sentence: string): string {
26
+ return JSON.stringify({ success: false, error: sentence });
27
+ }
28
+
29
+ const CUT_OFF_CONTENT = sealContent(SEAL_CUT_OFF);
30
+ const RELOADED_CONTENT = sealContent(SEAL_RELOADED);
31
+
32
+ /** What {@link repairToolPairs} did, and the transcript it did it to. */
33
+ export interface RepairReport {
34
+ /** The repaired transcript. Every message the repair did not create is the input's own object. */
35
+ readonly messages: ChatMessage[];
36
+ /** The id of each call given a seal, in transcript order. */
37
+ readonly sealed: string[];
38
+ /** The id of each `tool` message removed, in transcript order. */
39
+ readonly dropped: string[];
40
+ /** The id of each `tool` message put back after the request it answers, in transcript order. */
41
+ readonly moved: string[];
42
+ }
43
+
44
+ /**
45
+ * Whether a `tool` message's content is a seal rather than a reply: exactly one of the two contents
46
+ * {@link repairToolPairs} writes.
47
+ *
48
+ * @param {string} content
49
+ * @returns {boolean}
50
+ */
51
+ export function isSealContent(content: string): boolean {
52
+ return content === CUT_OFF_CONTENT || content === RELOADED_CONTENT;
53
+ }
54
+
55
+ /**
56
+ * Whether a call's arguments were cut off mid-stream: there are some, and they do not parse. An
57
+ * empty string is a call with no arguments, not one that was cut off.
58
+ */
59
+ function isCutOff(call: ToolCallBlock): boolean {
60
+ if (call.argumentsText === "") {
61
+ return false;
62
+ }
63
+ try {
64
+ JSON.parse(call.argumentsText);
65
+ return false;
66
+ } catch {
67
+ return true;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * A message's calls, one per id, in the order they were made. A call with no id cannot be answered,
73
+ * so nothing seals it either; a call id made twice in one message is answered by one reply.
74
+ */
75
+ function callsOf(message: ChatMessage): ToolCallBlock[] {
76
+ const calls = new Map<string, ToolCallBlock>();
77
+ for (const block of message.blocks) {
78
+ if (block.type === "tool_call" && block.id !== "" && !calls.has(block.id)) {
79
+ calls.set(block.id, block);
80
+ }
81
+ }
82
+ return [...calls.values()];
83
+ }
84
+
85
+ /** The call id a `tool` message answers: its first result's, or the empty id when it has none. */
86
+ function answers(message: ChatMessage): string {
87
+ const result = message.blocks.find(
88
+ (block): block is ToolResultBlock => block.type === "tool_result",
89
+ );
90
+ return result?.callId ?? "";
91
+ }
92
+
93
+ /**
94
+ * The seal for a call nothing answered: the `n`th of its message's unanswered calls, stamped with
95
+ * that message's time, under an id no message in `used` has (which it then joins).
96
+ */
97
+ function seal(call: ToolCallBlock, n: number, timestamp: number, used: Set<string>): ChatMessage {
98
+ const base = `sealed_${call.id}_${n}`;
99
+ let id = base;
100
+ for (let k = 1; used.has(id); k++) {
101
+ id = `${base}_${k}`;
102
+ }
103
+ used.add(id);
104
+ return {
105
+ id,
106
+ role: "tool",
107
+ blocks: [
108
+ {
109
+ type: "tool_result",
110
+ callId: call.id,
111
+ isError: true,
112
+ content: isCutOff(call) ? CUT_OFF_CONTENT : RELOADED_CONTENT,
113
+ },
114
+ ],
115
+ timestamp,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Put every tool call and its reply back together, so a provider accepts the transcript.
121
+ *
122
+ * Pairing is per request, never by call id across the transcript, because a provider may number its
123
+ * calls afresh every round (`call_0` in round one and again in round two):
124
+ *
125
+ * 1. A `tool` message answers the call id of its first result. Its owner is the closest earlier
126
+ * assistant message that made a call with that id and has no reply for it yet. A reply with no
127
+ * owner (no earlier call, no call id, or a second reply to a call already answered) is dropped.
128
+ * 2. Every other message keeps its order. Directly after each assistant message that made calls come a
129
+ * seal for each of its calls nothing answered, in call order, and then the replies it owns, in
130
+ * their original order. A reply that was not already in the run of `tool` messages directly
131
+ * after its owner is reported as moved.
132
+ * 3. A seal is a `tool` message carrying a failed result. It says the call was cut off when its
133
+ * arguments do not parse ({@link SEAL_CUT_OFF}), and otherwise that the session was reloaded or
134
+ * the history trimmed ({@link SEAL_RELOADED}). It is stamped with the requesting message's
135
+ * timestamp and named `sealed_<call id>_<n>`, `n` being the call's place among that message's
136
+ * unanswered calls, with `_<k>` appended when that id is taken.
137
+ *
138
+ * Pure and deterministic, and idempotent: repairing a repaired transcript reports nothing and
139
+ * returns the same messages.
140
+ *
141
+ * @param {readonly ChatMessage[]} messages
142
+ * @returns {RepairReport}
143
+ */
144
+ export function repairToolPairs(messages: readonly ChatMessage[]): RepairReport {
145
+ /* Per call id, the indices of the assistant messages that made it, oldest first; per such message,
146
+ its calls, the call ids answered so far and the indices of the replies it owns. */
147
+ const requesters = new Map<string, number[]>();
148
+ const calls = new Map<number, ToolCallBlock[]>();
149
+ const answered = new Map<number, Set<string>>();
150
+ const owned = new Map<number, number[]>();
151
+ const dropped: string[] = [];
152
+
153
+ for (const [index, message] of messages.entries()) {
154
+ if (message.role === "assistant") {
155
+ const made = callsOf(message);
156
+ if (made.length > 0) {
157
+ calls.set(index, made);
158
+ answered.set(index, new Set());
159
+ owned.set(index, []);
160
+ for (const call of made) {
161
+ requesters.set(call.id, [...(requesters.get(call.id) ?? []), index]);
162
+ }
163
+ }
164
+ } else if (message.role === "tool") {
165
+ const callId = answers(message);
166
+ const owner = requesters.get(callId)?.findLast((at) => !answered.get(at)!.has(callId));
167
+ if (owner === undefined) {
168
+ dropped.push(message.id);
169
+ } else {
170
+ answered.get(owner)!.add(callId);
171
+ owned.get(owner)!.push(index);
172
+ }
173
+ }
174
+ }
175
+
176
+ const used = new Set(messages.map((message) => message.id));
177
+ const repaired: ChatMessage[] = [];
178
+ const sealed: string[] = [];
179
+ const moved: string[] = [];
180
+
181
+ for (const [index, message] of messages.entries()) {
182
+ if (message.role === "tool") {
183
+ continue;
184
+ }
185
+ repaired.push(message);
186
+ const made = calls.get(index);
187
+ if (!made) {
188
+ continue;
189
+ }
190
+ const replied = answered.get(index)!;
191
+ const unanswered = made.filter((call) => !replied.has(call.id));
192
+ for (const [n, call] of unanswered.entries()) {
193
+ repaired.push(seal(call, n, message.timestamp, used));
194
+ sealed.push(call.id);
195
+ }
196
+ // The run of `tool` messages directly after the request, in the input: a reply there stayed.
197
+ let runEnd = index + 1;
198
+ while (runEnd < messages.length && messages[runEnd]!.role === "tool") {
199
+ runEnd += 1;
200
+ }
201
+ for (const at of owned.get(index)!) {
202
+ const reply = messages[at]!;
203
+ repaired.push(reply);
204
+ if (at >= runEnd) {
205
+ moved.push(reply.id);
206
+ }
207
+ }
208
+ }
209
+
210
+ return { messages: repaired, sealed, dropped, moved };
211
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Types.ts — the provider-neutral conversation model, and the v1 shape it is read from.
3
+ *
4
+ * Type-only. `@jxsuite/ai/messages` re-exports all of it; the conversions live in `convert.ts` and
5
+ * `openai.ts`.
6
+ *
7
+ * A conversation used to have one shape, OpenAI's: a message was `content`, an optional
8
+ * `reasoningContent` and an optional list of `toolCalls`, and the request was that array with the
9
+ * field names changed. Every provider that is not OpenAI-compatible describes a turn differently
10
+ * (Anthropic's thinking blocks carry a signature that must be replayed byte for byte, and its tool
11
+ * results ride on a user message), so a transcript written in one provider's words cannot be sent
12
+ * to another without guessing. A {@link ChatMessage} is a list of typed {@link Block}s instead, and
13
+ * each provider's adapter projects it onto its own wire (specs/ai.md §2.3).
14
+ *
15
+ * @module @jxsuite/ai/messages
16
+ * @license MIT
17
+ */
18
+
19
+ import type { JsonValue, ToolResult } from "../core-types.ts";
20
+
21
+ export type { JsonArray, JsonObject, JsonValue } from "../core-types.ts";
22
+
23
+ /** The provider families a block can come from, and that an adapter projects onto. */
24
+ export type ProviderFamily = "openai-compat" | "anthropic";
25
+
26
+ /** Which provider produced a block, for a block that must only be replayed to that provider. */
27
+ export interface Provenance {
28
+ readonly family: ProviderFamily;
29
+ /** The model that produced it; null only for data migrated from the v1 shape. */
30
+ readonly model: string | null;
31
+ }
32
+
33
+ /** Text the model wrote, or the person did. */
34
+ export interface TextBlock {
35
+ readonly type: "text";
36
+ readonly text: string;
37
+ }
38
+
39
+ /** A thinking model's chain of thought: owed back to the provider that streamed it (§2.2). */
40
+ export interface ReasoningBlock {
41
+ readonly type: "reasoning";
42
+ /** Empty when the provider sent only a redacted payload. */
43
+ readonly text: string;
44
+ /** Opaque, replayed unchanged to the provider that signed it. */
45
+ readonly signature?: string;
46
+ /** An opaque redacted payload, never merged into `text`. */
47
+ readonly redacted?: string;
48
+ /** Absent: the message's own `meta.family` decides, and v1 data is `openai-compat`. */
49
+ readonly provenance?: Provenance;
50
+ }
51
+
52
+ /** A tool call the model made. */
53
+ export interface ToolCallBlock {
54
+ readonly type: "tool_call";
55
+ readonly id: string;
56
+ readonly name: string;
57
+ /** The arguments exactly as streamed, which may not parse: the only source of truth. */
58
+ readonly argumentsText: string;
59
+ /** An opaque per-call signature, round-tripped. */
60
+ readonly signature?: string;
61
+ }
62
+
63
+ /** What a tool call answered. */
64
+ export interface ToolResultBlock {
65
+ readonly type: "tool_result";
66
+ readonly callId: string;
67
+ /** True when `content` parses as a result whose `success` is false; unparseable is false. */
68
+ readonly isError: boolean;
69
+ /** For a Jx tool, exactly the serialized `ToolResult`. */
70
+ readonly content: string;
71
+ }
72
+
73
+ /** Provider data the model has no neutral word for, kept for that provider alone. */
74
+ export interface OpaqueBlock {
75
+ readonly type: "opaque";
76
+ readonly family: ProviderFamily;
77
+ readonly data: JsonValue;
78
+ }
79
+
80
+ export type Block = TextBlock | ReasoningBlock | ToolCallBlock | ToolResultBlock | OpaqueBlock;
81
+
82
+ /** `system` is the v1 passthrough: a message any consumer may push, sent as it is. */
83
+ export type Role = "user" | "assistant" | "tool" | "system";
84
+
85
+ /** What a synthetic message is, for a message the harness rather than the model wrote. */
86
+ export type MessageKind =
87
+ | "ctx_summary"
88
+ | "sealed"
89
+ | "round_cap"
90
+ | "seeded"
91
+ | "restored"
92
+ | "context"
93
+ | "compaction";
94
+
95
+ /** Facts about a message that are never sent to a provider. */
96
+ export interface MessageMeta {
97
+ readonly origin?: "model" | "harness" | "user";
98
+ readonly kind?: MessageKind;
99
+ readonly model?: string;
100
+ readonly family?: ProviderFamily;
101
+ readonly incomplete?: "error" | "truncated" | "length";
102
+ readonly turnId?: string;
103
+ /** A host's own facts, under reverse-DNS keys such as `com.jxsuite.studio/ledger`. */
104
+ readonly host?: Readonly<Record<string, JsonValue>>;
105
+ }
106
+
107
+ /** One message of a conversation, in no provider's words. */
108
+ export interface ChatMessage {
109
+ readonly id: string;
110
+ readonly role: Role;
111
+ readonly blocks: readonly Block[];
112
+ readonly timestamp: number;
113
+ readonly meta?: MessageMeta;
114
+ }
115
+
116
+ /** What travels in a request: the role and the blocks, with no id, timestamp or meta. */
117
+ export interface WireMessage {
118
+ readonly role: Role;
119
+ readonly blocks: readonly Block[];
120
+ }
121
+
122
+ /** A tool call as the v1 transcript records it: what the model sent, and the chip's outcome. */
123
+ export interface LiveToolCall {
124
+ id: string;
125
+ name: string;
126
+ arguments: string;
127
+ /** Transcript-only: the outcome the chip renders. The `tool` reply is what the wire carries. */
128
+ result?: ToolResult | null;
129
+ }
130
+
131
+ /**
132
+ * The v1 message: chat-state's live shape and the shape every saved session holds, plus two
133
+ * optional fields.
134
+ */
135
+ export interface LiveMessage {
136
+ id: string;
137
+ role: Role;
138
+ content: string;
139
+ /**
140
+ * The turn's chain of thought, when the provider streamed one (`reasoning_content` /
141
+ * `reasoning`). Kept because a thinking model's own history is not optional: DeepSeek's thinking
142
+ * mode REQUIRES every prior turn's `reasoning_content` back on any request that carries `tools`,
143
+ * which is every request the agent loop makes.
144
+ */
145
+ reasoningContent?: string;
146
+ toolCalls?: LiveToolCall[];
147
+ toolCallId?: string;
148
+ timestamp: number;
149
+ /**
150
+ * The message's blocks, present ONLY when the v1 fields above cannot describe them exactly: a
151
+ * signed, redacted or provider-bound reasoning block, an opaque block, or text and calls
152
+ * interleaved, for instance. The v1 fields are still written, so a reader that predates blocks
153
+ * keeps working.
154
+ */
155
+ blocks?: Block[];
156
+ meta?: MessageMeta;
157
+ }
158
+
159
+ /** One part of a system prompt. A request's parts are joined in order, with nothing between. */
160
+ export interface SystemBlock {
161
+ readonly text: string;
162
+ }
163
+
164
+ /** A tool as a provider is told about it. */
165
+ export interface ToolSpec {
166
+ readonly name: string;
167
+ readonly title?: string;
168
+ readonly description: string;
169
+ /** JSON Schema for the arguments, by reference: a host may build it with live getters. */
170
+ readonly inputSchema: Readonly<Record<string, unknown>>;
171
+ /** OpenAI's strict mode, opted into per tool. */
172
+ readonly strict?: boolean;
173
+ }
174
+
175
+ /** One OpenAI chat-completions tool call. */
176
+ export interface OpenAIToolCall {
177
+ id: string;
178
+ type: "function";
179
+ function: { name: string; arguments: string };
180
+ }
181
+
182
+ /** One message on the OpenAI chat-completions wire. */
183
+ export type OpenAIMessage =
184
+ | { role: "user" | "system"; content: string }
185
+ | {
186
+ role: "assistant";
187
+ content: string | null;
188
+ reasoning_content?: string;
189
+ tool_calls?: OpenAIToolCall[];
190
+ }
191
+ | { role: "tool"; tool_call_id: string; content: string };
192
+
193
+ /** One tool on the OpenAI chat-completions wire: `ToolRegistry.listForLLM()`'s shape. */
194
+ export interface OpenAITool {
195
+ type: "function";
196
+ function: {
197
+ name: string;
198
+ description: string;
199
+ parameters: Readonly<Record<string, unknown>>;
200
+ strict?: true;
201
+ };
202
+ }
package/src/tools.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Tools.js — Tool registry infrastructure
3
3
  *
4
- * Provider-agnostic base classes for defining and registering tools that an LLM
5
- * can call. Tools have JSON Schema parameter definitions and execute functions.
6
- * The registry validates arguments before execution.
4
+ * Provider-agnostic base classes for defining and registering tools that an LLM can call. Tools
5
+ * have JSON Schema parameter definitions and execute functions. The registry validates arguments
6
+ * before execution.
7
7
  *
8
8
  * Every call carries a {@link ToolContext}: its own signal, its call id, who it acts for, the turn's
9
9
  * write ledger and the session's facts (specs/ai.md §3.7). A caller that has none gets a detached
10
- * one, so a tool run outside a turn (a test, a command) records into nothing and cannot be stopped.
10
+ * one, so a tool run outside a turn (a test, a command) records into nothing and cannot be
11
+ * stopped.
11
12
  *
12
- * @docs extending/embedding/assistant-harness
13
- * @license MIT
14
13
  * @module @jxsuite/ai/tools
14
+ * @license MIT
15
+ * @docs extending/embedding/assistant-harness
15
16
  */
16
17
 
17
18
  import type {
@@ -26,6 +27,8 @@ import type {
26
27
  export type {
27
28
  Actor,
28
29
  AiWrite,
30
+ JsonArray,
31
+ JsonObject,
29
32
  JsonValue,
30
33
  SessionFacts,
31
34
  ToolContext,
@@ -417,3 +420,96 @@ export function createToolRegistry() {
417
420
  },
418
421
  };
419
422
  }
423
+
424
+ // ─── Invocation ──────────────────────────────────────────────────────────────
425
+
426
+ /** A call a model made: its id, the tool, and the arguments exactly as streamed. */
427
+ export interface ToolCall {
428
+ readonly callId: string;
429
+ readonly name: string;
430
+ /** Raw, and possibly not JSON: a call cut off mid-arguments still arrives. */
431
+ readonly argumentsText: string;
432
+ }
433
+
434
+ /** What invoking a call produced. `executed` is false when the registry was never reached. */
435
+ export interface InvokeOutcome {
436
+ readonly status: "done";
437
+ readonly result: ToolResult;
438
+ readonly executed: boolean;
439
+ }
440
+
441
+ /**
442
+ * A result as JSON would carry it: the serialized bytes are unchanged, and `data` becomes plain
443
+ * JSON, so the result survives any transport that serializes it.
444
+ *
445
+ * @param {ToolResult} result
446
+ * @returns {ToolResult}
447
+ */
448
+ export function normalizeToolResult(result: ToolResult): ToolResult {
449
+ // oxlint-disable-next-line unicorn/prefer-structured-clone -- the JSON round trip is the point
450
+ return JSON.parse(JSON.stringify(result)) as ToolResult;
451
+ }
452
+
453
+ /**
454
+ * A call's arguments as the object a tool takes.
455
+ *
456
+ * An empty string is no arguments. JSON that parses to something other than an object (`null`, an
457
+ * array, a number) is refused with its own sentence rather than reaching the registry, where a
458
+ * validator reading a property of `null` threw a message about the validator.
459
+ */
460
+ function parseArguments(text: string): object {
461
+ if (!text) {
462
+ return {};
463
+ }
464
+ const parsed = JSON.parse(text) as unknown;
465
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
466
+ const type = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
467
+ throw new TypeError(`arguments must be a JSON object, got ${type}`);
468
+ }
469
+ return parsed;
470
+ }
471
+
472
+ /**
473
+ * Run one model call through a registry: parse its arguments, execute it with its context, and
474
+ * return the result as JSON would carry it. Never throws: a failure is a result the model reads.
475
+ *
476
+ * Arguments that do not parse answer `Failed to parse arguments: <reason>`, with the same prefix
477
+ * for a non-object, so the model reads both as one kind of mistake. The registry keeps its own
478
+ * texts for an unknown tool, failed validation, a gate and a tool that threw. A registry that
479
+ * itself throws is answered with the parse prefix too, as the agent loop always answered it, and a
480
+ * result JSON cannot carry is answered as one that cannot be serialized.
481
+ *
482
+ * @param {ToolRegistry} registry
483
+ * @param {ToolCall} call
484
+ * @param {ToolContext} ctx - The call's context, forwarded to the tool unchanged
485
+ * @returns {Promise<InvokeOutcome>}
486
+ */
487
+ export async function invokeTool(
488
+ registry: ToolRegistry,
489
+ call: ToolCall,
490
+ ctx: ToolContext,
491
+ ): Promise<InvokeOutcome> {
492
+ let executed = false;
493
+ let result: ToolResult;
494
+ try {
495
+ const args = parseArguments(call.argumentsText);
496
+ executed = true;
497
+ result = await registry.execute(call.name, args, ctx);
498
+ } catch (error) {
499
+ result = toolError(`Failed to parse arguments: ${(error as Error).message}`);
500
+ }
501
+ try {
502
+ return { status: "done", result: normalizeToolResult(result), executed };
503
+ } catch (error) {
504
+ /* A result JSON cannot carry (a cycle, a BigInt) cannot be sent back to the model either. It
505
+ used to throw out of the turn; the model is told instead, and the turn goes on. */
506
+ const reason = (error as Error).message;
507
+ return {
508
+ status: "done",
509
+ result: toolError(
510
+ `Tool "${call.name}" returned a result that cannot be serialized: ${reason}`,
511
+ ),
512
+ executed,
513
+ };
514
+ }
515
+ }