@jxsuite/ai 0.39.1 → 0.39.3

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,253 @@
1
+ /**
2
+ * Types.ts — the turn engine's contract: what a host hands `runTurn`, and what it hears back.
3
+ *
4
+ * Type-only. `@jxsuite/ai/harness` re-exports all of it; the engine lives in `run.ts`.
5
+ *
6
+ * @module @jxsuite/ai/harness
7
+ * @license MIT
8
+ */
9
+
10
+ import type { AiWrite, SessionFacts, ToolRegistry, ToolResult, WriteLedger } from "../tools.ts";
11
+ import type {
12
+ ChatMessage,
13
+ JsonValue,
14
+ ProviderFamily,
15
+ SystemBlock,
16
+ ToolSpec,
17
+ } from "../messages/types.ts";
18
+ import type { StreamEvent, StreamUsageEvent } from "../streaming-client.ts";
19
+
20
+ /** What one round asks the model. */
21
+ export interface ModelRequest {
22
+ /** Fixed for the whole turn. */
23
+ readonly system: readonly SystemBlock[];
24
+ /** The conversation so far, this turn's own messages included. */
25
+ readonly messages: readonly ChatMessage[];
26
+ readonly tools: readonly ToolSpec[];
27
+ }
28
+
29
+ /** Streams one round's answer. Called exactly once per round. */
30
+ export type ModelFn = (request: ModelRequest, signal: AbortSignal) => AsyncIterable<StreamEvent>;
31
+
32
+ /** A provider's token count for one request. */
33
+ export type Usage = Omit<StreamUsageEvent, "type">;
34
+
35
+ /** Why a turn failed. */
36
+ export interface TurnError {
37
+ readonly message: string;
38
+ readonly code?: string;
39
+ }
40
+
41
+ /**
42
+ * How a turn ended.
43
+ *
44
+ * - `complete`: the model stopped calling tools after drawing something.
45
+ * - `cap_partial`: the work budget ran out after something was applied; the turn ends on a message
46
+ * saying what.
47
+ * - `cap_failed`: the work budget ran out with nothing applied.
48
+ * - `error`: the provider sent an error frame.
49
+ * - `cancelled`: the turn was stopped.
50
+ * - `empty`: the model answered with neither text nor a tool call, and nothing earlier in the turn
51
+ * was drawn either.
52
+ */
53
+ export type TurnOutcomeKind =
54
+ | "complete"
55
+ | "cap_partial"
56
+ | "cap_failed"
57
+ | "error"
58
+ | "cancelled"
59
+ | "empty";
60
+
61
+ /** The numbers and words a turn is bounded and reported by. */
62
+ export interface TurnPolicy {
63
+ /** Rounds that did work: a round whose calls were all interactive does not count. */
64
+ readonly maxWorkRounds: number;
65
+ /** Rounds of every kind, so a turn ends whatever the model does. */
66
+ readonly maxRounds: number;
67
+ /** A token estimate for a text, reported beside each usage count for the system prompt. */
68
+ readonly estimateTokens: (text: string) => number;
69
+ /** What a turn that ran out of work rounds says. */
70
+ readonly capText: (cap: {
71
+ readonly maxWorkRounds: number;
72
+ readonly applied: readonly string[];
73
+ readonly errors: readonly string[];
74
+ }) => string;
75
+ /** What a turn that drew nothing says. */
76
+ readonly emptyText: string;
77
+ }
78
+
79
+ /** What a host can do between the engine's steps. */
80
+ export interface TurnHooks {
81
+ /** After a call has settled, before its result is reported. */
82
+ afterTool?: (call: {
83
+ readonly callId: string;
84
+ readonly name: string;
85
+ readonly result: ToolResult;
86
+ }) => void;
87
+ /** When a round ends on the provider's error frame, before `round_end` reports it. */
88
+ onStreamError?: (error: TurnError) => void;
89
+ }
90
+
91
+ /** One turn at a time per lane: acquiring a held lock answers null. */
92
+ export interface TurnLock {
93
+ /** The release function, or null when another turn holds the lock. */
94
+ acquire: (turnId: string) => (() => void) | null;
95
+ /** The turn holding the lock, or null. */
96
+ readonly active: string | null;
97
+ }
98
+
99
+ /** Everything one turn runs with. */
100
+ export interface TurnInput {
101
+ /**
102
+ * The conversation before this turn's reply: it ends with the message the turn answers. The turn
103
+ * repairs its tool-call pairing before the first request (`transcript_repaired`).
104
+ */
105
+ readonly history: readonly ChatMessage[];
106
+ /** Fixed for the turn. */
107
+ readonly system: readonly SystemBlock[];
108
+ readonly model: ModelFn;
109
+ /** Who answers: named in each call's actor. */
110
+ readonly modelInfo?: { readonly family: ProviderFamily; readonly model: string };
111
+ readonly tools: ToolRegistry;
112
+ /** Stops the turn when it aborts, with its reason. */
113
+ readonly signal?: AbortSignal;
114
+ /** Defaults to a fresh message id. */
115
+ readonly turnId?: string;
116
+ /** Defaults to the session facts' own id. */
117
+ readonly sessionId?: string | null;
118
+ /** The id of round one's message, for a host that has already put a placeholder on screen. */
119
+ readonly firstMessageId?: string;
120
+ /** Mints the ids of the messages the turn adds. Defaults to chat-state's scheme and counter. */
121
+ readonly newId?: () => string;
122
+ /** The clock messages are stamped with. Defaults to `Date.now`. */
123
+ readonly now?: () => number;
124
+ readonly policy?: Partial<TurnPolicy>;
125
+ readonly hooks?: TurnHooks;
126
+ /** What the conversation's tools remember across turns. Defaults to a fresh set. */
127
+ readonly session?: SessionFacts;
128
+ /** Where the turn's tools record their writes. Defaults to a fresh ledger. */
129
+ readonly ledger?: WriteLedger;
130
+ /** Held for the whole turn; a turn started on a held lock throws `LaneBusyError`. */
131
+ readonly lock?: TurnLock;
132
+ /**
133
+ * Called with every event, synchronously and in order, before the engine takes its next step. A
134
+ * host that keeps its own transcript applies each event here.
135
+ */
136
+ readonly onEvent?: (event: HarnessEvent) => void;
137
+ }
138
+
139
+ /** A turn in flight. Iterate it for its events, or await `outcome`. */
140
+ export interface TurnRun extends AsyncIterable<HarnessEvent> {
141
+ readonly turnId: string;
142
+ /** The turn's own signal: armed before `runTurn` returns, aborted by `cancel` or `input.signal`. */
143
+ readonly signal: AbortSignal;
144
+ cancel: (reason?: "user" | "superseded" | "shutdown") => void;
145
+ /**
146
+ * Rejects when something the host supplied throws (the model function, the registry's listing, a
147
+ * hook, `onEvent`); a provider failure is an `error` outcome.
148
+ */
149
+ readonly outcome: Promise<TurnOutcome>;
150
+ }
151
+
152
+ /** Every event carries the version, its place in the turn, and the turn. */
153
+ interface EventBase {
154
+ readonly v: 1;
155
+ /** Strictly increasing from 0 within a turn. */
156
+ readonly seq: number;
157
+ readonly turnId: string;
158
+ }
159
+
160
+ /** What a turn reports as it runs, in the order it happens. */
161
+ export type HarnessEvent = EventBase &
162
+ (
163
+ | { readonly type: "turn_start" }
164
+ | {
165
+ /**
166
+ * The history broke a tool call's pairing with its reply, and the turn repaired it before
167
+ * its first request (`repairToolPairs`, specs/ai.md §3.4). Heard right after `turn_start`,
168
+ * and only when the repair changed something. Each list is in transcript order: the calls
169
+ * given a seal, and the ids of the `tool` messages dropped and moved.
170
+ */
171
+ readonly type: "transcript_repaired";
172
+ readonly sealed: readonly string[];
173
+ readonly dropped: readonly string[];
174
+ readonly moved: readonly string[];
175
+ }
176
+ | {
177
+ readonly type: "round_start";
178
+ readonly round: number;
179
+ readonly workRounds: number;
180
+ readonly messageId: string;
181
+ }
182
+ | { readonly type: "text"; readonly messageId: string; readonly text: string }
183
+ | { readonly type: "reasoning"; readonly messageId: string; readonly text: string }
184
+ | {
185
+ readonly type: "tool_call_start";
186
+ readonly messageId: string;
187
+ readonly callId: string;
188
+ readonly name: string;
189
+ }
190
+ | {
191
+ readonly type: "tool_call_delta";
192
+ readonly messageId: string;
193
+ readonly callId: string;
194
+ readonly args: string;
195
+ }
196
+ | { readonly type: "tool_call_end"; readonly messageId: string; readonly callId: string }
197
+ | {
198
+ readonly type: "usage";
199
+ readonly messageId: string;
200
+ /** The provider's count exactly as the stream reported it. */
201
+ readonly frame: StreamUsageEvent;
202
+ /** The system prompt's estimate, so a later budget can add what it grew by since. */
203
+ readonly systemTokens: number;
204
+ }
205
+ | {
206
+ readonly type: "round_end";
207
+ readonly messageId: string;
208
+ readonly stopReason: string;
209
+ /** The stream ended with no `done` frame. */
210
+ readonly truncated: boolean;
211
+ /** The round's message is gone: it failed, or the turn drew nothing. */
212
+ readonly dropped: boolean;
213
+ readonly error?: TurnError;
214
+ }
215
+ | {
216
+ readonly type: "tool_start";
217
+ readonly callId: string;
218
+ readonly name: string;
219
+ readonly interactive: boolean;
220
+ }
221
+ | { readonly type: "tool_progress"; readonly callId: string; readonly progress: JsonValue }
222
+ | { readonly type: "write"; readonly callId: string; readonly write: AiWrite }
223
+ | {
224
+ readonly type: "tool_result";
225
+ readonly callId: string;
226
+ /** The assistant message that made the call. */
227
+ readonly messageId: string;
228
+ /** The `tool` message the result was appended as. */
229
+ readonly toolMessageId: string;
230
+ readonly result: ToolResult;
231
+ }
232
+ | { readonly type: "turn_end"; readonly outcome: TurnOutcome }
233
+ );
234
+
235
+ /** How a turn ended, and what it left behind. */
236
+ export interface TurnOutcome {
237
+ readonly kind: TurnOutcomeKind;
238
+ readonly turnId: string;
239
+ /** The last assistant message of the turn that was drawn, the cap message included. */
240
+ readonly anchorMessageId: string | null;
241
+ readonly rounds: number;
242
+ readonly workRounds: number;
243
+ readonly writes: readonly AiWrite[];
244
+ readonly usage: { readonly last: Usage | null; readonly total: Usage | null };
245
+ /** Every message the turn added, in its final form. */
246
+ readonly appended: readonly ChatMessage[];
247
+ /** For `cap_partial`: the message the turn ended on. */
248
+ readonly cap?: { readonly messageId: string; readonly text: string };
249
+ /** For `error` and `empty`: the round message that was dropped. */
250
+ readonly removedMessageId?: string;
251
+ /** For `error`, `empty` and `cap_failed`. */
252
+ readonly error?: TurnError;
253
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Message-id.ts — the one source of message ids in a process.
3
+ *
4
+ * Chat-state stamps every message it creates with `msg_<epoch ms>_<n>`, and the harness mints the
5
+ * ids of the messages a turn adds in the same scheme. One counter serves both, so an id the harness
6
+ * minted can never collide with one chat-state minted, and a host can adopt either kind into the
7
+ * other's transcript.
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ let counter = 0;
13
+
14
+ /**
15
+ * The next message id: `msg_<epoch ms>_<n>`, where `n` only ever increases.
16
+ *
17
+ * @returns {string}
18
+ */
19
+ export function nextMessageId(): string {
20
+ counter += 1;
21
+ return `msg_${Date.now()}_${counter}`;
22
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Convert.ts — between the v1 transcript and the neutral conversation model.
3
+ *
4
+ * Chat-state and every saved session hold {@link LiveMessage}s, the v1 shape. A {@link ChatMessage}
5
+ * describes the same message as blocks. The conversion keeps every field a message's role uses
6
+ * except a tool call's `result`, which is the chip's copy of an outcome the `tool` reply already
7
+ * carries; a host that renders chips rebuilds it from the reply.
8
+ *
9
+ * @module @jxsuite/ai/messages
10
+ * @license MIT
11
+ */
12
+
13
+ import type {
14
+ Block,
15
+ ChatMessage,
16
+ LiveMessage,
17
+ LiveToolCall,
18
+ ToolCallBlock,
19
+ ToolResultBlock,
20
+ WireMessage,
21
+ } from "./types.ts";
22
+
23
+ /** Whether a tool reply's content is a result that failed. Unparseable content is not. */
24
+ function isErrorContent(content: string): boolean {
25
+ try {
26
+ const parsed = JSON.parse(content) as unknown;
27
+ return (
28
+ typeof parsed === "object" &&
29
+ parsed !== null &&
30
+ (parsed as { success?: unknown }).success === false
31
+ );
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** A v1 value read as text: a string is itself, and anything else is absent. */
38
+ function asText(value: unknown): string {
39
+ return typeof value === "string" ? value : "";
40
+ }
41
+
42
+ /**
43
+ * The blocks a message's v1 fields describe, in the order a provider streams them.
44
+ *
45
+ * A saved session is restored without being checked, so a field can hold anything. A value of the
46
+ * wrong type is read as absent rather than turned into text or allowed to throw: a corrupt message
47
+ * is sent without its garbage, and never stops the send.
48
+ */
49
+ function blocksFromFields(message: LiveMessage): Block[] {
50
+ if (message.role === "tool") {
51
+ const content = asText(message.content);
52
+ return [
53
+ {
54
+ type: "tool_result",
55
+ callId: asText(message.toolCallId),
56
+ isError: isErrorContent(content),
57
+ content,
58
+ },
59
+ ];
60
+ }
61
+ const blocks: Block[] = [];
62
+ if (message.role === "assistant" && typeof message.reasoningContent === "string") {
63
+ blocks.push({ type: "reasoning", text: message.reasoningContent });
64
+ }
65
+ if (asText(message.content)) {
66
+ blocks.push({ type: "text", text: message.content });
67
+ }
68
+ if (message.role === "assistant" && Array.isArray(message.toolCalls)) {
69
+ for (const call of message.toolCalls as unknown[]) {
70
+ if (typeof call !== "object" || call === null) {
71
+ continue;
72
+ }
73
+ const { id, name, arguments: args } = call as Record<string, unknown>;
74
+ blocks.push({
75
+ type: "tool_call",
76
+ id: asText(id),
77
+ name: asText(name),
78
+ argumentsText: asText(args),
79
+ });
80
+ }
81
+ }
82
+ return blocks;
83
+ }
84
+
85
+ /** Whether two JSON values are equal, member order aside. */
86
+ function sameJson(a: unknown, b: unknown): boolean {
87
+ if (Object.is(a, b)) {
88
+ return true;
89
+ }
90
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
91
+ return false;
92
+ }
93
+ if (Array.isArray(a) || Array.isArray(b)) {
94
+ return (
95
+ Array.isArray(a) &&
96
+ Array.isArray(b) &&
97
+ a.length === b.length &&
98
+ a.every((item, i) => sameJson(item, b[i]))
99
+ );
100
+ }
101
+ const left = Object.entries(a).filter(([, value]) => value !== undefined);
102
+ const right = Object.entries(b).filter(([, value]) => value !== undefined);
103
+ return (
104
+ left.length === right.length &&
105
+ left.every(([key, value]) => sameJson(value, (b as Record<string, unknown>)[key]))
106
+ );
107
+ }
108
+
109
+ /** The concatenated text of a message's blocks of one kind. */
110
+ function joined(blocks: readonly Block[], type: "text" | "reasoning"): string {
111
+ let text = "";
112
+ for (const block of blocks) {
113
+ if (block.type === type) {
114
+ text += block.text;
115
+ }
116
+ }
117
+ return text;
118
+ }
119
+
120
+ /**
121
+ * Whether a message is an assistant turn carrying neither text nor a tool call.
122
+ *
123
+ * Such a turn is never sent (specs/ai.md §2.2): the one that always exists is the placeholder a
124
+ * turn opens with, which is the answer the request carrying it would generate. A message's blocks
125
+ * decide when it has them; a v1 message is read by its fields, as {@link toChatMessages} reads them.
126
+ *
127
+ * @param {ChatMessage | LiveMessage} message
128
+ * @returns {boolean}
129
+ */
130
+ export function isEmptyAssistant(message: ChatMessage | LiveMessage): boolean {
131
+ if (message.role !== "assistant") {
132
+ return false;
133
+ }
134
+ const blocks = message.blocks ?? blocksFromFields(message as LiveMessage);
135
+ return !blocks.some(
136
+ (block) => block.type === "tool_call" || (block.type === "text" && block.text !== ""),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * The neutral form of a v1 transcript. A message that carries `blocks` keeps them; any other is
142
+ * read from its fields. Fields a v1 message does not declare are not carried.
143
+ *
144
+ * @param {readonly LiveMessage[]} live
145
+ * @returns {ChatMessage[]}
146
+ */
147
+ export function toChatMessages(live: readonly LiveMessage[]): ChatMessage[] {
148
+ return live.map((message) => ({
149
+ id: message.id,
150
+ role: message.role,
151
+ blocks: message.blocks ? [...message.blocks] : blocksFromFields(message),
152
+ timestamp: message.timestamp,
153
+ ...(message.meta ? { meta: message.meta } : {}),
154
+ }));
155
+ }
156
+
157
+ /**
158
+ * The v1 form of a neutral transcript: every v1 field rebuilt from the blocks, and the blocks kept
159
+ * beside them only when those fields cannot describe them. A tool call carries no `result`.
160
+ *
161
+ * @param {readonly ChatMessage[]} messages
162
+ * @returns {LiveMessage[]}
163
+ */
164
+ export function toLiveMessages(messages: readonly ChatMessage[]): LiveMessage[] {
165
+ return messages.map((message) => {
166
+ const live: LiveMessage = {
167
+ id: message.id,
168
+ role: message.role,
169
+ content: joined(message.blocks, "text"),
170
+ timestamp: message.timestamp,
171
+ };
172
+ if (message.role === "tool") {
173
+ const reply = message.blocks.find(
174
+ (block): block is ToolResultBlock => block.type === "tool_result",
175
+ );
176
+ if (reply) {
177
+ live.content = reply.content;
178
+ live.toolCallId = reply.callId;
179
+ }
180
+ } else if (message.role === "assistant") {
181
+ if (message.blocks.some((block) => block.type === "reasoning")) {
182
+ live.reasoningContent = joined(message.blocks, "reasoning");
183
+ }
184
+ const calls = message.blocks.filter(
185
+ (block): block is ToolCallBlock => block.type === "tool_call",
186
+ );
187
+ if (calls.length > 0) {
188
+ live.toolCalls = calls.map((call): LiveToolCall => ({
189
+ id: call.id,
190
+ name: call.name,
191
+ arguments: call.argumentsText,
192
+ }));
193
+ }
194
+ }
195
+ if (!sameJson(blocksFromFields(live), message.blocks)) {
196
+ live.blocks = [...message.blocks];
197
+ }
198
+ if (message.meta) {
199
+ live.meta = message.meta;
200
+ }
201
+ return live;
202
+ });
203
+ }
204
+
205
+ /**
206
+ * What a request carries: each message's role and blocks, without the empty assistant turns no
207
+ * provider is sent (§2.2), and without ids, timestamps or meta.
208
+ *
209
+ * @param {readonly ChatMessage[]} messages
210
+ * @returns {WireMessage[]}
211
+ */
212
+ export function toWireMessages(messages: readonly ChatMessage[]): WireMessage[] {
213
+ return messages
214
+ .filter((message) => !isEmptyAssistant(message))
215
+ .map((message) => ({ role: message.role, blocks: message.blocks }));
216
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `@jxsuite/ai/messages`: the provider-neutral conversation model (specs/ai.md §2.3).
3
+ *
4
+ * A conversation is a list of {@link ChatMessage}s, each a list of typed blocks: text, reasoning,
5
+ * tool calls, tool results, and provider data with no neutral word. Chat-state and saved sessions
6
+ * keep the v1 shape ({@link LiveMessage}); `toChatMessages` and `toLiveMessages` convert between
7
+ * the two, and `toOpenAIMessages` is the OpenAI-compatible projection every request is built with.
8
+ * `repairToolPairs` puts every tool call back beside its reply before a request carries it
9
+ * (specs/ai.md §3.4).
10
+ *
11
+ * Worker-safe: it imports nothing at runtime outside itself (`tests/worker-safety.test.ts`,
12
+ * `tsconfig.worker.json`).
13
+ *
14
+ * @module @jxsuite/ai/messages
15
+ * @docs extending/embedding/assistant-harness
16
+ */
17
+
18
+ export { isEmptyAssistant, toChatMessages, toLiveMessages, toWireMessages } from "./convert.ts";
19
+ export { fromOpenAITools, toOpenAIMessages, toOpenAITools } from "./openai.ts";
20
+ export { SEAL_CUT_OFF, SEAL_RELOADED, isSealContent, repairToolPairs } from "./repair.ts";
21
+ export type { RepairReport } from "./repair.ts";
22
+
23
+ export type {
24
+ Block,
25
+ ChatMessage,
26
+ JsonArray,
27
+ JsonObject,
28
+ JsonValue,
29
+ LiveMessage,
30
+ LiveToolCall,
31
+ MessageKind,
32
+ MessageMeta,
33
+ OpaqueBlock,
34
+ OpenAIMessage,
35
+ OpenAITool,
36
+ OpenAIToolCall,
37
+ Provenance,
38
+ ProviderFamily,
39
+ ReasoningBlock,
40
+ Role,
41
+ SystemBlock,
42
+ TextBlock,
43
+ ToolCallBlock,
44
+ ToolResultBlock,
45
+ ToolSpec,
46
+ WireMessage,
47
+ } from "./types.ts";
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Openai.ts — the neutral conversation, and the tools, on the OpenAI chat-completions wire.
3
+ *
4
+ * The OpenAI-compatible projection is the one every v1 request was built with
5
+ * (`createChatState().toMessagesArray()`), stated once so a harness, a gateway and chat-state
6
+ * cannot each keep a copy that drifts (specs/ai.md §2.2, §2.3).
7
+ *
8
+ * @module @jxsuite/ai/messages
9
+ * @license MIT
10
+ */
11
+
12
+ import { isEmptyAssistant } from "./convert.ts";
13
+ import type {
14
+ ChatMessage,
15
+ OpenAIMessage,
16
+ OpenAITool,
17
+ OpenAIToolCall,
18
+ ReasoningBlock,
19
+ ToolSpec,
20
+ } from "./types.ts";
21
+
22
+ /**
23
+ * Whether a reasoning block is replayed to an OpenAI-compatible provider: it came from one (its own
24
+ * provenance, else its message's family, else the v1 default), and it is not redacted. A block
25
+ * another family signed is that family's to read, and a redacted payload is nobody's text.
26
+ */
27
+ function replays(block: ReasoningBlock, message: ChatMessage): boolean {
28
+ const family = block.provenance?.family ?? message.meta?.family ?? "openai-compat";
29
+ return family === "openai-compat" && block.redacted === undefined;
30
+ }
31
+
32
+ /**
33
+ * A conversation as an OpenAI chat-completions `messages` array. The system prompt travels
34
+ * separately.
35
+ *
36
+ * Two things it does NOT do verbatim, both because a transcript is not the wire:
37
+ *
38
+ * 1. **An assistant turn carrying neither text nor tool calls is dropped** ({@link isEmptyAssistant}).
39
+ * The one that always exists is the placeholder a turn opens with: the message being generated
40
+ * by the very request this builds. It reached providers as a trailing
41
+ * `{"role":"assistant","content":""}`, which most read as an empty prefill and ignore;
42
+ * DeepSeek's thinking mode instead answers 400 `The reasoning_content in the thinking mode must
43
+ * be passed back to the API`, because a thinking-mode assistant turn owes it one.
44
+ * 2. **`reasoning_content` is replayed when the turn has one.** DeepSeek requires the reasoning of all
45
+ * previous turns back on any request carrying `tools`, which is every request the agent loop
46
+ * makes, and ignores it on requests that carry none, so echoing what the provider itself
47
+ * streamed is safe for providers that never send it (they get no field at all).
48
+ *
49
+ * Assistant `content` is `null` when the turn made calls and wrote nothing; a `tool` message is one
50
+ * entry per result; any other role is `{ role, content }`, its text byte for byte.
51
+ *
52
+ * @param {readonly ChatMessage[]} messages @returns {OpenAIMessage[]}
53
+ */
54
+ export function toOpenAIMessages(messages: readonly ChatMessage[]): OpenAIMessage[] {
55
+ const out: OpenAIMessage[] = [];
56
+ for (const message of messages) {
57
+ if (message.role === "assistant") {
58
+ if (isEmptyAssistant(message)) {
59
+ continue;
60
+ }
61
+ let text = "";
62
+ let reasoning = "";
63
+ const calls: OpenAIToolCall[] = [];
64
+ for (const block of message.blocks) {
65
+ if (block.type === "text") {
66
+ text += block.text;
67
+ } else if (block.type === "reasoning" && replays(block, message)) {
68
+ reasoning += block.text;
69
+ } else if (block.type === "tool_call") {
70
+ calls.push({
71
+ id: block.id,
72
+ type: "function",
73
+ function: { name: block.name, arguments: block.argumentsText },
74
+ });
75
+ }
76
+ }
77
+ const entry: Extract<OpenAIMessage, { role: "assistant" }> = {
78
+ role: "assistant",
79
+ content: calls.length > 0 ? text || null : text,
80
+ };
81
+ if (reasoning) {
82
+ entry.reasoning_content = reasoning;
83
+ }
84
+ if (calls.length > 0) {
85
+ entry.tool_calls = calls;
86
+ }
87
+ out.push(entry);
88
+ } else if (message.role === "tool") {
89
+ for (const block of message.blocks) {
90
+ if (block.type === "tool_result") {
91
+ out.push({ role: "tool", tool_call_id: block.callId, content: block.content });
92
+ }
93
+ }
94
+ } else {
95
+ let text = "";
96
+ for (const block of message.blocks) {
97
+ if (block.type === "text") {
98
+ text += block.text;
99
+ }
100
+ }
101
+ out.push({ role: message.role, content: text });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Tools as an OpenAI chat-completions `tools` array, in `ToolRegistry.listForLLM()`'s shape: the
109
+ * same members in the same order, so the serialized request is byte for byte what it was. A tool's
110
+ * `title` has no OpenAI field and is not sent.
111
+ *
112
+ * @param {readonly ToolSpec[]} tools
113
+ * @returns {OpenAITool[]}
114
+ */
115
+ export function toOpenAITools(tools: readonly ToolSpec[]): OpenAITool[] {
116
+ return tools.map((tool) => ({
117
+ type: "function",
118
+ function: {
119
+ name: tool.name,
120
+ description: tool.description,
121
+ parameters: tool.inputSchema,
122
+ ...(tool.strict === true ? { strict: true as const } : {}),
123
+ },
124
+ }));
125
+ }
126
+
127
+ /**
128
+ * The tools an OpenAI chat-completions `tools` array describes. Each schema is kept by reference,
129
+ * so a schema built with live getters still serializes live.
130
+ *
131
+ * @param {readonly object[]} tools - `ToolRegistry.listForLLM()`, or any array of that shape
132
+ * @returns {ToolSpec[]}
133
+ * @throws {TypeError} When an entry is not a function tool with a name, a description and a schema
134
+ */
135
+ export function fromOpenAITools(tools: readonly object[]): ToolSpec[] {
136
+ return tools.map((tool, index) => {
137
+ const fn = (tool as { type?: unknown; function?: unknown }).function as
138
+ | { name?: unknown; description?: unknown; parameters?: unknown; strict?: unknown }
139
+ | undefined;
140
+ if (
141
+ (tool as { type?: unknown }).type !== "function" ||
142
+ typeof fn !== "object" ||
143
+ fn === null ||
144
+ typeof fn.name !== "string" ||
145
+ typeof fn.description !== "string" ||
146
+ typeof fn.parameters !== "object" ||
147
+ fn.parameters === null
148
+ ) {
149
+ throw new TypeError(
150
+ `tools[${index}] is not an OpenAI function tool with a name, a description and parameters`,
151
+ );
152
+ }
153
+ return {
154
+ name: fn.name,
155
+ description: fn.description,
156
+ inputSchema: fn.parameters as Readonly<Record<string, unknown>>,
157
+ ...(fn.strict === true ? { strict: true } : {}),
158
+ };
159
+ });
160
+ }