@jxsuite/ai 0.39.5 → 0.41.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.39.5",
3
+ "version": "0.41.0",
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": {
@@ -20,7 +20,9 @@
20
20
  "./chat-state": "./src/chat-state.ts",
21
21
  "./gateway": "./src/gateway/index.ts",
22
22
  "./harness": "./src/harness/index.ts",
23
- "./messages": "./src/messages/index.ts"
23
+ "./messages": "./src/messages/index.ts",
24
+ "./jx-tools": "./src/jx-tools/index.ts",
25
+ "./catalog": "./src/catalog/index.ts"
24
26
  },
25
27
  "publishConfig": {
26
28
  "provenance": true
@@ -31,11 +33,12 @@
31
33
  "upgrade": "bun update --latest && bun install"
32
34
  },
33
35
  "dependencies": {
34
- "@jxsuite/protocol": "^4.0.0",
36
+ "@jxsuite/protocol": "^5.0.0",
37
+ "@jxsuite/schema": "^4.1.0",
35
38
  "@vue/reactivity": "3.5.44"
36
39
  },
37
40
  "devDependencies": {
38
- "@cloudflare/workers-types": "^5.20261016.1",
39
- "@happy-dom/global-registrator": "^21.0.3"
41
+ "@cloudflare/workers-types": "^5.20261026.1",
42
+ "@happy-dom/global-registrator": "^21.0.4"
40
43
  }
41
44
  }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tool catalogs (specs/ai.md §3.10): which tools a host has (membership), and which of them the
