@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.
@@ -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
- tools: ToolDefinition[],
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
- tools.push(tool);
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
- tools.push(tool);
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
- /** Registry snapshot → agent tool catalog. Pure, deterministic, no I/O, no permission check —
111
- * every tool here is a name+schema only; `tool-dispatch.ts` is what actually calls a
112
- * permission-checked query handler when the LLM invokes one of these by name. Iterates mounted
113
- * `:list` handlers (not `getAllEntities()`) so the catalog never advertises a tool for an entity
114
- * that has no callable list handler — a wasted tool-call the LLM would just fail on. */
115
- export function buildToolCatalog(registry: RegistrySearchView): ToolCatalog {
116
- const tools: ToolDefinition[] = [];
117
- const dispatchTable = new Map<string, ToolDispatchDescriptor>();
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
- for (const [qn] of registry.getAllQueryHandlers()) {
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, tools, dispatchTable);
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
- return { tools, dispatchTable };
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
  }