@jskit-ai/assistant-runtime 0.2.46 → 0.2.48
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 +1 -0
- package/fixtures/conversation/vite.config.mjs +1 -1
- package/migrations/assistant_transcripts_tool_arguments.cjs +14 -0
- package/package.json +2 -2
- package/src/server/services/assistantConfigService.js +1 -1
- package/src/server/support/assistantServerConfig.js +25 -0
- package/src/server/support/createSurfaceAwareToolCatalog.js +7 -8
- package/test/assistantConversationBinding.browser.test.js +7 -4
- package/test/assistantServerConfig.test.js +17 -0
- package/test/chatServiceLifecycle.test.js +1 -2
- package/test/createSurfaceAwareToolCatalog.test.js +65 -3
- package/test/packageMetadata.test.js +1 -0
- package/test/sqlConversationStorage.mysql.test.js +23 -0
- package/test/sqlConversationStorage.test.js +6 -3
- package/test/surfaceToolLimits.test.js +102 -0
- package/test/turnRequests.test.js +1 -1
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: {
|
|
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.
|
|
3
|
+
"version": "0.2.48",
|
|
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.
|
|
18
|
+
"@jskit-ai/assistant-core": "0.2.46",
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
@@ -348,9 +348,11 @@ test("the first native goal preserves absent identity and later controls capture
|
|
|
348
348
|
const start = f.page.getByRole("button", { name: "Start goal", exact: true });
|
|
349
349
|
await expect(start).toBeEnabled();
|
|
350
350
|
await start.click();
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
await
|
|
351
|
+
// A native control starts no turn here, so the active goal says nothing is working on it yet.
|
|
352
|
+
const notRunning = f.primary.getByRole("button", { name: "Goal not running", exact: true });
|
|
353
|
+
await expect(notRunning).toBeVisible();
|
|
354
|
+
await expect(notRunning).toHaveAttribute("aria-expanded", "false");
|
|
355
|
+
await notRunning.click();
|
|
354
356
|
const pause = f.page.getByRole("button", { name: "Pause goal", exact: true });
|
|
355
357
|
await expect(pause).toBeEnabled();
|
|
356
358
|
await pause.click();
|
|
@@ -3535,7 +3537,8 @@ test("a background goal read keeps current pinned controls available and seriali
|
|
|
3535
3537
|
try {
|
|
3536
3538
|
await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
|
|
3537
3539
|
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
|
|
3538
|
-
|
|
3540
|
+
// Nothing works on the active goal here, so its chip says so.
|
|
3541
|
+
await f.primary.getByRole("button", { name: "Goal not running", exact: true }).click();
|
|
3539
3542
|
holdRead = true;
|
|
3540
3543
|
await f.page.evaluate(() => { window.fixtureBackgroundGoalRead = window.conversationFixture.current().refreshGoal(); });
|
|
3541
3544
|
await expect.poll(() => Boolean(releaseRead)).toBe(true);
|
|
@@ -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 = {
|
|
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 = {
|
|
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: {
|
|
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 }),
|