@cosmicdrift/kumiko-bundled-features 0.235.3 → 0.236.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 +9 -9
- package/src/agent-tools/__tests__/agent-manifest.test.ts +6 -0
- package/src/agent-tools/__tests__/agent-tools.integration.test.ts +199 -0
- package/src/agent-tools/__tests__/tool-catalog.test.ts +340 -128
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +28 -16
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +390 -44
- package/src/agent-tools/agent-manifest.ts +3 -0
- package/src/agent-tools/index.ts +4 -2
- package/src/agent-tools/tool-catalog.ts +457 -17
- package/src/agent-tools/tool-dispatch.ts +334 -43
- package/src/agent-tools/types.ts +61 -1
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +55 -82
- package/src/feature-toggles/feature.ts +24 -15
- package/src/feature-toggles/index.ts +3 -0
- package/src/feature-toggles/toggle-runtime.ts +46 -8
- package/src/feature-toggles/toggle-sync-signal.ts +57 -0
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +202 -0
- package/src/notes-history/entity.ts +51 -32
- package/src/notes-history/feature.ts +39 -7
- package/src/notes-history-user-data/hooks.ts +12 -9
- package/src/shared/has-where-rule.ts +14 -0
- package/src/shared/index.ts +1 -0
- package/src/tags/__tests__/tags-ownership.test.ts +23 -0
- package/src/tags/entity.ts +26 -16
- package/src/tags/feature.ts +44 -5
|
@@ -1,60 +1,59 @@
|
|
|
1
|
-
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type { ToolDispatchDescriptor } from "./types";
|
|
3
3
|
|
|
4
|
-
/** Narrow view of `Dispatcher` (packages/framework/src/pipeline/dispatcher.ts) —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
/** Narrow view of `Dispatcher` (packages/framework/src/pipeline/dispatcher.ts) — kept out of
|
|
5
|
+
* `ToolDispatchDescriptor`'s reach and small enough for tests to build a plain object instead
|
|
6
|
+
* of a real Dispatcher. `write` reaches the real `<entity>:write` / write-handler pipeline with
|
|
7
|
+
* the same permission checks as an HTTP write; `requestId` is forwarded through to the
|
|
8
|
+
* Redis-backed IdempotencyGuard (tenantId+userId+requestId keyed), so it — not
|
|
9
|
+
* `event.metadata.idempotencyKey`, a different lower layer — is the correct idempotency
|
|
10
|
+
* channel for a tool call that might be retried by the agent runtime. */
|
|
9
11
|
export type ToolDispatcher = {
|
|
10
12
|
query(type: string, payload: unknown, user: SessionUser): Promise<unknown>;
|
|
13
|
+
write(
|
|
14
|
+
type: string,
|
|
15
|
+
payload: unknown,
|
|
16
|
+
user: SessionUser,
|
|
17
|
+
requestId?: string,
|
|
18
|
+
): Promise<WriteResult>;
|
|
11
19
|
};
|
|
12
20
|
|
|
13
21
|
export type ToolCallResult =
|
|
14
22
|
| { readonly ok: true; readonly data: unknown }
|
|
15
23
|
| { readonly ok: false; readonly error: string };
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
toolInput: Readonly<Record<string, unknown>>,
|
|
31
|
-
dispatchTable: ReadonlyMap<string, ToolDispatchDescriptor>,
|
|
32
|
-
): Promise<ToolCallResult> {
|
|
33
|
-
const descriptor = dispatchTable.get(toolName);
|
|
34
|
-
if (!descriptor) {
|
|
35
|
-
return { ok: false, error: `Unknown tool "${toolName}"` };
|
|
36
|
-
}
|
|
25
|
+
export type ToolCallRequest = {
|
|
26
|
+
readonly dispatcher: ToolDispatcher;
|
|
27
|
+
/** The real SessionUser of the caller. Never systemQuery/createSystemUser/
|
|
28
|
+
* createAnonymousUser — those drop the caller's identity and with it every
|
|
29
|
+
* tenant, ownership and field-level check. */
|
|
30
|
+
readonly user: SessionUser;
|
|
31
|
+
readonly toolName: string;
|
|
32
|
+
readonly input: Readonly<Record<string, unknown>>;
|
|
33
|
+
readonly dispatchTable: ReadonlyMap<string, ToolDispatchDescriptor>;
|
|
34
|
+
/** Idempotency scope: `${runId}:${toolCallId}` becomes the write requestId. */
|
|
35
|
+
readonly runId: string;
|
|
36
|
+
readonly toolCallId: string;
|
|
37
|
+
};
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return payload;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const data = await dispatcher.query(descriptor.qn, payload.value, callerUser);
|
|
48
|
-
return { ok: true, data };
|
|
49
|
-
} catch (err) {
|
|
50
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
51
|
-
}
|
|
52
|
-
}
|
|
39
|
+
const SEARCH_RESULT_LIMIT = 10;
|
|
40
|
+
const DEFAULT_LIST_LIMIT = 10;
|
|
41
|
+
const MAX_LIST_LIMIT = 200;
|
|
42
|
+
const FILTER_OPS = ["eq", "ne", "lt", "gt", "in"] as const;
|
|
43
|
+
type FilterOp = (typeof FILTER_OPS)[number];
|
|
53
44
|
|
|
54
45
|
type PayloadResult =
|
|
55
46
|
| { readonly ok: true; readonly value: Readonly<Record<string, unknown>> }
|
|
56
47
|
| { readonly ok: false; readonly error: string };
|
|
57
48
|
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isFilterOp(value: unknown): value is FilterOp {
|
|
54
|
+
return typeof value === "string" && (FILTER_OPS as readonly string[]).includes(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
58
57
|
function buildSearchPayload(
|
|
59
58
|
toolName: string,
|
|
60
59
|
toolInput: Readonly<Record<string, unknown>>,
|
|
@@ -63,7 +62,7 @@ function buildSearchPayload(
|
|
|
63
62
|
if (typeof query !== "string" || query.length === 0) {
|
|
64
63
|
return { ok: false, error: `Tool "${toolName}" requires a non-empty string "query" argument` };
|
|
65
64
|
}
|
|
66
|
-
return { ok: true, value: { search: query, limit:
|
|
65
|
+
return { ok: true, value: { search: query, limit: SEARCH_RESULT_LIMIT } };
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
function buildFindByPayload(
|
|
@@ -77,6 +76,298 @@ function buildFindByPayload(
|
|
|
77
76
|
}
|
|
78
77
|
return {
|
|
79
78
|
ok: true,
|
|
80
|
-
value: { filter: { field: fieldName, op: "eq", value }, limit:
|
|
79
|
+
value: { filter: { field: fieldName, op: "eq", value }, limit: SEARCH_RESULT_LIMIT },
|
|
81
80
|
};
|
|
82
81
|
}
|
|
82
|
+
|
|
83
|
+
/** Builds the `entityListSchema` payload for `list_<entity>`, validating `filters` against the
|
|
84
|
+
* catalog's per-entity allowlist. The allowlist keeps the model from probing non-filterable /
|
|
85
|
+
* access-restricted columns through an arbitrary WHERE clause — rejecting here is an error
|
|
86
|
+
* contract, not a passthrough to the executor. */
|
|
87
|
+
function buildEntityListPayload(
|
|
88
|
+
list: {
|
|
89
|
+
readonly searchableFields: readonly string[];
|
|
90
|
+
readonly filterableFields: readonly string[];
|
|
91
|
+
},
|
|
92
|
+
input: Readonly<Record<string, unknown>>,
|
|
93
|
+
): PayloadResult {
|
|
94
|
+
const payload: Record<string, unknown> = { totalCount: true };
|
|
95
|
+
|
|
96
|
+
const search = input["search"];
|
|
97
|
+
if (search !== undefined) {
|
|
98
|
+
if (typeof search !== "string") {
|
|
99
|
+
return { ok: false, error: `"search" must be a string` };
|
|
100
|
+
}
|
|
101
|
+
if (list.searchableFields.length === 0) {
|
|
102
|
+
return { ok: false, error: "This entity has no searchable fields" };
|
|
103
|
+
}
|
|
104
|
+
payload["search"] = search;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const filters = input["filters"];
|
|
108
|
+
if (filters !== undefined) {
|
|
109
|
+
if (!Array.isArray(filters)) {
|
|
110
|
+
return { ok: false, error: `"filters" must be an array` };
|
|
111
|
+
}
|
|
112
|
+
const validated: Array<{ field: string; op: FilterOp; value: unknown }> = [];
|
|
113
|
+
for (const entry of filters) {
|
|
114
|
+
if (!isRecord(entry) || typeof entry["field"] !== "string" || !("value" in entry)) {
|
|
115
|
+
return { ok: false, error: `Each filter must be { field, op, value }` };
|
|
116
|
+
}
|
|
117
|
+
if (!list.filterableFields.includes(entry["field"])) {
|
|
118
|
+
return { ok: false, error: `Field "${entry["field"]}" is not filterable on this entity` };
|
|
119
|
+
}
|
|
120
|
+
if (!isFilterOp(entry["op"])) {
|
|
121
|
+
return { ok: false, error: `Unsupported filter operator "${String(entry["op"])}"` };
|
|
122
|
+
}
|
|
123
|
+
validated.push({ field: entry["field"], op: entry["op"], value: entry["value"] });
|
|
124
|
+
}
|
|
125
|
+
if (validated.length > 0) payload["filters"] = validated;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const rawLimit = input["limit"];
|
|
129
|
+
const limitValue = typeof rawLimit === "number" ? rawLimit : DEFAULT_LIST_LIMIT;
|
|
130
|
+
payload["limit"] = Math.min(Math.max(limitValue, 1), MAX_LIST_LIMIT);
|
|
131
|
+
|
|
132
|
+
return { ok: true, value: payload };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Pulls only `id`/`version` off a write result — from the top level, falling back to the
|
|
136
|
+
* nested `data` projection (the real create/update shape is `{ id, data: <projection>, changes,
|
|
137
|
+
* previous, ... }`, with `version` living on the projection, not the top level). Never returns
|
|
138
|
+
* the projection itself or any other field: this is the fallback path for when there is no
|
|
139
|
+
* detail handler to re-read through, so nothing here has passed the field-level read filter. */
|
|
140
|
+
function extractIdAndVersion(data: unknown): { id?: string; version?: number } {
|
|
141
|
+
if (!isRecord(data)) return {};
|
|
142
|
+
const nested = isRecord(data["data"]) ? data["data"] : undefined;
|
|
143
|
+
const id =
|
|
144
|
+
typeof data["id"] === "string"
|
|
145
|
+
? data["id"]
|
|
146
|
+
: typeof nested?.["id"] === "string"
|
|
147
|
+
? nested["id"]
|
|
148
|
+
: undefined;
|
|
149
|
+
const version =
|
|
150
|
+
typeof data["version"] === "number"
|
|
151
|
+
? data["version"]
|
|
152
|
+
: typeof nested?.["version"] === "number"
|
|
153
|
+
? nested["version"]
|
|
154
|
+
: undefined;
|
|
155
|
+
return { ...(id !== undefined && { id }), ...(version !== undefined && { version }) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function dispatchSearch(
|
|
159
|
+
descriptor: Extract<ToolDispatchDescriptor, { kind: "search" }>,
|
|
160
|
+
dispatcher: ToolDispatcher,
|
|
161
|
+
user: SessionUser,
|
|
162
|
+
toolName: string,
|
|
163
|
+
input: Readonly<Record<string, unknown>>,
|
|
164
|
+
): Promise<ToolCallResult> {
|
|
165
|
+
const payload = buildSearchPayload(toolName, input);
|
|
166
|
+
if (!payload.ok) return payload;
|
|
167
|
+
const data = await dispatcher.query(descriptor.qn, payload.value, user);
|
|
168
|
+
return { ok: true, data };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function dispatchFindBy(
|
|
172
|
+
descriptor: Extract<ToolDispatchDescriptor, { kind: "findBy" }>,
|
|
173
|
+
dispatcher: ToolDispatcher,
|
|
174
|
+
user: SessionUser,
|
|
175
|
+
toolName: string,
|
|
176
|
+
input: Readonly<Record<string, unknown>>,
|
|
177
|
+
): Promise<ToolCallResult> {
|
|
178
|
+
const payload = buildFindByPayload(toolName, descriptor.fieldName, input);
|
|
179
|
+
if (!payload.ok) return payload;
|
|
180
|
+
const data = await dispatcher.query(descriptor.qn, payload.value, user);
|
|
181
|
+
return { ok: true, data };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function dispatchServerQuery(
|
|
185
|
+
descriptor: Extract<ToolDispatchDescriptor, { kind: "server"; op: "query" }>,
|
|
186
|
+
dispatcher: ToolDispatcher,
|
|
187
|
+
user: SessionUser,
|
|
188
|
+
input: Readonly<Record<string, unknown>>,
|
|
189
|
+
): Promise<ToolCallResult> {
|
|
190
|
+
if (descriptor.detail) {
|
|
191
|
+
const id = input["id"];
|
|
192
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
193
|
+
return { ok: false, error: `This tool requires a non-empty string "id" argument` };
|
|
194
|
+
}
|
|
195
|
+
const data = await dispatcher.query(descriptor.qn, { id }, user);
|
|
196
|
+
return { ok: true, data };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (descriptor.list) {
|
|
200
|
+
const payload = buildEntityListPayload(descriptor.list, input);
|
|
201
|
+
if (!payload.ok) return payload;
|
|
202
|
+
const data = await dispatcher.query(descriptor.qn, payload.value, user);
|
|
203
|
+
return { ok: true, data };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const data = await dispatcher.query(descriptor.qn, input, user);
|
|
207
|
+
return { ok: true, data };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Runs a write tool call: optionally injects the current `version` (read fresh from the
|
|
211
|
+
* paired detail handler right before the optimistic-lock write), dispatches with a
|
|
212
|
+
* run-scoped idempotency key, and — on success — re-reads through the detail handler so the
|
|
213
|
+
* returned data goes through the field-level read filter (the raw write result does not). */
|
|
214
|
+
async function dispatchServerWrite(
|
|
215
|
+
descriptor: Extract<ToolDispatchDescriptor, { kind: "server"; op: "write" }>,
|
|
216
|
+
dispatcher: ToolDispatcher,
|
|
217
|
+
user: SessionUser,
|
|
218
|
+
input: Readonly<Record<string, unknown>>,
|
|
219
|
+
runId: string,
|
|
220
|
+
toolCallId: string,
|
|
221
|
+
): Promise<ToolCallResult> {
|
|
222
|
+
let payload: Readonly<Record<string, unknown>> = input;
|
|
223
|
+
|
|
224
|
+
if (descriptor.injectsVersion) {
|
|
225
|
+
const id = input["id"];
|
|
226
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
227
|
+
return { ok: false, error: `This tool requires a non-empty string "id" argument` };
|
|
228
|
+
}
|
|
229
|
+
if (!descriptor.detailQn) {
|
|
230
|
+
return { ok: false, error: "Tool is misconfigured: no detail handler to read version from" };
|
|
231
|
+
}
|
|
232
|
+
const current = await dispatcher.query(descriptor.detailQn, { id }, user);
|
|
233
|
+
const version =
|
|
234
|
+
isRecord(current) && typeof current["version"] === "number" ? current["version"] : undefined;
|
|
235
|
+
if (version === undefined) {
|
|
236
|
+
return { ok: false, error: `Could not read the current "version" for id "${id}"` };
|
|
237
|
+
}
|
|
238
|
+
payload = { ...input, version };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const requestId = `${runId}:${toolCallId}`;
|
|
242
|
+
const result = await dispatcher.write(descriptor.qn, payload, user, requestId);
|
|
243
|
+
|
|
244
|
+
if (!result.isSuccess) {
|
|
245
|
+
return { ok: false, error: result.error.message };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const { id, version } = extractIdAndVersion(result.data);
|
|
249
|
+
const scalars = { ...(id !== undefined && { id }), ...(version !== undefined && { version }) };
|
|
250
|
+
|
|
251
|
+
if (descriptor.detailQn && id !== undefined) {
|
|
252
|
+
try {
|
|
253
|
+
const data = await dispatcher.query(descriptor.detailQn, { id }, user);
|
|
254
|
+
return { ok: true, data };
|
|
255
|
+
} catch {
|
|
256
|
+
return { ok: true, data: scalars };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { ok: true, data: scalars };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function dispatchClientNavigate(
|
|
264
|
+
descriptor: Extract<ToolDispatchDescriptor, { kind: "client"; op: "navigate" }>,
|
|
265
|
+
input: Readonly<Record<string, unknown>>,
|
|
266
|
+
): ToolCallResult {
|
|
267
|
+
const entity = input["entity"];
|
|
268
|
+
if (typeof entity === "string") {
|
|
269
|
+
const screenId = descriptor.entityScreens.get(entity);
|
|
270
|
+
if (!screenId) return { ok: false, error: `Unknown entity "${entity}" for navigation` };
|
|
271
|
+
const id = input["id"];
|
|
272
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
273
|
+
return { ok: false, error: `Navigating to "${entity}" requires a non-empty string "id"` };
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, data: { kind: "navigate", screenId, params: { id } } };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const screenId = input["screenId"];
|
|
279
|
+
if (typeof screenId === "string") {
|
|
280
|
+
if (!descriptor.screenIds.has(screenId)) {
|
|
281
|
+
return { ok: false, error: `Unknown screen "${screenId}"` };
|
|
282
|
+
}
|
|
283
|
+
const params = input["params"];
|
|
284
|
+
if (params !== undefined) {
|
|
285
|
+
if (!isRecord(params) || Object.values(params).some((value) => typeof value !== "string")) {
|
|
286
|
+
return { ok: false, error: `"params" must be an object of string values` };
|
|
287
|
+
}
|
|
288
|
+
return { ok: true, data: { kind: "navigate", screenId, params } };
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, data: { kind: "navigate", screenId, params: {} } };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { ok: false, error: `navigate requires either { entity, id } or { screenId }` };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function dispatchClientOpenForm(
|
|
297
|
+
descriptor: Extract<ToolDispatchDescriptor, { kind: "client"; op: "open_form" }>,
|
|
298
|
+
input: Readonly<Record<string, unknown>>,
|
|
299
|
+
): ToolCallResult {
|
|
300
|
+
const handlerQn = input["handlerQn"];
|
|
301
|
+
if (typeof handlerQn !== "string") {
|
|
302
|
+
return { ok: false, error: `open_form requires a string "handlerQn" argument` };
|
|
303
|
+
}
|
|
304
|
+
const screenId = descriptor.formScreens.get(handlerQn);
|
|
305
|
+
if (!screenId) return { ok: false, error: `No form screen mounted for handler "${handlerQn}"` };
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
data: { kind: "open_form", screenId, handlerQn, prefill: input["prefill"] ?? {} },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isStringArray(value: unknown): value is readonly string[] {
|
|
313
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function dispatchClientAskUser(input: Readonly<Record<string, unknown>>): ToolCallResult {
|
|
317
|
+
const question = input["question"];
|
|
318
|
+
if (typeof question !== "string" || question.length === 0) {
|
|
319
|
+
return { ok: false, error: `ask_user requires a non-empty string "question" argument` };
|
|
320
|
+
}
|
|
321
|
+
const options = input["options"];
|
|
322
|
+
if (options === undefined) {
|
|
323
|
+
return { ok: true, data: { kind: "ask_user", question } };
|
|
324
|
+
}
|
|
325
|
+
if (!isStringArray(options)) {
|
|
326
|
+
return { ok: false, error: `ask_user "options" argument must be an array of strings` };
|
|
327
|
+
}
|
|
328
|
+
return { ok: true, data: { kind: "ask_user", question, options } };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function dispatchClient(
|
|
332
|
+
descriptor: Extract<ToolDispatchDescriptor, { kind: "client" }>,
|
|
333
|
+
input: Readonly<Record<string, unknown>>,
|
|
334
|
+
): ToolCallResult {
|
|
335
|
+
switch (descriptor.op) {
|
|
336
|
+
case "navigate":
|
|
337
|
+
return dispatchClientNavigate(descriptor, input);
|
|
338
|
+
case "open_form":
|
|
339
|
+
return dispatchClientOpenForm(descriptor, input);
|
|
340
|
+
case "ask_user":
|
|
341
|
+
return dispatchClientAskUser(input);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
|
|
346
|
+
* disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
|
|
347
|
+
* back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
|
|
348
|
+
* crashing. `request.user` must be the caller's real identity — every server-kind tool runs
|
|
349
|
+
* through the same permission-checked dispatcher pipeline as a normal HTTP request. */
|
|
350
|
+
export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult> {
|
|
351
|
+
const { dispatcher, user, toolName, input, dispatchTable, runId, toolCallId } = request;
|
|
352
|
+
const descriptor = dispatchTable.get(toolName);
|
|
353
|
+
if (!descriptor) {
|
|
354
|
+
return { ok: false, error: `Unknown tool "${toolName}"` };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
switch (descriptor.kind) {
|
|
359
|
+
case "search":
|
|
360
|
+
return await dispatchSearch(descriptor, dispatcher, user, toolName, input);
|
|
361
|
+
case "findBy":
|
|
362
|
+
return await dispatchFindBy(descriptor, dispatcher, user, toolName, input);
|
|
363
|
+
case "client":
|
|
364
|
+
return dispatchClient(descriptor, input);
|
|
365
|
+
case "server":
|
|
366
|
+
return descriptor.op === "query"
|
|
367
|
+
? await dispatchServerQuery(descriptor, dispatcher, user, input)
|
|
368
|
+
: await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId);
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
372
|
+
}
|
|
373
|
+
}
|
package/src/agent-tools/types.ts
CHANGED
|
@@ -10,6 +10,12 @@ import type {
|
|
|
10
10
|
WriteHandlerDef,
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
|
|
13
|
+
export type AgentToolMode = "read-only" | "approval" | "edit";
|
|
14
|
+
|
|
15
|
+
export type ToolCatalogOptions = {
|
|
16
|
+
readonly mode: AgentToolMode;
|
|
17
|
+
};
|
|
18
|
+
|
|
13
19
|
/** Mirrors `ToolDefinition` in `@cosmicdriftgamestudio/kumiko-ai-foundation` (providers/types.ts)
|
|
14
20
|
* field-for-field so a generated catalog needs no translation layer at the call site. Kept as
|
|
15
21
|
* a local, dependency-free type — agent-tools has no ai-foundation/enterprise dependency. */
|
|
@@ -40,7 +46,53 @@ export type ToolDispatchDescriptor =
|
|
|
40
46
|
readonly entityName: string;
|
|
41
47
|
readonly fieldName: string;
|
|
42
48
|
readonly qn: string;
|
|
43
|
-
}
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: "server";
|
|
52
|
+
readonly op: "query";
|
|
53
|
+
readonly qn: string;
|
|
54
|
+
readonly risk: AgentRisk;
|
|
55
|
+
readonly entity?: string;
|
|
56
|
+
/** Set for `list_<entity>`: dispatch builds the entityListSchema payload
|
|
57
|
+
* itself and validates field names against these allowlists. */
|
|
58
|
+
readonly list?: {
|
|
59
|
+
readonly searchableFields: readonly string[];
|
|
60
|
+
readonly filterableFields: readonly string[];
|
|
61
|
+
};
|
|
62
|
+
/** Set for `get_<entity>`. */
|
|
63
|
+
readonly detail?: true;
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
readonly kind: "server";
|
|
67
|
+
readonly op: "write";
|
|
68
|
+
readonly qn: string;
|
|
69
|
+
readonly risk: AgentRisk;
|
|
70
|
+
readonly entity?: string;
|
|
71
|
+
/** `<feature>:query:<entity>:detail` QN when one is mounted and readable.
|
|
72
|
+
* Dispatch reads the current `version` from it before an optimistic-lock
|
|
73
|
+
* write, and re-reads through it after a successful write so the result
|
|
74
|
+
* goes through the field-level read filter. */
|
|
75
|
+
readonly detailQn?: string;
|
|
76
|
+
/** True when the handler schema requires `version` and `detailQn` exists,
|
|
77
|
+
* so the catalog stripped `version` from the model-facing input schema. */
|
|
78
|
+
readonly injectsVersion?: boolean;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
readonly kind: "client";
|
|
82
|
+
readonly op: "navigate";
|
|
83
|
+
/** entity name → detail screen id, resolved from the manifest's `detailFor`. */
|
|
84
|
+
readonly entityScreens: ReadonlyMap<string, string>;
|
|
85
|
+
/** Every screen id present in the role-filtered manifest — the allowlist
|
|
86
|
+
* for the `{ screenId, params }` form. */
|
|
87
|
+
readonly screenIds: ReadonlySet<string>;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly kind: "client";
|
|
91
|
+
readonly op: "open_form";
|
|
92
|
+
/** write-handler QN → actionForm/entityEdit screen id. */
|
|
93
|
+
readonly formScreens: ReadonlyMap<string, string>;
|
|
94
|
+
}
|
|
95
|
+
| { readonly kind: "client"; readonly op: "ask_user" };
|
|
44
96
|
|
|
45
97
|
export type ToolCatalog = {
|
|
46
98
|
readonly tools: readonly ToolDefinition[];
|
|
@@ -97,6 +149,11 @@ export type AgentManifestScreen = {
|
|
|
97
149
|
readonly entity?: string;
|
|
98
150
|
readonly params: readonly string[];
|
|
99
151
|
readonly workspaces: readonly string[];
|
|
152
|
+
/** Entity this screen is the detail view for. Distinct from `entity`: a custom
|
|
153
|
+
* screen can render entity A while being the detail view for B. */
|
|
154
|
+
readonly detailFor?: string;
|
|
155
|
+
/** Write-handler QN an `actionForm` screen submits to. */
|
|
156
|
+
readonly handler?: string;
|
|
100
157
|
};
|
|
101
158
|
|
|
102
159
|
export type AgentManifestNav = {
|
|
@@ -120,6 +177,9 @@ export type AgentManifestFeature = {
|
|
|
120
177
|
};
|
|
121
178
|
|
|
122
179
|
export type AgentManifest = {
|
|
180
|
+
/** Roles this manifest was built for. `buildToolCatalog` derives its role filter from here so
|
|
181
|
+
* the registry-derived and manifest-derived halves of the catalog cannot disagree. */
|
|
182
|
+
readonly builtForRoles: readonly string[];
|
|
123
183
|
readonly features: readonly AgentManifestFeature[];
|
|
124
184
|
readonly entities: readonly AgentManifestEntity[];
|
|
125
185
|
readonly handlers: readonly AgentManifestHandler[];
|