@jskit-ai/assistant-runtime 0.1.234 → 0.1.235
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/README.md +36 -0
- package/fixtures/conversation/vite.config.mjs +2 -2
- package/package.json +9 -10
- package/src/client/components/AssistantSettingsClientElement.vue +0 -3
- package/src/client/components/AssistantSurfaceClientElement.vue +2 -5
- package/src/client/composables/useAssistantConversation.js +57 -55
- package/src/client/composables/useAssistantRuntime.js +14 -29
- package/src/client/support/accessDenied.js +6 -0
- package/src/client/support/subscribeAssistantConversation.js +4 -5
- package/src/server/actions.js +3 -36
- package/src/server/createAssistantSettingsActions.js +0 -8
- package/src/server/inputSchemas.js +3 -8
- package/src/server/registerConversationSubscriptions.js +13 -15
- package/src/server/registerRoutes.js +15 -39
- package/src/server/repositories/conversationsRepository.js +9 -47
- package/src/server/repositories/messagesRepository.js +5 -22
- package/src/server/repositories/repositoryPersistenceUtils.js +34 -1
- package/src/server/services/assistantConfigService.js +4 -17
- package/src/server/services/chatService.js +14 -37
- package/src/server/services/transcriptService.js +1 -54
- package/src/server/support/assistantErrors.js +13 -0
- package/src/server/support/assistantRouteContext.js +1 -10
- package/src/server/support/assistantStreamWriter.js +12 -0
- package/src/server/support/assistantSurface.js +13 -0
- package/src/server/support/createSurfaceAwareToolCatalog.js +5 -6
- package/src/shared/assistantRuntimeConfig.js +3 -1
- package/src/shared/assistantSurfaces.js +18 -0
- package/test/actionDefinitions.test.js +0 -1
- package/test/assistantSurfaces.test.js +29 -0
- package/test/chatServiceLifecycle.test.js +12 -3
- package/test/turnRequests.test.js +0 -1
|
@@ -2,12 +2,12 @@ import { AppError, parsePositiveInteger } from "@jskit-ai/kernel/server/runtime"
|
|
|
2
2
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
3
3
|
import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
4
|
import { normalizeConversationStatus } from "@jskit-ai/assistant-core/shared";
|
|
5
|
+
import { DEFAULT_CONVERSATION_TITLE } from "../../shared/assistantRuntimeConfig.js";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_PAGE_SIZE = 20;
|
|
7
8
|
const MAX_PAGE_SIZE = 200;
|
|
8
9
|
const DEFAULT_MESSAGES_PAGE_SIZE = 200;
|
|
9
10
|
const MAX_MESSAGES_PAGE_SIZE = 500;
|
|
10
|
-
const DEFAULT_CONVERSATION_TITLE = "New conversation";
|
|
11
11
|
const MAX_CONVERSATION_TITLE_LENGTH = 80;
|
|
12
12
|
|
|
13
13
|
function resolveWorkspaceId(workspace, { required = false } = {}) {
|
|
@@ -141,58 +141,6 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
async function appendMessage(assistantSurface, conversationId, payload = {}, options = {}) {
|
|
145
|
-
const resolvedAssistantSurface = requireAssistantSurface(assistantSurface);
|
|
146
|
-
const normalizedConversationId = normalizeRecordId(conversationId, { fallback: null });
|
|
147
|
-
if (!normalizedConversationId) {
|
|
148
|
-
throw new TypeError("appendMessage requires conversationId.");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const source = normalizeObject(payload);
|
|
152
|
-
const context = normalizeObject(options.context);
|
|
153
|
-
const actorUserId = normalizeRecordId(source.actorUserId, { fallback: null }) || resolveActorUserId(context.actor);
|
|
154
|
-
const workspaceId = resolveExpectedWorkspaceId(resolvedAssistantSurface, options.workspace || context.workspace);
|
|
155
|
-
return conversationsRepository.transaction(async trx => {
|
|
156
|
-
const conversation = await conversationsRepository.findByIdForActorScope(normalizedConversationId, {
|
|
157
|
-
workspaceId,
|
|
158
|
-
actorUserId,
|
|
159
|
-
surfaceId: resolvedAssistantSurface.targetSurfaceId
|
|
160
|
-
}, { trx, forUpdate: true });
|
|
161
|
-
if (!conversation) {
|
|
162
|
-
throw new AppError(404, "Conversation not found.");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const createdMessage = await messagesRepository.create({
|
|
166
|
-
conversationId: normalizedConversationId,
|
|
167
|
-
workspaceId: conversation.workspaceId,
|
|
168
|
-
role: normalizeText(source.role).toLowerCase(),
|
|
169
|
-
kind: normalizeText(source.kind).toLowerCase() || "chat",
|
|
170
|
-
clientMessageSid: normalizeText(source.clientMessageSid),
|
|
171
|
-
actorUserId,
|
|
172
|
-
contentText: source.contentText == null ? null : String(source.contentText),
|
|
173
|
-
metadata: normalizeObject(source.metadata)
|
|
174
|
-
}, { trx });
|
|
175
|
-
|
|
176
|
-
await conversationsRepository.updateById(normalizedConversationId, { messageCount: conversation.messageCount + 1 }, { trx });
|
|
177
|
-
|
|
178
|
-
const messageRole = normalizeText(source.role).toLowerCase();
|
|
179
|
-
const messageKind = normalizeText(source.kind).toLowerCase() || "chat";
|
|
180
|
-
if (messageRole === "user" && messageKind === "chat" && isDefaultConversationTitle(conversation.title)) {
|
|
181
|
-
const derivedTitle = deriveConversationTitleFromMessage(source.contentText);
|
|
182
|
-
if (derivedTitle) {
|
|
183
|
-
await conversationsRepository.updateById(normalizedConversationId, {
|
|
184
|
-
title: derivedTitle
|
|
185
|
-
}, { trx });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
conversationId: normalizedConversationId,
|
|
191
|
-
message: createdMessage
|
|
192
|
-
};
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
144
|
async function completeConversation(assistantSurface, conversationId, payload = {}, options = {}) {
|
|
197
145
|
const resolvedAssistantSurface = requireAssistantSurface(assistantSurface);
|
|
198
146
|
const normalizedConversationId = normalizeRecordId(conversationId, { fallback: null });
|
|
@@ -323,7 +271,6 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
323
271
|
|
|
324
272
|
return Object.freeze({
|
|
325
273
|
createConversationForTurn,
|
|
326
|
-
appendMessage,
|
|
327
274
|
completeConversation,
|
|
328
275
|
listConversationsForUser,
|
|
329
276
|
getConversationMessagesForUser
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
2
|
+
|
|
3
|
+
function errorStatus(error) {
|
|
4
|
+
return Number(error?.status || error?.statusCode || 500);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// AppError details are withheld from clients when an action permission is denied.
|
|
8
|
+
function exposedAppErrorDetails(error) {
|
|
9
|
+
if (!(error instanceof AppError) || !error.details) return undefined;
|
|
10
|
+
return String(error.code || "").trim() === "ACTION_PERMISSION_DENIED" ? undefined : error.details;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { errorStatus, exposedAppErrorDetails };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
2
2
|
import { composeSchemaDefinitions } from "@jskit-ai/kernel/shared/validators";
|
|
3
3
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
4
|
-
import {
|
|
4
|
+
import { requireAssistantSurface } from "./assistantSurface.js";
|
|
5
5
|
import { assistantSurfaceRouteParamsValidator } from "../inputSchemas.js";
|
|
6
6
|
|
|
7
7
|
function requireWorkspaceAssistantRouteParams(workspaceScopeSupport = null) {
|
|
@@ -30,15 +30,6 @@ function readWorkspaceInput(request, requiresWorkspace, workspaceScopeSupport =
|
|
|
30
30
|
return workspaceScopeSupport.buildInputFromRouteParams(request?.input?.params);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
|
|
34
|
-
const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
|
|
35
|
-
if (assistantSurface) {
|
|
36
|
-
return assistantSurface;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
throw new AppError(404, "Assistant not found.");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
33
|
function requireHostSurfaceId(request) {
|
|
43
34
|
const headerValue = Array.isArray(request?.headers?.["x-jskit-surface"])
|
|
44
35
|
? request.headers["x-jskit-surface"][0]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The stream writer contract shared by the chat route and the chat service.
|
|
2
|
+
const ASSISTANT_STREAM_WRITER_METHODS = Object.freeze([
|
|
3
|
+
"sendMeta",
|
|
4
|
+
"sendAssistantDelta",
|
|
5
|
+
"sendAssistantMessage",
|
|
6
|
+
"sendToolCall",
|
|
7
|
+
"sendToolResult",
|
|
8
|
+
"sendError",
|
|
9
|
+
"sendDone"
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export { ASSISTANT_STREAM_WRITER_METHODS };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
2
|
+
import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
3
|
+
|
|
4
|
+
function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
|
|
5
|
+
const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
|
|
6
|
+
if (assistantSurface) {
|
|
7
|
+
return assistantSurface;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
throw new AppError(404, "Assistant not found.");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { requireAssistantSurface };
|
|
@@ -21,13 +21,12 @@ function requireContextSurfaceId(context = {}) {
|
|
|
21
21
|
throw new Error("assistant surface-aware tool catalog requires context.surface.");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function createSurfaceAwareToolCatalog(actions, { appConfig = {},
|
|
24
|
+
function createSurfaceAwareToolCatalog(actions, { appConfig = {}, createCatalog = createServiceToolCatalog } = {}) {
|
|
25
25
|
if (!actions || typeof actions.listDefinitions !== "function") {
|
|
26
26
|
throw new Error("createSurfaceAwareToolCatalog requires runtime.actions.");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const
|
|
30
|
-
typeof resolveAppConfig === "function" ? () => normalizeObject(resolveAppConfig()) : () => normalizeObject(appConfig);
|
|
29
|
+
const normalizedAppConfig = normalizeObject(appConfig);
|
|
31
30
|
const cache = new Map();
|
|
32
31
|
let schemaCatalog = null;
|
|
33
32
|
|
|
@@ -36,14 +35,14 @@ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, resolveAppConf
|
|
|
36
35
|
return cache.get(surfaceId);
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
const nextCatalog = createCatalog(actions, buildCatalogOptions(
|
|
38
|
+
const nextCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, surfaceId));
|
|
40
39
|
cache.set(surfaceId, nextCatalog);
|
|
41
40
|
return nextCatalog;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
return Object.freeze({
|
|
45
44
|
get limits() {
|
|
46
|
-
if (!schemaCatalog) schemaCatalog = createCatalog(actions, buildCatalogOptions(
|
|
45
|
+
if (!schemaCatalog) schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
|
|
47
46
|
return schemaCatalog.limits;
|
|
48
47
|
},
|
|
49
48
|
resolveToolSet(context = {}, options) {
|
|
@@ -52,7 +51,7 @@ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, resolveAppConf
|
|
|
52
51
|
},
|
|
53
52
|
toOpenAiToolSchema(tool) {
|
|
54
53
|
if (!schemaCatalog) {
|
|
55
|
-
schemaCatalog = createCatalog(actions, buildCatalogOptions(
|
|
54
|
+
schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
return schemaCatalog.toOpenAiToolSchema(tool);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
|
|
1
2
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
2
3
|
import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
4
|
|
|
@@ -102,9 +103,26 @@ function resolveAssistantSurfaceConfig(appConfig = {}, targetSurfaceId = "") {
|
|
|
102
103
|
});
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The runtime route of one resolved surface. Only the surface decides whether
|
|
108
|
+
* a workspace segment exists, so HTTP and realtime always name the same route;
|
|
109
|
+
* a workspace slug offered for a surface without workspaces is ignored.
|
|
110
|
+
*/
|
|
111
|
+
function resolveAssistantRuntimeRoute(surface, workspaceSlug = "") {
|
|
112
|
+
const requiresWorkspace = surface?.runtimeSurfaceRequiresWorkspace === true;
|
|
113
|
+
const workspace = requiresWorkspace ? normalizeText(workspaceSlug) : "";
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
workspaceSlug: workspace,
|
|
116
|
+
configured: Boolean(surface) && (!requiresWorkspace || Boolean(workspace)),
|
|
117
|
+
endpoint: buildAssistantApiPath({ requiresWorkspace, workspaceSlug: workspace,
|
|
118
|
+
suffix: `/${normalizeText(surface?.targetSurfaceId)}` })
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
export {
|
|
106
123
|
defineAssistantSurface,
|
|
107
124
|
normalizeAssistantConfigScope,
|
|
125
|
+
resolveAssistantRuntimeRoute,
|
|
108
126
|
resolveAssistantSurfaceConfig,
|
|
109
127
|
resolveAssistantSurfacesConfig,
|
|
110
128
|
resolveSurfaceDefinitions
|
|
@@ -120,7 +120,6 @@ test("host access wraps only canonical conversation definitions and receives the
|
|
|
120
120
|
actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
|
|
121
121
|
]);
|
|
122
122
|
assert.equal(findDefinition(definitions, actionIds.conversationSubscribe), undefined);
|
|
123
|
-
assert.equal(findDefinition(definitions, subscribeActionId).audit.actionName, subscribeActionId);
|
|
124
123
|
for (const definition of wrapped) {
|
|
125
124
|
const adapted = findDefinition(definitions, definition.id);
|
|
126
125
|
assert.equal(adapted.input, definition.input);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
3
|
import test from "node:test";
|
|
3
4
|
import {
|
|
4
5
|
defineAssistantSurface,
|
|
5
6
|
normalizeAssistantConfigScope,
|
|
7
|
+
resolveAssistantRuntimeRoute,
|
|
6
8
|
resolveAssistantSurfaceConfig
|
|
7
9
|
} from "../src/shared/assistantSurfaces.js";
|
|
8
10
|
|
|
@@ -71,3 +73,30 @@ test("single-surface shorthand preserves explicit routing, scope and disabled su
|
|
|
71
73
|
assert.equal(resolveAssistantSurfaceConfig(defineAssistantSurface("home", { configScope: "workspace" }), "home"), null);
|
|
72
74
|
assert.throws(() => defineAssistantSurface(""), /surface id is required/u);
|
|
73
75
|
});
|
|
76
|
+
|
|
77
|
+
test("only the surface decides the runtime route, so HTTP and realtime name the same one", () => {
|
|
78
|
+
const home = resolveAssistantSurfaceConfig(defineAssistantSurface("home"), "home");
|
|
79
|
+
const admin = resolveAssistantSurfaceConfig(defineAssistantSurface("admin", { requiresWorkspace: true }), "admin");
|
|
80
|
+
// A slug offered to a surface without workspaces is ignored rather than
|
|
81
|
+
// producing a workspace URL the server answers with 404.
|
|
82
|
+
assert.deepEqual(resolveAssistantRuntimeRoute(home, "acme"),
|
|
83
|
+
{ workspaceSlug: "", configured: true, endpoint: "/api/assistant/home" });
|
|
84
|
+
assert.deepEqual(resolveAssistantRuntimeRoute(admin, " acme "),
|
|
85
|
+
{ workspaceSlug: "acme", configured: true, endpoint: "/api/w/acme/assistant/admin" });
|
|
86
|
+
assert.equal(resolveAssistantRuntimeRoute(admin, "").configured, false);
|
|
87
|
+
assert.equal(resolveAssistantRuntimeRoute(null, "acme").configured, false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("client runtime code routes from surface configuration and names no application surface", async () => {
|
|
91
|
+
const root = new URL("../src/client/", import.meta.url);
|
|
92
|
+
const files = (await readdir(root, { recursive: true })).filter(file => /\.(js|vue)$/u.test(file));
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const source = await readFile(new URL(file, root), "utf8");
|
|
95
|
+
assert.doesNotMatch(source, /["']admin["']|isAdminSurface/u, `${file} hard-codes an application surface`);
|
|
96
|
+
}
|
|
97
|
+
for (const file of ["composables/useAssistantConversation.js", "composables/useAssistantRuntime.js"]) {
|
|
98
|
+
const source = await readFile(new URL(file, root), "utf8");
|
|
99
|
+
assert.match(source, /resolveAssistantRuntimeRoute\(/u);
|
|
100
|
+
assert.doesNotMatch(source, /buildAssistantApiPath\(/u, `${file} must route through resolveAssistantRuntimeRoute`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
@@ -122,9 +122,6 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
122
122
|
}
|
|
123
123
|
};
|
|
124
124
|
},
|
|
125
|
-
async appendMessage(_surface, _conversationId, message) {
|
|
126
|
-
transcriptMessages.push(structuredClone(message));
|
|
127
|
-
},
|
|
128
125
|
async completeConversation(_surface, _conversationId, completion) {
|
|
129
126
|
completedConversations.push(structuredClone(completion));
|
|
130
127
|
}
|
|
@@ -546,3 +543,15 @@ test("application-authorized attachments reach the model and survive transcript
|
|
|
546
543
|
const legacy = createHarness([textCompletion("Must not run")], { attachments, supportsAttachments: false });
|
|
547
544
|
await assert.rejects(legacy.run("Read this", { attachmentIds: ["file-one"] }), /not configured/);
|
|
548
545
|
});
|
|
546
|
+
|
|
547
|
+
test("attachment identifiers use the conversation runtime's shape before any file is resolved", async () => {
|
|
548
|
+
const calls = [];
|
|
549
|
+
const attachments = { async resolve(request) { calls.push(request); return { attachments: [], content: [] }; } };
|
|
550
|
+
for (const attachmentIds of [["file-one", "file-one"], [" "], Array.from({ length: 11 }, (_, index) => `file-${index}`)]) {
|
|
551
|
+
const harness = createHarness([textCompletion("Must not run")], { attachments });
|
|
552
|
+
await assert.rejects(harness.run("Read this", { attachmentIds }), error =>
|
|
553
|
+
error.status === 400 && /Invalid attachment identifiers/u.test(error.message));
|
|
554
|
+
assert.deepEqual(harness.completionRequests, []);
|
|
555
|
+
}
|
|
556
|
+
assert.deepEqual(calls, []);
|
|
557
|
+
});
|
|
@@ -52,7 +52,6 @@ function service(repository, { provider, transcript = [] } = {}) {
|
|
|
52
52
|
aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test", createChatCompletionStream: provider }) },
|
|
53
53
|
transcriptService: {
|
|
54
54
|
async createConversationForTurn() { return { conversation: { id: "100" } }; },
|
|
55
|
-
async appendMessage(_surface, _id, message) { transcript.push(message); },
|
|
56
55
|
async completeConversation() {}
|
|
57
56
|
},
|
|
58
57
|
serviceToolCatalog: { limits: { maxToolArgumentBytes: 64 * 1024 }, resolveToolSet: () => ({ tools: [] }),
|