@jskit-ai/assistant-runtime 0.2.46 → 0.2.47

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/CHANGELOG.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `assistantServer.<surface>.maxToolArgumentBytes` (a whole number of bytes, at most 1 MiB) raises a surface's limit on an application tool call's arguments from the default 32 KiB, for an assistant that writes a large document in one call. The surface's own limit is the one its conversations' calls are held to and the one every engine is given (the OpenCode tool connection included); before, the runtime enforced and handed engines the default surface's 32 KiB whatever a surface's catalogue allowed. `assistantServer.<surface>.maxToolResultBytes` (also at most 1 MiB) raises its limit on a tool call's result from the default 48 KiB in the same way, for an assistant that reads a large document back: a larger result reaches the model as `assistant_tool_result_too_large` instead. A malformed value of either stops the server from starting. **Breaking.** The surface-aware tool catalogue no longer has a `limits` property, which always answered the default surface's: each tool set it resolves carries its surface's `limits`. New migration `assistant_transcripts_tool_arguments`: on MySQL and MariaDB, `assistant_messages.content_text` becomes LONGTEXT, so a saved call's arguments and result may pass TEXT's 64 KiB; PostgreSQL and SQLite are unchanged.
5
6
  - A Set or Resume goal that the server refuses after its message already appeared in the conversation shows the server's reason (for example that Claude's goal attachment is not available yet) instead of only `jskit.assistant.runtime.goal.changeFailed` ("Could not change the goal."): the transcript turn settled the optimistic message first, so the failure's own text was dropped with it.
6
7
  - The saved-conversations list (`GET /api/assistant/:surfaceId/conversations`, and its workspace form) answers 200 again instead of 500 ("metadata is required!") once a person has a conversation: the route still serialized each item with the conversation's read schema, which requires `metadata`, after list items stopped carrying it. It serializes them with assistant-core's `assistantConversationListItemOutputValidator`, the item of `assistantContract`'s `conversationsList`. `conversationsListRoute.test.js` lists a saved conversation over HTTP through the real route, actions and SQL repositories.
7
8
  - Each conversation request authenticates once: the route's access check and the action's request context share one sign-in lookup instead of each asking again.
