@cosmicdrift/kumiko-bundled-features 0.235.4 → 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,11 +1,21 @@
|
|
|
1
1
|
import type { EntityDefinition, FieldDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { hasAccess } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import type {
|
|
4
|
+
AgentManifest,
|
|
5
|
+
AgentManifestEntity,
|
|
6
|
+
AgentManifestHandler,
|
|
7
|
+
AgentManifestScreen,
|
|
8
|
+
AgentToolMode,
|
|
3
9
|
RegistrySearchView,
|
|
4
10
|
ToolCatalog,
|
|
11
|
+
ToolCatalogOptions,
|
|
5
12
|
ToolDefinition,
|
|
6
13
|
ToolDispatchDescriptor,
|
|
7
14
|
} from "./types";
|
|
8
15
|
|
|
16
|
+
const FILTER_OPS = ["eq", "ne", "lt", "gt", "in"] as const;
|
|
17
|
+
const MAX_TOOL_NAME_LENGTH = 64;
|
|
18
|
+
|
|
9
19
|
/** Field types that declare `filterable` (per `packages/framework/src/engine/types/fields.ts`).
|
|
10
20
|
* Mapped to the JSON-Schema type an LLM tool-call argument should use. `undefined` = field type
|
|
11
21
|
* is skipped for exact-lookup tools (not filterable at the type level). */
|
|
@@ -80,19 +90,21 @@ function isListHandlerQn(qn: string, entityName: string): boolean {
|
|
|
80
90
|
return qn.endsWith(`:${entityName}:list`);
|
|
81
91
|
}
|
|
82
92
|
|
|
93
|
+
function isDetailHandlerQn(qn: string, entityName: string): boolean {
|
|
94
|
+
return qn.endsWith(`:${entityName}:detail`);
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
function addToolsForListHandler(
|
|
84
98
|
registry: RegistrySearchView,
|
|
85
99
|
qn: string,
|
|
86
100
|
entityName: string,
|
|
87
101
|
entity: EntityDefinition,
|
|
88
|
-
|
|
89
|
-
dispatchTable: Map<string, ToolDispatchDescriptor>,
|
|
102
|
+
sink: CatalogSink,
|
|
90
103
|
): void {
|
|
91
104
|
const searchableFields = registry.getSearchableFields(entityName);
|
|
92
105
|
if (searchableFields.length > 0) {
|
|
93
106
|
const tool = buildSearchTool(entityName, searchableFields);
|
|
94
|
-
|
|
95
|
-
dispatchTable.set(tool.name, { kind: "search", entityName, qn });
|
|
107
|
+
addTool(sink, tool, { kind: "search", entityName, qn });
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
for (const [fieldName, field] of Object.entries(
|
|
@@ -102,29 +114,457 @@ function addToolsForListHandler(
|
|
|
102
114
|
const fieldSchema = jsonSchemaTypeForField(field);
|
|
103
115
|
if (!fieldSchema) continue;
|
|
104
116
|
const tool = buildFindByTool(entityName, fieldName, fieldSchema);
|
|
105
|
-
|
|
106
|
-
dispatchTable.set(tool.name, { kind: "findBy", entityName, fieldName, qn });
|
|
117
|
+
addTool(sink, tool, { kind: "findBy", entityName, fieldName, qn });
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
121
|
+
function entityDisplayLabel(
|
|
122
|
+
entity: AgentManifestEntity | undefined,
|
|
123
|
+
entityName: string,
|
|
124
|
+
locale: string,
|
|
125
|
+
): string {
|
|
126
|
+
if (entity?.description) return entity.description;
|
|
127
|
+
const label = entity?.labels[locale];
|
|
128
|
+
if (label) return label;
|
|
129
|
+
return entityName;
|
|
130
|
+
}
|
|
118
131
|
|
|
119
|
-
|
|
132
|
+
function buildGetTool(
|
|
133
|
+
entityName: string,
|
|
134
|
+
qn: string,
|
|
135
|
+
label: string,
|
|
136
|
+
): { tool: ToolDefinition; descriptor: ToolDispatchDescriptor } {
|
|
137
|
+
return {
|
|
138
|
+
tool: {
|
|
139
|
+
name: `get_${entityName}`,
|
|
140
|
+
description: `Fetch a single ${label} by its id.`,
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: { id: { type: "string", description: "Record id (uuid)" } },
|
|
144
|
+
required: ["id"],
|
|
145
|
+
additionalProperties: false,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
descriptor: { kind: "server", op: "query", qn, risk: "low", entity: entityName, detail: true },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildListInputSchema(
|
|
153
|
+
searchableFields: readonly string[],
|
|
154
|
+
filterableFields: readonly string[],
|
|
155
|
+
): Readonly<Record<string, unknown>> {
|
|
156
|
+
const properties: Record<string, unknown> = {};
|
|
157
|
+
if (searchableFields.length > 0) {
|
|
158
|
+
properties["search"] = {
|
|
159
|
+
type: "string",
|
|
160
|
+
description: `Free-text search over: ${searchableFields.join(", ")}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (filterableFields.length > 0) {
|
|
164
|
+
properties["filters"] = {
|
|
165
|
+
type: "array",
|
|
166
|
+
description: `Filters combined with AND. Filterable fields: ${filterableFields.join(", ")}`,
|
|
167
|
+
items: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
field: { type: "string", enum: filterableFields },
|
|
171
|
+
op: { type: "string", enum: FILTER_OPS },
|
|
172
|
+
value: {},
|
|
173
|
+
},
|
|
174
|
+
required: ["field", "op", "value"],
|
|
175
|
+
additionalProperties: false,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
properties["limit"] = { type: "integer", minimum: 1, maximum: 200 };
|
|
180
|
+
return { type: "object", properties, required: [], additionalProperties: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildListTool(
|
|
184
|
+
entityName: string,
|
|
185
|
+
qn: string,
|
|
186
|
+
label: string,
|
|
187
|
+
searchableFields: readonly string[],
|
|
188
|
+
filterableFields: readonly string[],
|
|
189
|
+
): { tool: ToolDefinition; descriptor: ToolDispatchDescriptor } {
|
|
190
|
+
const descriptionParts = [`List ${label} records.`];
|
|
191
|
+
if (searchableFields.length > 0) {
|
|
192
|
+
descriptionParts.push(`Free-text search over: ${searchableFields.join(", ")}.`);
|
|
193
|
+
}
|
|
194
|
+
if (filterableFields.length > 0) {
|
|
195
|
+
descriptionParts.push(`Filterable fields: ${filterableFields.join(", ")}.`);
|
|
196
|
+
}
|
|
197
|
+
descriptionParts.push("The result carries a total count.");
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
tool: {
|
|
201
|
+
name: `list_${entityName}`,
|
|
202
|
+
description: descriptionParts.join(" "),
|
|
203
|
+
inputSchema: buildListInputSchema(searchableFields, filterableFields),
|
|
204
|
+
},
|
|
205
|
+
descriptor: {
|
|
206
|
+
kind: "server",
|
|
207
|
+
op: "query",
|
|
208
|
+
qn,
|
|
209
|
+
risk: "low",
|
|
210
|
+
entity: entityName,
|
|
211
|
+
list: { searchableFields, filterableFields },
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function filterableFieldsOf(entity: AgentManifestEntity | undefined): readonly string[] {
|
|
217
|
+
if (!entity) return [];
|
|
218
|
+
return entity.fields.filter((field) => field.filterable === true).map((field) => field.name);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function compareByCodePoint(a: string, b: string): number {
|
|
222
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
226
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function schemaRequiresField(schema: Readonly<Record<string, unknown>>, field: string): boolean {
|
|
230
|
+
const required = schema["required"];
|
|
231
|
+
return Array.isArray(required) && required.includes(field);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Strips `field` from both `properties` and `required` of a JSON Schema object — used to
|
|
235
|
+
* remove `version` from a write tool's model-facing input schema when dispatch injects it
|
|
236
|
+
* itself (read fresh from the paired detail handler right before the optimistic-lock write). */
|
|
237
|
+
function stripFieldFromSchema(
|
|
238
|
+
schema: Readonly<Record<string, unknown>>,
|
|
239
|
+
field: string,
|
|
240
|
+
): Readonly<Record<string, unknown>> {
|
|
241
|
+
const { properties, required, ...rest } = schema;
|
|
242
|
+
const strippedProperties = isRecord(properties)
|
|
243
|
+
? Object.fromEntries(Object.entries(properties).filter(([key]) => key !== field))
|
|
244
|
+
: properties;
|
|
245
|
+
const strippedRequired = Array.isArray(required)
|
|
246
|
+
? required.filter((entry) => entry !== field)
|
|
247
|
+
: required;
|
|
248
|
+
return { ...rest, properties: strippedProperties, required: strippedRequired };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function handlerDescription(handler: AgentManifestHandler): string {
|
|
252
|
+
return handler.description.length > 0 ? handler.description : `Run ${handler.qn}.`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
|
|
256
|
+
* sanitizes to a valid tool-call identifier. Only the FIRST matching segment is dropped —
|
|
257
|
+
* QNs are `<feature>:query|write:<entity>:<verb>`, so the verb only ever appears once at that
|
|
258
|
+
* position; a blanket filter could accidentally eat a legitimately-named later segment. */
|
|
259
|
+
export function toolNameForQn(qn: string): string {
|
|
260
|
+
const segments = qn.split(":");
|
|
261
|
+
const verbIndex = segments.findIndex((segment) => segment === "query" || segment === "write");
|
|
262
|
+
if (verbIndex !== -1) segments.splice(verbIndex, 1);
|
|
263
|
+
const joined = segments.join("_");
|
|
264
|
+
const sanitized = joined.replace(/[^A-Za-z0-9_]/g, "_").replace(/_+/g, "_");
|
|
265
|
+
return sanitized.slice(0, MAX_TOOL_NAME_LENGTH);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const OPEN_FORM_TOOL_NAME = "open_form";
|
|
269
|
+
|
|
270
|
+
function buildNavigateTool(manifest: AgentManifest): {
|
|
271
|
+
tool: ToolDefinition;
|
|
272
|
+
descriptor: ToolDispatchDescriptor;
|
|
273
|
+
} {
|
|
274
|
+
const entityScreens = new Map<string, string>();
|
|
275
|
+
for (const screen of manifest.screens) {
|
|
276
|
+
if (screen.detailFor !== undefined && !entityScreens.has(screen.detailFor)) {
|
|
277
|
+
entityScreens.set(screen.detailFor, screen.id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// A screen is navigable iff it is present in the role-filtered manifest — do not filter
|
|
281
|
+
// additionally on `workspaces`. `buildScreens` gives detail screens (which have no nav
|
|
282
|
+
// pointing at them) `workspaces: []`, and the `{entity,id}` form is exactly the detail-screen
|
|
283
|
+
// case, so a `workspaces.length > 0` filter would reject every legitimate navigate.
|
|
284
|
+
const screenIds = new Set(manifest.screens.map((screen: AgentManifestScreen) => screen.id));
|
|
285
|
+
const sortedScreenIds = [...screenIds].sort(compareByCodePoint);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
tool: {
|
|
289
|
+
name: "navigate",
|
|
290
|
+
description:
|
|
291
|
+
"Navigate the UI. Either { entity, id } to open an entity's detail screen, or " +
|
|
292
|
+
"{ screenId, params } to open a specific screen.",
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: {
|
|
296
|
+
entity: { type: "string", description: "Entity name; resolved to its detail screen." },
|
|
297
|
+
id: { type: "string", description: "Record id, required with `entity`." },
|
|
298
|
+
screenId: { type: "string", enum: sortedScreenIds },
|
|
299
|
+
params: { type: "object", additionalProperties: { type: "string" } },
|
|
300
|
+
},
|
|
301
|
+
required: [],
|
|
302
|
+
additionalProperties: false,
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
descriptor: { kind: "client", op: "navigate", entityScreens, screenIds },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildFormScreens(manifest: AgentManifest): ReadonlyMap<string, string> {
|
|
310
|
+
const formScreens = new Map<string, string>();
|
|
311
|
+
for (const screen of manifest.screens) {
|
|
312
|
+
if (screen.type === "actionForm" && screen.handler !== undefined) {
|
|
313
|
+
formScreens.set(screen.handler, screen.id);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const screen of manifest.screens) {
|
|
317
|
+
if (screen.type !== "entityEdit" || screen.entity === undefined) continue;
|
|
318
|
+
for (const handler of manifest.handlers) {
|
|
319
|
+
if (handler.kind !== "write") continue;
|
|
320
|
+
if (
|
|
321
|
+
handler.qn.endsWith(`:${screen.entity}:create`) ||
|
|
322
|
+
handler.qn.endsWith(`:${screen.entity}:update`)
|
|
323
|
+
) {
|
|
324
|
+
formScreens.set(handler.qn, screen.id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return formScreens;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function buildAskUserTool(): { tool: ToolDefinition; descriptor: ToolDispatchDescriptor } {
|
|
332
|
+
return {
|
|
333
|
+
tool: {
|
|
334
|
+
name: "ask_user",
|
|
335
|
+
description: "Ask the user a clarifying question, optionally offering suggested answers.",
|
|
336
|
+
inputSchema: {
|
|
337
|
+
type: "object",
|
|
338
|
+
properties: {
|
|
339
|
+
question: { type: "string" },
|
|
340
|
+
options: { type: "array", items: { type: "string" } },
|
|
341
|
+
},
|
|
342
|
+
required: ["question"],
|
|
343
|
+
additionalProperties: false,
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
descriptor: { kind: "client", op: "ask_user" },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
type CatalogSink = {
|
|
351
|
+
readonly tools: ToolDefinition[];
|
|
352
|
+
readonly dispatchTable: Map<string, ToolDispatchDescriptor>;
|
|
353
|
+
readonly usedNames: Set<string>;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
function claimDispatchName(
|
|
357
|
+
sink: CatalogSink,
|
|
358
|
+
name: string,
|
|
359
|
+
descriptor: ToolDispatchDescriptor,
|
|
360
|
+
): boolean {
|
|
361
|
+
if (sink.usedNames.has(name)) return false;
|
|
362
|
+
sink.dispatchTable.set(name, descriptor);
|
|
363
|
+
sink.usedNames.add(name);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function addTool(
|
|
368
|
+
sink: CatalogSink,
|
|
369
|
+
tool: ToolDefinition,
|
|
370
|
+
descriptor: ToolDispatchDescriptor,
|
|
371
|
+
): void {
|
|
372
|
+
if (claimDispatchName(sink, tool.name, descriptor)) sink.tools.push(tool);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function addRegistrySearchTools(
|
|
376
|
+
registry: RegistrySearchView,
|
|
377
|
+
roleFilter: { roles: readonly string[] },
|
|
378
|
+
sink: CatalogSink,
|
|
379
|
+
): void {
|
|
380
|
+
for (const [qn, def] of registry.getAllQueryHandlers()) {
|
|
120
381
|
const entityName = registry.getHandlerEntity(qn);
|
|
121
382
|
if (!entityName || !isListHandlerQn(qn, entityName)) continue;
|
|
383
|
+
if (!hasAccess(roleFilter, def.access)) continue;
|
|
122
384
|
|
|
123
385
|
const entity = registry.getEntity(entityName);
|
|
124
386
|
if (!entity) continue;
|
|
125
387
|
|
|
126
|
-
addToolsForListHandler(registry, qn, entityName, entity,
|
|
388
|
+
addToolsForListHandler(registry, qn, entityName, entity, sink);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
type EntityHandlerQns = {
|
|
393
|
+
readonly detailQnByEntity: ReadonlyMap<string, string>;
|
|
394
|
+
readonly listQnByEntity: ReadonlyMap<string, string>;
|
|
395
|
+
readonly entityListDetailQns: ReadonlySet<string>;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
function collectEntityHandlerQns(
|
|
399
|
+
registry: RegistrySearchView,
|
|
400
|
+
roleFilter: { roles: readonly string[] },
|
|
401
|
+
): EntityHandlerQns {
|
|
402
|
+
const detailQnByEntity = new Map<string, string>();
|
|
403
|
+
const listQnByEntity = new Map<string, string>();
|
|
404
|
+
// Structural set (role-independent): every qn shaped like an entity list/detail handler, used
|
|
405
|
+
// to keep query-handler tools from ever double-registering a handler already covered by get_/list_.
|
|
406
|
+
const entityListDetailQns = new Set<string>();
|
|
407
|
+
for (const [qn] of registry.getAllQueryHandlers()) {
|
|
408
|
+
const entityName = registry.getHandlerEntity(qn);
|
|
409
|
+
if (!entityName) continue;
|
|
410
|
+
if (isDetailHandlerQn(qn, entityName)) entityListDetailQns.add(qn);
|
|
411
|
+
if (isListHandlerQn(qn, entityName)) entityListDetailQns.add(qn);
|
|
412
|
+
}
|
|
413
|
+
for (const [qn, def] of registry.getAllQueryHandlers()) {
|
|
414
|
+
const entityName = registry.getHandlerEntity(qn);
|
|
415
|
+
if (!entityName || !hasAccess(roleFilter, def.access)) continue;
|
|
416
|
+
if (isDetailHandlerQn(qn, entityName)) detailQnByEntity.set(entityName, qn);
|
|
417
|
+
if (isListHandlerQn(qn, entityName)) listQnByEntity.set(entityName, qn);
|
|
418
|
+
}
|
|
419
|
+
return { detailQnByEntity, listQnByEntity, entityListDetailQns };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function addGetTools(
|
|
423
|
+
detailQnByEntity: ReadonlyMap<string, string>,
|
|
424
|
+
entityByName: ReadonlyMap<string, AgentManifestEntity>,
|
|
425
|
+
locale: string,
|
|
426
|
+
sink: CatalogSink,
|
|
427
|
+
): void {
|
|
428
|
+
const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
|
|
429
|
+
for (const [entityName, qn] of getEntries) {
|
|
430
|
+
const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
|
|
431
|
+
const { tool, descriptor } = buildGetTool(entityName, qn, label);
|
|
432
|
+
addTool(sink, tool, descriptor);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function addListTools(
|
|
437
|
+
registry: RegistrySearchView,
|
|
438
|
+
listQnByEntity: ReadonlyMap<string, string>,
|
|
439
|
+
entityByName: ReadonlyMap<string, AgentManifestEntity>,
|
|
440
|
+
locale: string,
|
|
441
|
+
sink: CatalogSink,
|
|
442
|
+
): void {
|
|
443
|
+
const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
|
|
444
|
+
for (const [entityName, qn] of listEntries) {
|
|
445
|
+
const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
|
|
446
|
+
const searchableFields = registry.getSearchableFields(entityName);
|
|
447
|
+
const filterableFields = filterableFieldsOf(entityByName.get(entityName));
|
|
448
|
+
const { tool, descriptor } = buildListTool(
|
|
449
|
+
entityName,
|
|
450
|
+
qn,
|
|
451
|
+
label,
|
|
452
|
+
searchableFields,
|
|
453
|
+
filterableFields,
|
|
454
|
+
);
|
|
455
|
+
addTool(sink, tool, descriptor);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function addQueryHandlerTools(
|
|
460
|
+
manifest: AgentManifest,
|
|
461
|
+
entityListDetailQns: ReadonlySet<string>,
|
|
462
|
+
sink: CatalogSink,
|
|
463
|
+
): void {
|
|
464
|
+
for (const handler of manifest.handlers) {
|
|
465
|
+
if (handler.kind !== "query") continue;
|
|
466
|
+
if (entityListDetailQns.has(handler.qn)) continue;
|
|
467
|
+
const name = toolNameForQn(handler.qn);
|
|
468
|
+
|
|
469
|
+
addTool(
|
|
470
|
+
sink,
|
|
471
|
+
{ name, description: handlerDescription(handler), inputSchema: handler.inputSchema },
|
|
472
|
+
{
|
|
473
|
+
kind: "server",
|
|
474
|
+
op: "query",
|
|
475
|
+
qn: handler.qn,
|
|
476
|
+
risk: handler.risk,
|
|
477
|
+
...(handler.entity !== undefined && { entity: handler.entity }),
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function addWriteHandlerTools(
|
|
484
|
+
manifest: AgentManifest,
|
|
485
|
+
detailQnByEntity: ReadonlyMap<string, string>,
|
|
486
|
+
sink: CatalogSink,
|
|
487
|
+
): void {
|
|
488
|
+
for (const handler of manifest.handlers) {
|
|
489
|
+
if (handler.kind !== "write") continue;
|
|
490
|
+
const name = toolNameForQn(handler.qn);
|
|
491
|
+
|
|
492
|
+
const detailQn =
|
|
493
|
+
handler.entity !== undefined ? detailQnByEntity.get(handler.entity) : undefined;
|
|
494
|
+
const injectsVersion =
|
|
495
|
+
detailQn !== undefined && schemaRequiresField(handler.inputSchema, "version");
|
|
496
|
+
const inputSchema = injectsVersion
|
|
497
|
+
? stripFieldFromSchema(handler.inputSchema, "version")
|
|
498
|
+
: handler.inputSchema;
|
|
499
|
+
|
|
500
|
+
addTool(
|
|
501
|
+
sink,
|
|
502
|
+
{ name, description: handlerDescription(handler), inputSchema },
|
|
503
|
+
{
|
|
504
|
+
kind: "server",
|
|
505
|
+
op: "write",
|
|
506
|
+
qn: handler.qn,
|
|
507
|
+
risk: handler.risk,
|
|
508
|
+
...(handler.entity !== undefined && { entity: handler.entity }),
|
|
509
|
+
...(detailQn !== undefined && { detailQn }),
|
|
510
|
+
...(injectsVersion && { injectsVersion: true as const }),
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function addClientTools(manifest: AgentManifest, mode: AgentToolMode, sink: CatalogSink): void {
|
|
517
|
+
// Same precedence rule as every loop above: a name already claimed by a handler-derived tool
|
|
518
|
+
// wins, and a built-in never overwrites it — only the order differs (built-ins run last).
|
|
519
|
+
const navigate = buildNavigateTool(manifest);
|
|
520
|
+
addTool(sink, navigate.tool, navigate.descriptor);
|
|
521
|
+
|
|
522
|
+
// open_form is dispatch-only by design: the approval layer triggers it, the model never calls it.
|
|
523
|
+
if (mode !== "read-only") {
|
|
524
|
+
claimDispatchName(sink, OPEN_FORM_TOOL_NAME, {
|
|
525
|
+
kind: "client",
|
|
526
|
+
op: "open_form",
|
|
527
|
+
formScreens: buildFormScreens(manifest),
|
|
528
|
+
});
|
|
127
529
|
}
|
|
128
530
|
|
|
129
|
-
|
|
531
|
+
const askUser = buildAskUserTool();
|
|
532
|
+
addTool(sink, askUser.tool, askUser.descriptor);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Registry snapshot + role-filtered manifest → agent tool catalog. Pure, deterministic, no I/O
|
|
536
|
+
* — every tool here is a name+schema only; `tool-dispatch.ts` is what actually calls a
|
|
537
|
+
* permission-checked handler when the LLM invokes one of these by name.
|
|
538
|
+
*
|
|
539
|
+
* `search_<entity>` / `find_<entity>_by_<field>` iterate mounted `:list` handlers directly off
|
|
540
|
+
* the registry (unchanged from the original design). `get_<entity>` / `list_<entity>` are also
|
|
541
|
+
* enumerated from the registry rather than `manifest.handlers`, because entity CRUD handlers
|
|
542
|
+
* carry no `description`/`agent` and are therefore never role-exposed into the manifest (see
|
|
543
|
+
* `resolveAgentExposure`) — the manifest can't tell us these handlers exist at all. Every other
|
|
544
|
+
* tool (custom query/write handlers, navigate/open_form/ask_user) is manifest-derived, since
|
|
545
|
+
* the manifest already carries the role-filtered handler/screen shape needed for those. Roles
|
|
546
|
+
* and locale both come from the manifest (`manifest.builtForRoles` / `manifest.tenantSettings.locale`)
|
|
547
|
+
* so the registry-derived and manifest-derived halves share one source and cannot disagree. */
|
|
548
|
+
export function buildToolCatalog(
|
|
549
|
+
registry: RegistrySearchView,
|
|
550
|
+
manifest: AgentManifest,
|
|
551
|
+
options: ToolCatalogOptions,
|
|
552
|
+
): ToolCatalog {
|
|
553
|
+
const sink: CatalogSink = { tools: [], dispatchTable: new Map(), usedNames: new Set() };
|
|
554
|
+
const roleFilter = { roles: manifest.builtForRoles };
|
|
555
|
+
const locale = manifest.tenantSettings.locale;
|
|
556
|
+
const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
|
|
557
|
+
|
|
558
|
+
addRegistrySearchTools(registry, roleFilter, sink);
|
|
559
|
+
const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(
|
|
560
|
+
registry,
|
|
561
|
+
roleFilter,
|
|
562
|
+
);
|
|
563
|
+
addGetTools(detailQnByEntity, entityByName, locale, sink);
|
|
564
|
+
addListTools(registry, listQnByEntity, entityByName, locale, sink);
|
|
565
|
+
addQueryHandlerTools(manifest, entityListDetailQns, sink);
|
|
566
|
+
if (options.mode !== "read-only") addWriteHandlerTools(manifest, detailQnByEntity, sink);
|
|
567
|
+
addClientTools(manifest, options.mode, sink);
|
|
568
|
+
|
|
569
|
+
return { tools: sink.tools, dispatchTable: sink.dispatchTable };
|
|
130
570
|
}
|