3
+ * model is offered and may call right now (advertisement and the gate).
4
+ *
5
+ * @module @jxsuite/ai/catalog
6
+ * @license MIT
7
+ */
8
+
9
+ import { isFunction } from "@jxsuite/schema/guards";
10
+ import { createToolRegistry, toolError } from "../tools.ts";
11
+ import type {
12
+ ToolAnnotations,
13
+ ToolArguments,
14
+ ToolContext,
15
+ ToolDefinition,
16
+ ToolRegistry,
17
+ } from "../tools.ts";
18
+
19
+ /** What a tool's name may be: what every provider and MCP accept. */
20
+ export const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
21
+
22
+ /** A catalog member: a tool definition with the title and the hints a catalog requires. */
23
+ export type CatalogEntry = ToolDefinition & {
24
+ readonly title: string;
25
+ readonly annotations: ToolAnnotations;
26
+ };
27
+
28
+ /** A host's tools, each name once, in the order they were given. */
29
+ export interface Catalog {
30
+ readonly entries: readonly CatalogEntry[];
31
+ /** The member named `name`. */
32
+ readonly get: (name: string) => CatalogEntry | undefined;
33
+ }
34
+
35
+ /** Why a catalog refused a tool. */
36
+ export class CatalogError extends Error {
37
+ /**
38
+ * `name`: the name is not {@link TOOL_NAME_PATTERN}. `duplicate`: two tools share it. `schema`:
39
+ * its parameters are not an object schema. `annotations`: it has no title or no hints.
40
+ */
41
+ readonly code: "name" | "duplicate" | "schema" | "annotations";
42
+
43
+ constructor(code: CatalogError["code"], message: string) {
44
+ super(message);
45
+ this.name = "CatalogError";
46
+ this.code = code;
47
+ }
48
+ }
49
+
50
+ /** The entry, or the reason it cannot be one. */
51
+ function admit(tool: ToolDefinition): CatalogEntry {
52
+ if (!TOOL_NAME_PATTERN.test(tool.name)) {
53
+ throw new CatalogError("name", `Tool name "${tool.name}" is not ${TOOL_NAME_PATTERN}.`);
54
+ }
55
+ if (tool.parameters.type !== "object") {
56
+ throw new CatalogError("schema", `Tool "${tool.name}" parameters are not an object schema.`);
57
+ }
58
+ const { annotations, title } = tool;
59
+ if (title === undefined || annotations === undefined) {
60
+ throw new CatalogError("annotations", `Tool "${tool.name}" has no title or no annotations.`);
61
+ }
62
+ return { ...tool, annotations, title };
63
+ }
64
+
65
+ /**
66
+ * A catalog of `groups`, in order. Membership is decided here, once: a tool is a member or the
67
+ * catalog is not built.
68
+ *
69
+ * @param {...(readonly ToolDefinition[])} groups
70
+ * @returns {Catalog}
71
+ * @throws {CatalogError} For an invalid or duplicate name, a non-object parameter schema, or a
72
+ * missing title or annotations
73
+ */
74
+ export function createCatalog(...groups: readonly (readonly ToolDefinition[])[]): Catalog {
75
+ const byName = new Map<string, CatalogEntry>();
76
+ for (const tool of groups.flat()) {
77
+ if (byName.has(tool.name)) {
78
+ throw new CatalogError("duplicate", `Tool "${tool.name}" is in the catalog twice.`);
79
+ }
80
+ byName.set(tool.name, admit(tool));
81
+ }
82
+ const entries = [...byName.values()];
83
+ return { entries, get: (name) => byName.get(name) };
84
+ }
85
+
86
+ /**
87
+ * Whether `entry` may be called with `args` right now: null, or what it requires (the end of the
88
+ * sentence the model reads, such as `"an open project"`). `args` is null when the question is
89
+ * whether to offer the tool at all.
90
+ */
91
+ export type Gate = (entry: CatalogEntry, args: ToolArguments | null) => string | null;
92
+
93
+ /** How a host sees its catalog this round. */
94
+ export interface CatalogView {
95
+ /** Refuses a member, at listing and at call time alike. */
96
+ readonly gate?: Gate;
97
+ /**
98
+ * Narrows what the model is offered, without refusing the call: a member it returns false for is
99
+ * not listed, and a call to it still runs.
100
+ */
101
+ readonly advertise?: (entry: CatalogEntry) => boolean;
102
+ }
103
+
104
+ /** A catalog with the plain registry that validates and runs its members. */
105
+ interface CatalogRun {
106
+ members: Catalog;
107
+ runner: ToolRegistry;
108
+ }
109
+
110
+ /**
111
+ * The registry a turn reads its tools from: `list()` is the members, `listForLLM()` the members the
112
+ * view admits, and `execute` refuses a call the gate refuses, naming what it requires. `catalog`
113
+ * may be a function, read on every call, for a host whose membership follows its own state.
114
+ *
115
+ * @param {Catalog | (() => Catalog)} catalog
116
+ * @param {CatalogView} [view]
117
+ * @returns {ToolRegistry}
118
+ */
119
+ export function catalogRegistry(
120
+ catalog: Catalog | (() => Catalog),
121
+ { advertise, gate }: CatalogView = {},
122
+ ): ToolRegistry {
123
+ const runners = new WeakMap<Catalog, ToolRegistry>();
124
+
125
+ /** The catalog now, and the registry that validates and runs its members. */
126
+ function current(): CatalogRun {
127
+ const members = isFunction(catalog) ? catalog() : catalog;
128
+ let runner = runners.get(members);
129
+ if (!runner) {
130
+ runner = createToolRegistry();
131
+ for (const entry of members.entries) {
132
+ runner.register(entry);
133
+ }
134
+ runners.set(members, runner);
135
+ }
136
+ return { members, runner };
137
+ }
138
+
139
+ function offered(entry: CatalogEntry): boolean {
140
+ return (gate?.(entry, null) ?? null) === null && (advertise?.(entry) ?? true);
141
+ }
142
+
143
+ return {
144
+ register() {
145
+ throw new TypeError("A catalog's members are given to createCatalog, not registered.");
146
+ },
147
+ list: () => [...current().members.entries],
148
+ listForLLM() {
149
+ const { members, runner } = current();
150
+ const names = new Set(members.entries.filter(offered).map((entry) => entry.name));
151
+ // The runner's own listing, so the bytes are the ones a plain registry sends.
152
+ return runner.listForLLM().filter((_tool, at) => names.has(members.entries[at]!.name));
153
+ },
154
+ getDefinition: (name) => current().members.get(name),
155
+ validate: (name, args) => current().runner.validate(name, args),
156
+ async execute(name: string, args: ToolArguments, ctx?: ToolContext) {
157
+ const { members, runner } = current();
158
+ const entry = members.get(name);
159
+ const requires = entry ? (gate?.(entry, args) ?? null) : null;
160
+ if (requires !== null) {
161
+ return toolError(`Tool "${name}" is not available right now — it requires ${requires}.`);
162
+ }
163
+ return runner.execute(name, args, ctx);
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@jxsuite/ai/catalog`: a host's tools as one catalog (specs/ai.md §3.10). Membership is decided
3
+ * once, by `createCatalog`, which refuses a duplicate or malformed tool; what the model is offered
4
+ * each round, and which calls are refused, is the host's view over it. `toMcpTool` and
5
+ * `toCallToolResult` project members and results into MCP's shapes.
6
+ *
7
+ * Worker-safe: the module graph is this directory, `../tools.ts` and `@jxsuite/schema/guards`.
8
+ *
9
+ * @module @jxsuite/ai/catalog
10
+ * @license MIT
11
+ * @docs extending/embedding/assistant-harness
12
+ */
13
+
14
+ export { catalogRegistry, CatalogError, createCatalog, TOOL_NAME_PATTERN } from "./catalog.ts";
15
+ export type { Catalog, CatalogEntry, CatalogView, Gate } from "./catalog.ts";
16
+ export { toCallToolResult, toMcpTool } from "./mcp.ts";
17
+ export type { McpCallToolResult, McpToolJson } from "./mcp.ts";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A catalog member as the Model Context Protocol names a tool, and a tool result as MCP returns one
3
+ * (specs/ai.md §3.10). Pure data: no MCP SDK is imported, so any host can serve them.
4
+ *
5
+ * @module @jxsuite/ai/catalog
6
+ * @license MIT
7
+ */
8
+
9
+ import type { Undoability } from "../jx-tools/types.ts";
10
+ import type { JSONSchema, ToolResult } from "../tools.ts";
11
+ import type { CatalogEntry } from "./catalog.ts";
12
+
13
+ /** A tool as an MCP `tools/list` result names it. */
14
+ export interface McpToolJson {
15
+ name: string;
16
+ title: string;
17
+ description: string;
18
+ inputSchema: JSONSchema;
19
+ /** All four, always stated: MCP's defaults are the opposite of a safe guess for some. */
20
+ annotations: {
21
+ readOnlyHint: boolean;
22
+ destructiveHint: boolean;
23
+ idempotentHint: boolean;
24
+ openWorldHint: boolean;
25
+ };
26
+ _meta: { "com.jxsuite/undoable": Undoability };
27
+ }
28
+
29
+ /**
30
+ * `entry` as an MCP tool. `undoable` is the serving host's: whether it can take the tool's writes
31
+ * back.
32
+ *
33
+ * @param {CatalogEntry} entry
34
+ * @param {{ undoable: Undoability }} facts
35
+ * @returns {McpToolJson}
36
+ */
37
+ export function toMcpTool(
38
+ entry: CatalogEntry,
39
+ { undoable }: { undoable: Undoability },
40
+ ): McpToolJson {
41
+ const { destructiveHint, idempotentHint, openWorldHint, readOnlyHint } = entry.annotations;
42
+ return {
43
+ _meta: { "com.jxsuite/undoable": undoable },
44
+ annotations: { destructiveHint, idempotentHint, openWorldHint, readOnlyHint },
45
+ description: entry.description,
46
+ inputSchema: entry.parameters,
47
+ name: entry.name,
48
+ title: entry.title,
49
+ };
50
+ }
51
+
52
+ /** An MCP `tools/call` result. */
53
+ export interface McpCallToolResult {
54
+ content: { type: "text"; text: string }[];
55
+ isError?: true;
56
+ }
57
+
58
+ /**
59
+ * A tool result as MCP returns it: the summary, then the data as JSON, each its own text block; a
60
+ * failure is its error, flagged. A success with neither is the result itself, as JSON.
61
+ *
62
+ * @param {ToolResult} result
63
+ * @returns {McpCallToolResult}
64
+ */
65
+ export function toCallToolResult(result: ToolResult): McpCallToolResult {
66
+ if (!result.success) {
67
+ return { content: [{ text: result.error ?? "The tool failed.", type: "text" }], isError: true };
68
+ }
69
+ const texts: string[] = [];
70
+ if (result.summary !== undefined) {
71
+ texts.push(result.summary);
72
+ }
73
+ if (result.data !== undefined) {
74
+ texts.push(JSON.stringify(result.data));
75
+ }
76
+ if (texts.length === 0) {
77
+ texts.push(JSON.stringify(result));
78
+ }
79
+ return { content: texts.map((text) => ({ text, type: "text" })) };
80
+ }
@@ -16,10 +16,11 @@
16
16
  export { LaneBusyError, createTurnLock } from "./lock.ts";
17
17
  export { fromStreamingClient, joinSystem } from "./model.ts";
18
18
  export { CHARS_PER_TOKEN, DEFAULT_TURN_POLICY } from "./policy.ts";
19
- export { runTurn } from "./run.ts";
19
+ export { assistantActorId, runTurn } from "./run.ts";
20
20
 
21
21
  export type {
22
22
  HarnessEvent,
23
+ InteractionStrategy,
23
24
  ModelFn,
24
25
  ModelRequest,
25
26
  TurnError,
@@ -5,6 +5,7 @@
5
5
  * @license MIT
6
6
  */
7
7
 
8
+ import { proceedAlways } from "../tools.ts";
8
9
  import type { TurnPolicy } from "./types.ts";
9
10
 
10
11
  /** Average characters per token: the estimate every budget used before providers reported counts. */
@@ -36,4 +37,5 @@ export const DEFAULT_TURN_POLICY: TurnPolicy = {
36
37
  );
37
38
  },
38
39
  emptyText: "The model sent back an empty reply.",
40
+ approval: proceedAlways,
39
41
  };
@@ -35,8 +35,9 @@ import {
35
35
  createToolContext,
36
36
  invokeTool,
37
37
  linkCallSignal,
38
+ toolError,
38
39
  } from "../tools.ts";
39
- import type { Actor } from "../tools.ts";
40
+ import type { Actor, ToolCall, ToolContext } from "../tools.ts";
40
41
  import { LaneBusyError } from "./lock.ts";
41
42
  import { joinSystem } from "./model.ts";
42
43
  import { DEFAULT_TURN_POLICY } from "./policy.ts";
@@ -136,6 +137,15 @@ function createEventQueue() {
136
137
  };
137
138
  }
138
139
 
140
+ /**
141
+ * The id a turn's {@link Actor} carries: `assistant:<sessionId|local>:<turnId>`. A host that keys
142
+ * anything on the actor before the turn starts (Studio's batch lane, specs/ai.md §3.3) builds it
143
+ * here, so the key it opens cannot drift from the one each call's context will name.
144
+ */
145
+ export function assistantActorId(sessionId: string | null, turnId: string): string {
146
+ return `assistant:${sessionId ?? "local"}:${turnId}`;
147
+ }
148
+
139
149
  /**
140
150
  * Start one turn. It begins at once and runs to its end whether or not anyone reads its events;
141
151
  * `onEvent` hears each one synchronously, in order, before the engine takes its next step.
@@ -182,7 +192,7 @@ export function runTurn(input: TurnInput): TurnRun {
182
192
  const session = input.session ?? createSessionFacts();
183
193
  const ledger = input.ledger ?? createLedger();
184
194
  const sessionId = input.sessionId === undefined ? session.sessionId : input.sessionId;
185
- const actorId = `assistant:${sessionId ?? "local"}:${turnId}`;
195
+ const actorId = assistantActorId(sessionId, turnId);
186
196
  const actor: Actor = input.modelInfo
187
197
  ? { id: actorId, kind: "assistant", model: input.modelInfo.model, sessionId, turnId }
188
198
  : { id: actorId, kind: "assistant", sessionId, turnId };
@@ -217,6 +227,30 @@ export function runTurn(input: TurnInput): TurnRun {
217
227
  .slice(answered + 1)
218
228
  .some((message) => message.role === "assistant" && !isEmptyAssistant(message));
219
229
 
230
+ const interactions = input.interactions ?? { mode: "none" };
231
+
232
+ /**
233
+ * Invoke one call to its result. A call that needs a person is put to the host's strategy and
234
+ * invoked again with the answer, so no tool body ever awaits a person (specs/ai.md §3.11).
235
+ */
236
+ async function invoke(call: ToolCall, ctx: ToolContext, callSignal: AbortSignal) {
237
+ let outcome = await invokeTool(input.tools, call, ctx, { approval: policy.approval });
238
+ while (outcome.status === "needs_input") {
239
+ if (interactions.mode === "none") {
240
+ return toolError(`${call.name} needs a person, and none is available here.`);
241
+ }
242
+ const { request } = outcome;
243
+ emit({ type: "interaction_request", callId: call.callId, request });
244
+ const response = await interactions.resolve(request, {
245
+ callId: call.callId,
246
+ signal: callSignal,
247
+ });
248
+ emit({ type: "interaction_settled", callId: call.callId, response });
249
+ outcome = await invokeTool(input.tools, call, ctx, { approval: policy.approval, response });
250
+ }
251
+ return outcome.result;
252
+ }
253
+
220
254
  async function loop(): Promise<TurnOutcome> {
221
255
  const appended: ChatMessage[] = [];
222
256
  const applied: string[] = [];
@@ -412,11 +446,11 @@ export function runTurn(input: TurnInput): TurnRun {
412
446
  });
413
447
  let result;
414
448
  try {
415
- ({ result } = await invokeTool(
416
- input.tools,
449
+ result = await invoke(
417
450
  { callId, name: call.name, argumentsText: call.argumentsText },
418
451
  ctx,
419
- ));
452
+ link.signal,
453
+ );
420
454
  } finally {
421
455
  link.release();
422
456
  }
@@ -7,7 +7,16 @@
7
7
  * @license MIT
8
8
  */
9
9
 
10
- import type { AiWrite, SessionFacts, ToolRegistry, ToolResult, WriteLedger } from "../tools.ts";
10
+ import type {
11
+ AiWrite,
12
+ ApprovalPolicy,
13
+ InteractionRequest,
14
+ InteractionResponse,
15
+ SessionFacts,
16
+ ToolRegistry,
17
+ ToolResult,
18
+ WriteLedger,
19
+ } from "../tools.ts";
11
20
  import type {
12
21
  ChatMessage,
13
22
  JsonValue,
@@ -74,8 +83,28 @@ export interface TurnPolicy {
74
83
  }) => string;
75
84
  /** What a turn that drew nothing says. */
76
85
  readonly emptyText: string;
86
+ /** Whether each call needs the author's approval before it runs. Defaults to `proceedAlways`. */
87
+ readonly approval: ApprovalPolicy;
77
88
  }
78
89
 
90
+ /**
91
+ * How a turn gets a person's answer when a call needs one (specs/ai.md §3.11): `inline` asks the
92
+ * host and waits on the answer within the turn, and `none` answers that nobody is available.
93
+ */
94
+ export type InteractionStrategy =
95
+ | {
96
+ readonly mode: "inline";
97
+ /**
98
+ * The person's answer to `request`. Resolve it when the call's `signal` aborts too, with the
99
+ * answer a stopped turn gives (for a question, unanswered and not skipped).
100
+ */
101
+ readonly resolve: (
102
+ request: InteractionRequest,
103
+ at: { readonly callId: string; readonly signal: AbortSignal },
104
+ ) => Promise<InteractionResponse>;
105
+ }
106
+ | { readonly mode: "none" };
107
+
79
108
  /** What a host can do between the engine's steps. */
80
109
  export interface TurnHooks {
81
110
  /** After a call has settled, before its result is reported. */
@@ -123,6 +152,8 @@ export interface TurnInput {
123
152
  readonly now?: () => number;
124
153
  readonly policy?: Partial<TurnPolicy>;
125
154
  readonly hooks?: TurnHooks;
155
+ /** How a call that needs a person gets their answer. Defaults to `{ mode: "none" }`. */
156
+ readonly interactions?: InteractionStrategy;
126
157
  /** What the conversation's tools remember across turns. Defaults to a fresh set. */
127
158
  readonly session?: SessionFacts;
128
159
  /** Where the turn's tools record their writes. Defaults to a fresh ledger. */
@@ -218,6 +249,18 @@ export type HarnessEvent = EventBase &
218
249
  readonly name: string;
219
250
  readonly interactive: boolean;
220
251
  }
252
+ | {
253
+ /** The call needs a person before it can run, and the host was asked. */
254
+ readonly type: "interaction_request";
255
+ readonly callId: string;
256
+ readonly request: InteractionRequest;
257
+ }
258
+ | {
259
+ /** The person answered (or the turn's stop answered for them). */
260
+ readonly type: "interaction_settled";
261
+ readonly callId: string;
262
+ readonly response: InteractionResponse;
263
+ }
221
264
  | { readonly type: "tool_progress"; readonly callId: string; readonly progress: JsonValue }
222
265
  | { readonly type: "write"; readonly callId: string; readonly write: AiWrite }
223
266
  | {