@@ -88,7 +88,7 @@ export default defineConfig({
88
88
  async createConversationForTurn() { return { conversation: { id: "100" } }; },
89
89
  async completeConversation() {}
90
90
  },
91
- serviceToolCatalog: { limits: { maxToolArgumentBytes: 64 * 1024 }, resolveToolSet: () => ({ tools: [] }),
91
+ serviceToolCatalog: { resolveToolSet: () => ({ tools: [], limits: { maxToolArgumentBytes: 64 * 1024 } }),
92
92
  toOpenAiToolSchema: tool => ({ type: "function", function: tool }) },
93
93
  assistantConfigService: { resolveSystemPrompt: async () => "Answer the user." },
94
94
  personContextPrompt: createPersonContextPrompt({ personContext: NO_PERSON_CONTEXT, logger: silentLogger }),
@@ -0,0 +1,14 @@
1
+ // An application tool call's arguments are saved in its tool_call row's content_text and its result in
2
+ // its tool_result row's, and a surface may allow up to 1 MiB of each
3
+ // (assistantServer.<surface>.maxToolArgumentBytes and maxToolResultBytes). MySQL's TEXT holds 64 KiB,
4
+ // so on MySQL and MariaDB content_text becomes LONGTEXT; PostgreSQL's and SQLite's text has no such
5
+ // limit and stays as it is.
6
+ exports.up = async function up(knex) {
7
+ if (!/mysql|maria/iu.test(String(knex.client.config.client))) return;
8
+ await knex.schema.alterTable("assistant_messages", table => {
9
+ table.text("content_text", "longtext").nullable().alter();
10
+ });
11
+ };
12
+
13
+ // Narrowing content_text back to TEXT would cut longer saved calls and results, so it stays LONGTEXT.
14
+ exports.down = async function down() {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -15,7 +15,7 @@
15
15
  "./locales/en.json": "./locales/en.json"
16
16
  },
17
17
  "dependencies": {
18
- "@jskit-ai/assistant-core": "0.2.44",
18
+ "@jskit-ai/assistant-core": "0.2.45",
19
19
  "@jskit-ai/resource-core": "0.1.211",
20
20
  "json-rest-schema": "^1.0.20"
21
21
  },
@@ -29,7 +29,7 @@ function createService({
29
29
  appConfig,
30
30
  workspaceScopeSupport
31
31
  }) {
32
- // Read once here, so a malformed settingsUpdatePermission stops the server from starting.
32
+ // Read once here, so a malformed settingsUpdatePermission, maxToolArgumentBytes or maxToolResultBytes stops the server from starting.
33
33
  resolveAssistantServerConfigMap(appConfig);
34
34
 
35
35
  /**
@@ -7,6 +7,8 @@ import {
7
7
  } from "@jskit-ai/assistant-core/server";
8
8
 
9
9
  const SETTINGS_UPDATE_REQUIRE_MODES = Object.freeze(["authenticated", "all", "any"]);
10
+ // The most a surface may raise its application tool argument and result limits to: 1 MiB.
11
+ const MAX_TOOL_BYTES_CEILING = 1024 * 1024;
10
12
 
11
13
  /**
12
14
  * Who may change a surface's assistant settings, as configured: a permission, an auth policy
@@ -36,6 +38,27 @@ function normalizeSettingsUpdatePermission(value, targetSurfaceId) {
36
38
  );
37
39
  }
38
40
 
41
+ /**
42
+ * A surface's limit in bytes on its application tool calls' arguments (`maxToolArgumentBytes`) or
43
+ * results (`maxToolResultBytes`), as configured, or null for the tool catalogue's default (32 KiB of
44
+ * arguments, 48 KiB of result).
45
+ * @param {Record<string, unknown>} entry
46
+ * @param {"maxToolArgumentBytes" | "maxToolResultBytes"} key
47
+ * @param {string} targetSurfaceId
48
+ */
49
+ function normalizeMaxToolBytes(entry, key, targetSurfaceId) {
50
+ const value = entry[key];
51
+ if (value === undefined) {
52
+ return null;
53
+ }
54
+ if (Number.isSafeInteger(value) && value >= 1 && value <= MAX_TOOL_BYTES_CEILING) {
55
+ return value;
56
+ }
57
+ throw new TypeError(
58
+ `assistantServer.${targetSurfaceId}.${key} must be a whole number of bytes from 1 to ${MAX_TOOL_BYTES_CEILING} (1 MiB).`
59
+ );
60
+ }
61
+
39
62
  function resolveAssistantServerConfigMap(appConfig) {
40
63
  const source = normalizeObject(appConfig?.assistantServer);
41
64
  const resolved = {};
@@ -58,6 +81,8 @@ function resolveAssistantServerConfigMap(appConfig) {
58
81
  timeoutMs: entry.timeoutMs,
59
82
  barredActionIds: Object.freeze(normalizeUniqueTextList(entry.barredActionIds, { acceptSingle: true })),
60
83
  toolSkipActionPrefixes: Object.freeze(normalizeUniqueTextList(entry.toolSkipActionPrefixes, { acceptSingle: true })),
84
+ maxToolArgumentBytes: normalizeMaxToolBytes(entry, "maxToolArgumentBytes", targetSurfaceId),
85
+ maxToolResultBytes: normalizeMaxToolBytes(entry, "maxToolResultBytes", targetSurfaceId),
61
86
  settingsUpdatePermission: normalizeSettingsUpdatePermission(entry.settingsUpdatePermission, targetSurfaceId)
62
87
  });
63
88
  }
@@ -8,7 +8,9 @@ function buildCatalogOptions(appConfig = {}, surfaceId = "") {
8
8
 
9
9
  return Object.freeze({
10
10
  barredActionIds: surfaceConfig.barredActionIds || [],
11
- skipActionPrefixes: Object.freeze(["assistant.", ...(surfaceConfig.toolSkipActionPrefixes || [])])
11
+ skipActionPrefixes: Object.freeze(["assistant.", ...(surfaceConfig.toolSkipActionPrefixes || [])]),
12
+ ...(surfaceConfig.maxToolArgumentBytes ? { maxToolArgumentBytes: surfaceConfig.maxToolArgumentBytes } : {}),
13
+ ...(surfaceConfig.maxToolResultBytes ? { maxToolResultBytes: surfaceConfig.maxToolResultBytes } : {})
12
14
  });
13
15
  }
14
16
 
@@ -24,9 +26,10 @@ function requireContextSurfaceId(context) {
24
26
  /**
25
27
  * The assistant's tool catalogue over an application's actions, resolved per surface. Each surface
26
28
  * offers the actions its `assistantServer` configuration leaves in (its barred action ids and
27
- * skipped prefixes taken out), and every call names its surface in `context.surface`. The assistant
28
- * runtime builds its catalogue this way; an application test builds one the same way to see the
29
- * tools a surface offers and to call them.
29
+ * skipped prefixes taken out), and its tool sets carry its limits, `maxToolArgumentBytes` and
30
+ * `maxToolResultBytes` included, which its calls are held to. Every call names its surface in
31
+ * `context.surface`. The assistant runtime builds its catalogue this way; an application test builds
32
+ * one the same way to see the tools a surface offers and to call them.
30
33
  * @param {import("@jskit-ai/kernel/server/types").ActionCatalogue} actions The application's actions (`runtime.actions`).
31
34
  * @param {object} [options]
32
35
  * @param {Record<string, any>} [options.appConfig] The server configuration that holds `assistantServer`.
@@ -72,10 +75,6 @@ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, createCatalog
72
75
  }
73
76
 
74
77
  return Object.freeze({
75
- get limits() {
76
- if (!schemaCatalog) schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
77
- return schemaCatalog.limits;
78
- },
79
78
  resolveToolSet,
80
79
  toOpenAiToolSchema,
81
80
  executeToolCall
@@ -42,6 +42,8 @@ test("assistant server config resolves per-surface AI config without app-level g
42
42
  timeoutMs: 45_000,
43
43
  barredActionIds: ["demo.admin.secret"],
44
44
  toolSkipActionPrefixes: ["demo.hidden."],
45
+ maxToolArgumentBytes: null,
46
+ maxToolResultBytes: null,
45
47
  settingsUpdatePermission: null
46
48
  });
47
49
  assert.deepEqual(resolveAssistantAiConfig({ appConfig, env }, "admin"), {
@@ -73,3 +75,18 @@ test("assistant server config requires explicit aiConfigPrefix for each surface"
73
75
  /requires assistantServer\.admin\.aiConfigPrefix/
74
76
  );
75
77
  });
78
+
79
+ for (const [key, fallback] of [["maxToolArgumentBytes", "32 KiB"], ["maxToolResultBytes", "48 KiB"]]) {
80
+ test(`assistantServer.<surface>.${key} raises a surface's tool ${key === "maxToolArgumentBytes" ? "argument" : "result"} limit up to 1 MiB`, () => {
81
+ const resolve = value => resolveAssistantServerConfig({ assistantServer: { writer: { [key]: value } } }, "writer");
82
+ assert.equal(resolve(undefined)[key], null, `Absent, the surface keeps the catalogue's ${fallback}`);
83
+ assert.equal(resolve(128 * 1024)[key], 128 * 1024);
84
+ assert.equal(resolve(1024 * 1024)[key], 1024 * 1024);
85
+ for (const invalid of [0, -1, 1.5, "65536", 1024 * 1024 + 1, Number.POSITIVE_INFINITY, null, true]) {
86
+ assert.throws(() => resolve(invalid), {
87
+ name: "TypeError",
88
+ message: new RegExp(`^assistantServer\\.writer\\.${key} must be`, "u")
89
+ }, String(invalid));
90
+ }
91
+ });
92
+ }
@@ -148,9 +148,8 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
148
148
  }
149
149
  },
150
150
  serviceToolCatalog: {
151
- limits: { maxToolArgumentBytes: 64 * 1024 },
152
151
  resolveToolSet() {
153
- return { tools };
152
+ return { tools, limits: { maxToolArgumentBytes: 64 * 1024 } };
154
153
  },
155
154
  toOpenAiToolSchema(tool) {
156
155
  return {
@@ -1,5 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
4
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
3
5
  import { createSurfaceAwareToolCatalog } from "../src/server/support/createSurfaceAwareToolCatalog.js";
4
6
 
5
7
  test("surface-aware tool catalog applies per-surface skip and barred configuration", async () => {
@@ -12,7 +14,9 @@ test("surface-aware tool catalog applies per-surface skip and barred configurati
12
14
  assistantServer: {
13
15
  admin: {
14
16
  barredActionIds: ["demo.admin.secret"],
15
- toolSkipActionPrefixes: ["demo.admin."]
17
+ toolSkipActionPrefixes: ["demo.admin."],
18
+ maxToolArgumentBytes: 65536,
19
+ maxToolResultBytes: 131072
16
20
  },
17
21
  console: {
18
22
  barredActionIds: ["demo.console.secret"]
@@ -65,7 +69,9 @@ test("surface-aware tool catalog applies per-surface skip and barred configurati
65
69
  assert.deepEqual(observed, [
66
70
  {
67
71
  barredActionIds: ["demo.admin.secret"],
68
- skipActionPrefixes: ["assistant.", "demo.admin."]
72
+ skipActionPrefixes: ["assistant.", "demo.admin."],
73
+ maxToolArgumentBytes: 65536,
74
+ maxToolResultBytes: 131072
69
75
  },
70
76
  {
71
77
  barredActionIds: ["demo.console.secret"],
@@ -74,10 +80,66 @@ test("surface-aware tool catalog applies per-surface skip and barred configurati
74
80
  ]);
75
81
  assert.deepEqual(execution.options, {
76
82
  barredActionIds: ["demo.admin.secret"],
77
- skipActionPrefixes: ["assistant.", "demo.admin."]
83
+ skipActionPrefixes: ["assistant.", "demo.admin."],
84
+ maxToolArgumentBytes: 65536,
85
+ maxToolResultBytes: 131072
78
86
  });
79
87
  });
80
88
 
89
+ test("each surface's tool sets carry and enforce its maxToolArgumentBytes", async () => {
90
+ const lengths = [];
91
+ const actions = createActionCatalogue();
92
+ actions.register({ contributorId: "test.documents", domain: "test", actions: [{
93
+ id: "documents.propose", version: 1, kind: "command", channels: ["automation"], surfaces: ["writer", "home"],
94
+ permission: { require: "authenticated" },
95
+ input: { schema: createSchema({ text: { type: "string", required: true } }), mode: "replace" },
96
+ output: { schema: createSchema({ length: { type: "number", required: true } }), mode: "replace" },
97
+ execute: async ({ text }) => { lengths.push(text.length); return { length: text.length }; }
98
+ }] });
99
+ const catalog = createSurfaceAwareToolCatalog(actions, { appConfig: { assistantServer: { writer: { maxToolArgumentBytes: 64 * 1024 } } } });
100
+ const argumentsText = JSON.stringify({ text: "x".repeat(40 * 1024) });
101
+ const call = surface => {
102
+ const context = { actor: { id: "owner" }, surface };
103
+ const toolSet = catalog.resolveToolSet(context);
104
+ return { limit: toolSet.limits.maxToolArgumentBytes,
105
+ result: catalog.executeToolCall({ toolName: toolSet.tools[0].name, argumentsText, context, toolSet }) };
106
+ };
107
+
108
+ const writer = call("writer");
109
+ assert.equal(writer.limit, 64 * 1024);
110
+ assert.deepEqual(await writer.result, { ok: true, result: { length: 40 * 1024 } });
111
+ const home = call("home");
112
+ assert.equal(home.limit, 32 * 1024, "A surface without the key keeps the default");
113
+ assert.equal((await home.result).error.code, "assistant_tool_arguments_too_large");
114
+ assert.deepEqual(lengths, [40 * 1024]);
115
+ });
116
+
117
+ test("each surface's tool sets carry and enforce its maxToolResultBytes", async () => {
118
+ const actions = createActionCatalogue();
119
+ actions.register({ contributorId: "test.documents", domain: "test", actions: [{
120
+ id: "documents.read", version: 1, kind: "query", channels: ["automation"], surfaces: ["reader", "home"],
121
+ permission: { require: "authenticated" },
122
+ input: { schema: createSchema({}), mode: "replace" },
123
+ output: { schema: createSchema({ text: { type: "string", required: true } }), mode: "replace" },
124
+ execute: async () => ({ text: "x".repeat(67 * 1024) })
125
+ }] });
126
+ const catalog = createSurfaceAwareToolCatalog(actions, { appConfig: { assistantServer: { reader: { maxToolResultBytes: 96 * 1024 } } } });
127
+ const call = surface => {
128
+ const context = { actor: { id: "owner" }, surface };
129
+ const toolSet = catalog.resolveToolSet(context);
130
+ return { limit: toolSet.limits.maxToolResultBytes,
131
+ result: catalog.executeToolCall({ toolName: toolSet.tools[0].name, argumentsText: "{}", context, toolSet }) };
132
+ };
133
+
134
+ const reader = call("reader");
135
+ assert.equal(reader.limit, 96 * 1024);
136
+ assert.equal((await reader.result).result.text.length, 67 * 1024);
137
+ const home = call("home");
138
+ assert.equal(home.limit, 48 * 1024, "A surface without the key keeps the default");
139
+ assert.deepEqual((await home.result).error, { code: "assistant_tool_result_too_large",
140
+ message: "Tool result exceeds the assistant size limit. Narrow the request and try again.", status: 413 });
141
+ });
142
+
81
143
  test("an application reaches the surface-aware tool catalog through the package's server entry", async () => {
82
144
  const server = await import("@jskit-ai/assistant-runtime/server");
83
145
  assert.equal(server.createSurfaceAwareToolCatalog, createSurfaceAwareToolCatalog);
@@ -24,6 +24,7 @@ test("assistant-runtime registers providers without install-time authoring machi
24
24
  "assistant_config_initial.cjs",
25
25
  "assistant_transcripts_initial.cjs",
26
26
  "assistant_transcripts_summary.cjs",
27
+ "assistant_transcripts_tool_arguments.cjs",
27
28
  "assistant_turn_requests.cjs",
28
29
  "constraints"
29
30
  ]);
@@ -4,6 +4,7 @@ import knex from "knex";
4
4
  import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
5
5
  import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
6
6
  import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
7
+ import toolArgumentsMigration from "../migrations/assistant_transcripts_tool_arguments.cjs";
7
8
  import { createSqlConversationStorage } from "../src/server/sqlConversationStorage.js";
8
9
  import { createTestAssistantStore } from "./support/assistantStore.js";
9
10
 
@@ -77,6 +78,28 @@ test("the SQL conversation storage keeps its contract, cache and summary on MySQ
77
78
  }));
78
79
  assert.deepEqual(summarised, { summary: { text, throughTurnId: "000100" }, after: ["000101", "000102"] });
79
80
 
81
+ // A tool call's arguments are its tool_call row's text and its result its tool_result row's: an
82
+ // application may allow up to 1 MiB of each, past the 64 KiB a MySQL TEXT column holds.
83
+ const contentTextType = async () => (await f.db.raw("SELECT DATA_TYPE AS type FROM information_schema.COLUMNS " +
84
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'assistant_messages' AND COLUMN_NAME = 'content_text'"))[0][0].type.toLowerCase();
85
+ assert.equal(await contentTextType(), "text");
86
+ await toolArgumentsMigration.up(f.db);
87
+ await toolArgumentsMigration.up(f.db);
88
+ assert.equal(await contentTextType(), "longtext");
89
+ const proposal = await conversation("1");
90
+ const argumentsText = JSON.stringify({ text: "x".repeat(1024 * 1024 - 11) });
91
+ const call = { id: "propose", name: "documents_propose", arguments: argumentsText, status: "complete",
92
+ at: "2026-03-01T00:00:00.000Z", result: { ok: true, result: { text: "y".repeat(1024 * 1024 - 32) } } };
93
+ const proposed = await f.storage.write(proposal.id, async transaction => {
94
+ const turnId = await transaction.nextTurnId();
95
+ await transaction.appendMessage(turnId, { role: "user", messageId: "propose", text: "Propose a document.", at: call.at });
96
+ await transaction.updateTurnMetadata(turnId, { applicationTools: [call] });
97
+ return turnId;
98
+ });
99
+ const reread = await (await connect()).storage.read(proposal.id, transaction => transaction.readTurn(proposed));
100
+ assert.equal(reread.metadata.applicationTools[0].arguments, argumentsText, "A 1 MiB call's arguments are saved whole");
101
+ assert.deepEqual(reread.metadata.applicationTools[0].result, call.result, "And its 1 MiB result");
102
+
80
103
  await summaryMigration.down(f.db);
81
104
  for (const column of ["summary_text", "summary_through_turn_id", "transcript_revision"]) {
82
105
  assert.equal(await f.db.schema.hasColumn("assistant_conversations", column), false);
@@ -10,6 +10,7 @@ import { createConversationTranscript } from "@jskit-ai/assistant-core/server/co
10
10
  import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
11
11
  import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
12
12
  import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
13
+ import toolArgumentsMigration from "../migrations/assistant_transcripts_tool_arguments.cjs";
13
14
  import claimsMigration from "../migrations/assistant_turn_requests.cjs";
14
15
  import { createSqlConversationStorage } from "../src/server/sqlConversationStorage.js";
15
16
  import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
@@ -48,6 +49,8 @@ async function fixture(t) {
48
49
  await first.db("users").insert([{ id: 1 }, { id: 2 }]);
49
50
  await transcriptsMigration.up(first.db);
50
51
  await summaryMigration.up(first.db);
52
+ // MySQL only: SQLite's text holds a tool call's arguments as they are.
53
+ await toolArgumentsMigration.up(first.db);
51
54
  await claimsMigration.up(first.db);
52
55
  await first.db.raw("PRAGMA journal_mode = WAL");
53
56
  const conversation = actor => first.conversationsRepository.create({ createdByUserId: actor || "1", surfaceId: "assistant", metadata: {} });
@@ -171,8 +174,8 @@ test("the SQL facade uses common inference and durable tool rows, and the model'
171
174
  })();
172
175
  }, { model: "fixture-model" }) };
173
176
  } };
174
- const catalog = { limits: { maxToolArgumentBytes: 4096 },
175
- resolveToolSet: () => ({ tools: [{ name: "inventory_lookup", parameters: { type: "object" } }] }),
177
+ const catalog = {
178
+ resolveToolSet: () => ({ tools: [{ name: "inventory_lookup", parameters: { type: "object" } }], limits: { maxToolArgumentBytes: 4096 } }),
176
179
  toOpenAiToolSchema: tool => ({ type: "function", function: tool }),
177
180
  async executeToolCall() {
178
181
  executions += 1;
@@ -347,7 +350,7 @@ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"])
347
350
  yield { choices: [{ delta: { content: "Fresh answer." }, finish_reason: "stop" }] };
348
351
  })();
349
352
  }, { model: "fixture-model" }) }) };
350
- const catalog = { limits: { maxToolArgumentBytes: 4096 }, resolveToolSet: () => ({ tools: [] }),
353
+ const catalog = { resolveToolSet: () => ({ tools: [], limits: { maxToolArgumentBytes: 4096 } }),
351
354
  toOpenAiToolSchema: tool => ({ type: "function", function: tool }),
352
355
  executeToolCall() { effects++; assert.fail("Retained history cannot repeat an application effect"); } };
353
356
  const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSchema } from "json-rest-schema";
4
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
+ import { createConversationRuntime, createMemoryConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
6
+ import { createSurfaceAwareToolCatalog } from "../src/server/support/createSurfaceAwareToolCatalog.js";
7
+
8
+ // An assistant on an API model that proposes a whole document in one application tool call, and reads one back.
9
+ const KIB = 1024;
10
+ const SURFACES = ["writer", "planner", "reader", "home"];
11
+ const appConfig = { assistantServer: { writer: { maxToolArgumentBytes: 64 * KIB }, planner: { maxToolArgumentBytes: 256 * KIB },
12
+ reader: { maxToolResultBytes: 96 * KIB }, home: {} } };
13
+ const frame = (delta, finish_reason = null) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
14
+ const stream = body => new Response(body, { headers: { "content-type": "text/event-stream" } });
15
+ /** A document whose JSON is `bytes` bytes: {"text":"…"} is 11 bytes around the text. */
16
+ function documentOf(bytes) {
17
+ const document = { text: "x".repeat(bytes - 11) };
18
+ assert.equal(Buffer.byteLength(JSON.stringify(document)), bytes);
19
+ return document;
20
+ }
21
+
22
+ /**
23
+ * On `surface`, the model calls `actionId` once with `input`, then answers. documents.read answers
24
+ * a document of `resultBytes` bytes. Returns the turn, what the actions received and the model's requests.
25
+ */
26
+ async function converse(t, surface, actionId, input, { resultBytes = 0 } = {}) {
27
+ const received = [];
28
+ const text = createSchema({ text: { type: "string", required: true } });
29
+ const action = { version: 1, channels: ["automation"], surfaces: SURFACES, permission: { require: "authenticated" } };
30
+ const actions = createActionCatalogue();
31
+ actions.register({ contributorId: "test.documents", domain: "test", actions: [{
32
+ ...action, id: "documents.propose", kind: "command", input: { schema: text, mode: "replace" },
33
+ output: { schema: createSchema({ length: { type: "number", required: true } }), mode: "replace" },
34
+ execute: async ({ text: proposed }) => { received.push(proposed.length); return { length: proposed.length }; }
35
+ }, {
36
+ ...action, id: "documents.read", kind: "query", input: { schema: createSchema({}), mode: "replace" }, output: { schema: text, mode: "replace" },
37
+ execute: async () => { received.push("read"); return documentOf(resultBytes); }
38
+ }] });
39
+ const toolCatalog = createSurfaceAwareToolCatalog(actions, { appConfig });
40
+ const context = { actor: { id: "owner" }, surface, applicationId: "test", subjectId: "owner" };
41
+ const name = toolCatalog.resolveToolSet(context).tools.find(tool => tool.actionId === actionId).name;
42
+ const requests = [];
43
+ const runtime = createConversationRuntime({ storage: createMemoryConversationStorage(), toolCatalog, authorize: () => true,
44
+ connections: { resolve: async () => ({ providerId: "test", model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
45
+ apiKey: "test", baseURL: "http://test.invalid/v1" }) },
46
+ async fetch(_url, request) {
47
+ const body = JSON.parse(request.body);
48
+ // A check of the reply's claims, when the engine makes one, finds none unsupported.
49
+ if (!body.stream) return Response.json({ id: "check", object: "chat.completion", created: 0, model: "test-model",
50
+ choices: [{ index: 0, message: { role: "assistant", content: '{"unsupported": []}' }, finish_reason: "stop" }] });
51
+ requests.push(body);
52
+ return stream(requests.length === 1
53
+ ? frame({ tool_calls: [{ index: 0, id: "call", type: "function", function: { name, arguments: JSON.stringify(input) } }] }) + frame({}, "tool_calls")
54
+ : frame({ content: "Here it is." }) + frame({}, "stop"));
55
+ } });
56
+ t.after(() => runtime.close());
57
+ const conversation = await runtime.open({ id: "document", context, configuration: { systemPrompt: "Work on documents.", integrationId: "assistant" } });
58
+ await conversation.send({ messageId: "ask", text: "Work on the document." });
59
+ const result = await conversation.wait();
60
+ const toolMessage = requests[1]?.messages.find(message => message.role === "tool");
61
+ return { result, received, modelSaw: toolMessage && JSON.parse(toolMessage.content) };
62
+ }
63
+
64
+ test("an API conversation's tool call is held to its surface's maxToolArgumentBytes", async t => {
65
+ const writer = await converse(t, "writer", "documents.propose", documentOf(40 * KIB));
66
+ assert.equal(writer.result.error, "");
67
+ assert.equal(writer.result.conversationLog[0].metadata.runtime.status, "complete");
68
+ assert.deepEqual(writer.received, [40 * KIB - 11]);
69
+ assert.equal(writer.result.conversationLog[0].metadata.applicationTools[0].arguments.length, 40 * KIB);
70
+
71
+ const home = await converse(t, "home", "documents.propose", documentOf(40 * KIB));
72
+ assert.equal(home.result.error, "The application tool arguments exceed their size limit.");
73
+ assert.deepEqual(home.received, [], "Over the default 32 KiB the action never runs");
74
+ });
75
+
76
+ test("a 128 KiB tool call passes end to end on a surface that allows it, and one over its limit fails the same way", async t => {
77
+ const fits = await converse(t, "planner", "documents.propose", documentOf(128 * KIB));
78
+ assert.equal(fits.result.error, "");
79
+ assert.deepEqual(fits.received, [128 * KIB - 11]);
80
+
81
+ const over = await converse(t, "planner", "documents.propose", documentOf(256 * KIB + 1));
82
+ assert.equal(over.result.error, "The application tool arguments exceed their size limit.");
83
+ assert.deepEqual(over.received, []);
84
+ });
85
+
86
+ test("a 67 KiB document read back reaches the model on a surface whose maxToolResultBytes allows it, and only the error does elsewhere", async t => {
87
+ const reader = await converse(t, "reader", "documents.read", {}, { resultBytes: 67 * KIB });
88
+ assert.equal(reader.result.error, "");
89
+ assert.equal(reader.result.conversationLog[0].metadata.applicationTools[0].result.ok, true);
90
+ assert.equal(reader.modelSaw.text.length, 67 * KIB - 11, "The model reads the whole document");
91
+
92
+ const home = await converse(t, "home", "documents.read", {}, { resultBytes: 67 * KIB });
93
+ assert.equal(home.result.error, "", "The turn carries on with the error in the document's place");
94
+ assert.deepEqual(home.received, ["read"]);
95
+ assert.equal(home.result.conversationLog[0].metadata.applicationTools[0].result.error.code, "assistant_tool_result_too_large");
96
+ assert.equal(home.modelSaw.error.code, "assistant_tool_result_too_large", "Over the default 48 KiB the model gets the error, not the document");
97
+
98
+ const atLimit = await converse(t, "reader", "documents.read", {}, { resultBytes: 96 * KIB });
99
+ assert.equal(atLimit.modelSaw.text.length, 96 * KIB - 11);
100
+ const over = await converse(t, "reader", "documents.read", {}, { resultBytes: 96 * KIB + 1 });
101
+ assert.equal(over.modelSaw.error.code, "assistant_tool_result_too_large");
102
+ });
@@ -81,7 +81,7 @@ function service(repository, { provider, transcript = [] } = {}) {
81
81
  async createConversationForTurn() { return { conversation: { id: "100" } }; },
82
82
  async completeConversation() {}
83
83
  },
84
- serviceToolCatalog: { limits: { maxToolArgumentBytes: 64 * 1024 }, resolveToolSet: () => ({ tools: [] }),
84
+ serviceToolCatalog: { resolveToolSet: () => ({ tools: [], limits: { maxToolArgumentBytes: 64 * 1024 } }),
85
85
  toOpenAiToolSchema: tool => ({ type: "function", function: tool }) },
86
86
  assistantConfigService: { resolveSystemPrompt: async () => "Answer." },
87
87
  personContextPrompt: createPersonContextPrompt({ personContext: NO_PERSON_CONTEXT, logger: silentLogger }),