@cosmicdrift/kumiko-bundled-features 0.155.0 → 0.156.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.
Files changed (50) hide show
  1. package/package.json +7 -6
  2. package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
  3. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
  4. package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
  5. package/src/agent-tools/index.ts +9 -0
  6. package/src/agent-tools/tool-catalog.ts +130 -0
  7. package/src/agent-tools/tool-dispatch.ts +82 -0
  8. package/src/agent-tools/types.ts +38 -0
  9. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +5 -1
  10. package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +5 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +14 -4
  12. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +89 -0
  13. package/src/auth-mfa/__tests__/totp.test.ts +2 -2
  14. package/src/auth-mfa/__tests__/verify.integration.test.ts +29 -4
  15. package/src/auth-mfa/constants.ts +4 -0
  16. package/src/auth-mfa/handlers/disable.write.ts +2 -1
  17. package/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
  18. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +4 -3
  19. package/src/auth-mfa/handlers/verify.write.ts +22 -12
  20. package/src/auth-mfa/recovery-codes.ts +2 -2
  21. package/src/auth-mfa/schema/user-mfa.ts +10 -0
  22. package/src/auth-mfa/totp.ts +18 -6
  23. package/src/auth-mfa/verify-factor.ts +30 -2
  24. package/src/auth-mfa/web/i18n.ts +2 -0
  25. package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -29
  26. package/src/auth-mfa/web/mfa-error-keys.ts +2 -0
  27. package/src/auth-mfa/web/qrcode-browser.d.ts +5 -0
  28. package/src/config/__tests__/pii-encrypted.integration.test.ts +158 -0
  29. package/src/config/__tests__/write-helpers.test.ts +23 -0
  30. package/src/config/handlers/set.write.ts +47 -1
  31. package/src/config/resolver.ts +28 -0
  32. package/src/config/write-helpers.ts +22 -0
  33. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  34. package/src/delivery/feature.ts +1 -1
  35. package/src/file-foundation/__tests__/feature.test.ts +1 -1
  36. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +3 -1
  37. package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
  38. package/src/inbound-mail-foundation/feature.ts +3 -3
  39. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +7 -11
  40. package/src/jobs/feature.ts +1 -1
  41. package/src/legal-pages/feature.ts +1 -1
  42. package/src/mail-foundation/__tests__/feature.test.ts +1 -1
  43. package/src/page-render/branding.ts +4 -3
  44. package/src/personal-access-tokens/feature.ts +1 -1
  45. package/src/sessions/__tests__/sessions.integration.test.ts +42 -0
  46. package/src/sessions/feature.ts +2 -2
  47. package/src/user-data-rights/handlers/download-by-job.query.ts +14 -7
  48. package/src/user-data-rights/handlers/download-by-token.query.ts +14 -7
  49. package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
  50. package/src/user-data-rights/lib/tenant-file-provider.ts +0 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.155.0",
3
+ "version": "0.156.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -18,6 +18,7 @@
18
18
  "runtime": "runtime"
19
19
  },
20
20
  "exports": {
21
+ "./agent-tools": "./src/agent-tools/index.ts",
21
22
  "./audit": "./src/audit/index.ts",
22
23
  "./audit/web": "./src/audit/web/index.ts",
23
24
  "./admin-shell": "./src/admin-shell/index.ts",
@@ -115,11 +116,11 @@
115
116
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
116
117
  },
117
118
  "dependencies": {
118
- "@cosmicdrift/kumiko-dispatcher-live": "0.155.0",
119
- "@cosmicdrift/kumiko-framework": "0.155.0",
120
- "@cosmicdrift/kumiko-headless": "0.155.0",
121
- "@cosmicdrift/kumiko-renderer": "0.155.0",
122
- "@cosmicdrift/kumiko-renderer-web": "0.155.0",
119
+ "@cosmicdrift/kumiko-dispatcher-live": "0.156.0",
120
+ "@cosmicdrift/kumiko-framework": "0.156.0",
121
+ "@cosmicdrift/kumiko-headless": "0.156.0",
122
+ "@cosmicdrift/kumiko-renderer": "0.156.0",
123
+ "@cosmicdrift/kumiko-renderer-web": "0.156.0",
123
124
  "@mollie/api-client": "^4.5.0",
124
125
  "imapflow": "^1.3.3",
125
126
  "mailparser": "^3.9.8",
@@ -0,0 +1,179 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createEntity,
4
+ createSelectField,
5
+ createTextField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import type {
8
+ EntityDefinition,
9
+ QueryHandlerDef,
10
+ ReferenceFieldDef,
11
+ } from "@cosmicdrift/kumiko-framework/engine/types";
12
+ import { buildToolCatalog } from "../tool-catalog";
13
+ import type { RegistrySearchView } from "../types";
14
+
15
+ const vendorEntity = createEntity({
16
+ fields: {
17
+ name: createTextField({ searchable: true, filterable: true }),
18
+ iban: createTextField({ filterable: true }),
19
+ notes: createTextField(),
20
+ status: createSelectField({ options: ["active", "archived"] as const, filterable: true }),
21
+ },
22
+ });
23
+
24
+ const invoiceEntity = createEntity({
25
+ fields: {
26
+ vendorId: { type: "reference", entity: "vendor", filterable: true } satisfies ReferenceFieldDef,
27
+ description: createTextField({ searchable: true }),
28
+ },
29
+ });
30
+
31
+ const FAKE_HANDLER = {} as QueryHandlerDef;
32
+
33
+ type EntityFixture = {
34
+ readonly qn: string;
35
+ readonly entityName: string;
36
+ readonly entity: EntityDefinition;
37
+ readonly searchableFields: readonly string[];
38
+ };
39
+
40
+ /** Handler-first fake: one entry per MOUNTED `:list` handler, mirroring what
41
+ * `registry.getAllQueryHandlers()` + `getHandlerEntity()` would return for a real app. There is
42
+ * no `getAllEntities()` here on purpose — an entity with no fixture is exactly "no list handler
43
+ * mounted", the case `buildToolCatalog` must skip. */
44
+ function fakeRegistry(fixtures: readonly EntityFixture[]): RegistrySearchView {
45
+ const byQn = new Map(fixtures.map((f) => [f.qn, FAKE_HANDLER]));
46
+ const entityByQn = new Map(fixtures.map((f) => [f.qn, f.entityName]));
47
+ const entityByName = new Map(fixtures.map((f) => [f.entityName, f.entity]));
48
+ const searchableByName = new Map(fixtures.map((f) => [f.entityName, f.searchableFields]));
49
+
50
+ return {
51
+ getAllQueryHandlers: () => byQn,
52
+ getHandlerEntity: (qn) => entityByQn.get(qn),
53
+ getEntity: (entityName) => entityByName.get(entityName),
54
+ getSearchableFields: (entityName) => searchableByName.get(entityName) ?? [],
55
+ };
56
+ }
57
+
58
+ function vendorFixture(searchableFields: readonly string[] = []): EntityFixture {
59
+ return {
60
+ qn: "vendor-feature:query:vendor:list",
61
+ entityName: "vendor",
62
+ entity: vendorEntity,
63
+ searchableFields,
64
+ };
65
+ }
66
+
67
+ function invoiceFixture(searchableFields: readonly string[] = []): EntityFixture {
68
+ return {
69
+ qn: "invoice-feature:query:invoice:list",
70
+ entityName: "invoice",
71
+ entity: invoiceEntity,
72
+ searchableFields,
73
+ };
74
+ }
75
+
76
+ describe("buildToolCatalog", () => {
77
+ test("generates a search_<entity> tool only when searchable fields exist", () => {
78
+ const registry = fakeRegistry([vendorFixture(["name"]), invoiceFixture(["description"])]);
79
+ const catalog = buildToolCatalog(registry);
80
+ const names = catalog.tools.map((t) => t.name);
81
+
82
+ expect(names).toContain("search_vendor");
83
+ expect(names).toContain("search_invoice");
84
+ });
85
+
86
+ test("skips search_<entity> when the entity has no searchable fields", () => {
87
+ const registry = fakeRegistry([vendorFixture([])]);
88
+ const catalog = buildToolCatalog(registry);
89
+ expect(catalog.tools.map((t) => t.name)).not.toContain("search_vendor");
90
+ });
91
+
92
+ test("skips an entity entirely when it has no mounted :list handler", () => {
93
+ // getHandlerEntity resolves it, but the qn doesn't end in ":<entity>:list" — e.g. a
94
+ // :detail handler picked up by a naive "any handler mentioning this entity" scan.
95
+ const registry: RegistrySearchView = {
96
+ getAllQueryHandlers: () => new Map([["vendor-feature:query:vendor:detail", FAKE_HANDLER]]),
97
+ getHandlerEntity: () => "vendor",
98
+ getEntity: () => vendorEntity,
99
+ getSearchableFields: () => ["name"],
100
+ };
101
+ const catalog = buildToolCatalog(registry);
102
+ expect(catalog.tools).toEqual([]);
103
+ });
104
+
105
+ test("generates one find_<entity>_by_<field> tool per filterable field", () => {
106
+ const registry = fakeRegistry([vendorFixture(["name"])]);
107
+ const catalog = buildToolCatalog(registry);
108
+ const names = catalog.tools.map((t) => t.name);
109
+
110
+ expect(names).toContain("find_vendor_by_name");
111
+ expect(names).toContain("find_vendor_by_iban");
112
+ expect(names).toContain("find_vendor_by_status");
113
+ expect(names).not.toContain("find_vendor_by_notes"); // not filterable
114
+ });
115
+
116
+ test("select field becomes a string schema with an enum of its options", () => {
117
+ const registry = fakeRegistry([vendorFixture()]);
118
+ const catalog = buildToolCatalog(registry);
119
+ const statusTool = catalog.tools.find((t) => t.name === "find_vendor_by_status");
120
+
121
+ expect(statusTool?.inputSchema).toEqual({
122
+ type: "object",
123
+ properties: { status: { type: "string", enum: ["active", "archived"] } },
124
+ required: ["status"],
125
+ additionalProperties: false,
126
+ });
127
+ });
128
+
129
+ test("reference field becomes a string schema describing the referenced entity", () => {
130
+ const registry = fakeRegistry([invoiceFixture()]);
131
+ const catalog = buildToolCatalog(registry);
132
+ const vendorIdTool = catalog.tools.find((t) => t.name === "find_invoice_by_vendorId");
133
+
134
+ expect(vendorIdTool?.inputSchema).toEqual({
135
+ type: "object",
136
+ properties: {
137
+ vendorId: { type: "string", description: 'ID referencing "vendor"' },
138
+ },
139
+ required: ["vendorId"],
140
+ additionalProperties: false,
141
+ });
142
+ });
143
+
144
+ test("search tool description lists every searchable field", () => {
145
+ const registry = fakeRegistry([vendorFixture(["name", "iban"])]);
146
+ const catalog = buildToolCatalog(registry);
147
+ const searchTool = catalog.tools.find((t) => t.name === "search_vendor");
148
+
149
+ expect(searchTool?.description).toContain("name, iban");
150
+ });
151
+
152
+ test("empty registry produces an empty catalog", () => {
153
+ const registry = fakeRegistry([]);
154
+ const catalog = buildToolCatalog(registry);
155
+ expect(catalog.tools).toEqual([]);
156
+ expect(catalog.dispatchTable.size).toBe(0);
157
+ });
158
+
159
+ test("dispatchTable maps search_<entity> to a search descriptor carrying the real qn", () => {
160
+ const registry = fakeRegistry([vendorFixture(["name"])]);
161
+ const catalog = buildToolCatalog(registry);
162
+ expect(catalog.dispatchTable.get("search_vendor")).toEqual({
163
+ kind: "search",
164
+ entityName: "vendor",
165
+ qn: "vendor-feature:query:vendor:list",
166
+ });
167
+ });
168
+
169
+ test("dispatchTable maps find_<entity>_by_<field> to a findBy descriptor carrying the real qn", () => {
170
+ const registry = fakeRegistry([vendorFixture()]);
171
+ const catalog = buildToolCatalog(registry);
172
+ expect(catalog.dispatchTable.get("find_vendor_by_iban")).toEqual({
173
+ kind: "findBy",
174
+ entityName: "vendor",
175
+ fieldName: "iban",
176
+ qn: "vendor-feature:query:vendor:list",
177
+ });
178
+ });
179
+ });
@@ -0,0 +1,100 @@
1
+ // Proves the property the unit tests can't: dispatchToolCall runs through the
2
+ // REAL <entity>:list handler + permission pipeline, not a fake. Two things a
3
+ // hand-rolled recording dispatcher would happily hide:
4
+ // - tenant isolation: tenant A's find_by_iban call must never surface
5
+ // tenant B's row, even when both tenants have the exact same IBAN value.
6
+ // - cap enforcement: a caller without the list handler's required role
7
+ // gets denied, not an empty-but-"ok" result.
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
+ import {
14
+ createTestUser,
15
+ setupTestStack,
16
+ type TestStack,
17
+ unsafeCreateEntityTable,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
19
+ import { buildToolCatalog } from "../tool-catalog";
20
+ import { dispatchToolCall } from "../tool-dispatch";
21
+
22
+ const vendorEntity = createEntity({
23
+ table: "agent_tools_test_vendors",
24
+ fields: {
25
+ name: createTextField({ required: true, searchable: true, filterable: true }),
26
+ iban: createTextField({ required: true, filterable: true }),
27
+ },
28
+ });
29
+
30
+ const vendorFeature = defineFeature("agent-tools-test-vendor", (r) => {
31
+ r.crud("vendor", vendorEntity, {
32
+ write: { access: { roles: ["Admin"] } },
33
+ read: { access: { roles: ["Reader"] } },
34
+ });
35
+ });
36
+
37
+ const VENDOR_CREATE_QN = "agent-tools-test-vendor:write:vendor:create";
38
+
39
+ let stack: TestStack;
40
+
41
+ beforeAll(async () => {
42
+ stack = await setupTestStack({ features: [vendorFeature] });
43
+ await unsafeCreateEntityTable(stack.db, vendorEntity);
44
+ await createEventsTable(stack.db);
45
+ }, 20000);
46
+
47
+ afterAll(async () => {
48
+ await stack.cleanup();
49
+ });
50
+
51
+ beforeEach(async () => {
52
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
53
+ await asRawClient(stack.db).unsafe("DELETE FROM agent_tools_test_vendors");
54
+ });
55
+
56
+ const TENANT_B = "00000000-0000-4000-8000-0000000000bb";
57
+
58
+ const adminA = createTestUser({ roles: ["Admin"] });
59
+ const adminB = createTestUser({ roles: ["Admin"], tenantId: TENANT_B });
60
+ const readerA = createTestUser({ roles: ["Reader"], id: adminA.id, tenantId: adminA.tenantId });
61
+ const noRoleA = createTestUser({ roles: [], id: adminA.id, tenantId: adminA.tenantId });
62
+
63
+ describe("dispatchToolCall — real <entity>:list pipeline", () => {
64
+ test("find_vendor_by_iban never leaks another tenant's row, even on an identical IBAN", async () => {
65
+ await stack.dispatcher.write(VENDOR_CREATE_QN, { name: "Acme A", iban: "DE-SAME" }, adminA);
66
+ await stack.dispatcher.write(VENDOR_CREATE_QN, { name: "Acme B", iban: "DE-SAME" }, adminB);
67
+
68
+ const catalog = buildToolCatalog(stack.registry);
69
+ const result = await dispatchToolCall(
70
+ stack.dispatcher,
71
+ readerA,
72
+ "find_vendor_by_iban",
73
+ { iban: "DE-SAME" },
74
+ catalog.dispatchTable,
75
+ );
76
+
77
+ expect(result.ok).toBe(true);
78
+ if (!result.ok) throw new Error("unreachable");
79
+ const rows = (result.data as { rows: readonly { name: unknown }[] }).rows;
80
+ expect(rows).toHaveLength(1);
81
+ expect(rows[0]?.name).toBe("Acme A");
82
+ });
83
+
84
+ test("a caller without the list handler's required role gets denied, not an empty ok result", async () => {
85
+ await stack.dispatcher.write(VENDOR_CREATE_QN, { name: "Acme A", iban: "DE-SAME" }, adminA);
86
+
87
+ const catalog = buildToolCatalog(stack.registry);
88
+ const result = await dispatchToolCall(
89
+ stack.dispatcher,
90
+ noRoleA,
91
+ "find_vendor_by_iban",
92
+ { iban: "DE-SAME" },
93
+ catalog.dispatchTable,
94
+ );
95
+
96
+ expect(result.ok).toBe(false);
97
+ if (result.ok) throw new Error("unreachable");
98
+ expect(result.error).toContain("access denied");
99
+ });
100
+ });
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { dispatchToolCall } from "../tool-dispatch";
4
+ import type { ToolDispatchDescriptor } from "../types";
5
+
6
+ const CALLER: SessionUser = { id: "user-1", tenantId: "tenant-1", roles: ["member"] };
7
+
8
+ const DISPATCH_TABLE = new Map<string, ToolDispatchDescriptor>([
9
+ [
10
+ "search_vendor",
11
+ { kind: "search", entityName: "vendor", qn: "vendor-feature:query:vendor:list" },
12
+ ],
13
+ [
14
+ "find_vendor_by_iban",
15
+ {
16
+ kind: "findBy",
17
+ entityName: "vendor",
18
+ fieldName: "iban",
19
+ qn: "vendor-feature:query:vendor:list",
20
+ },
21
+ ],
22
+ ]);
23
+
24
+ function recordingDispatcher() {
25
+ const calls: { type: string; payload: unknown; user: SessionUser }[] = [];
26
+ return {
27
+ calls,
28
+ query: async (type: string, payload: unknown, user: SessionUser) => {
29
+ calls.push({ type, payload, user });
30
+ return { rows: [{ id: "vendor-1" }] };
31
+ },
32
+ };
33
+ }
34
+
35
+ describe("dispatchToolCall", () => {
36
+ test("unknown tool name returns an error result without calling the dispatcher", async () => {
37
+ const dispatcher = recordingDispatcher();
38
+ const result = await dispatchToolCall(dispatcher, CALLER, "search_ghost", {}, DISPATCH_TABLE);
39
+
40
+ expect(result).toEqual({ ok: false, error: 'Unknown tool "search_ghost"' });
41
+ expect(dispatcher.calls).toHaveLength(0);
42
+ });
43
+
44
+ test("search tool dispatches to <entity>:list with a search payload, using the caller's identity", async () => {
45
+ const dispatcher = recordingDispatcher();
46
+ const result = await dispatchToolCall(
47
+ dispatcher,
48
+ CALLER,
49
+ "search_vendor",
50
+ { query: "Müller GmbH" },
51
+ DISPATCH_TABLE,
52
+ );
53
+
54
+ expect(result).toEqual({ ok: true, data: { rows: [{ id: "vendor-1" }] } });
55
+ expect(dispatcher.calls).toEqual([
56
+ {
57
+ type: "vendor-feature:query:vendor:list",
58
+ payload: { search: "Müller GmbH", limit: 10 },
59
+ user: CALLER,
60
+ },
61
+ ]);
62
+ });
63
+
64
+ test("search tool without a query argument returns an error, no dispatcher call", async () => {
65
+ const dispatcher = recordingDispatcher();
66
+ const result = await dispatchToolCall(dispatcher, CALLER, "search_vendor", {}, DISPATCH_TABLE);
67
+
68
+ expect(result.ok).toBe(false);
69
+ expect(dispatcher.calls).toHaveLength(0);
70
+ });
71
+
72
+ test("findBy tool dispatches to <entity>:list with an exact filter payload", async () => {
73
+ const dispatcher = recordingDispatcher();
74
+ const result = await dispatchToolCall(
75
+ dispatcher,
76
+ CALLER,
77
+ "find_vendor_by_iban",
78
+ { iban: "DE89370400440532013000" },
79
+ DISPATCH_TABLE,
80
+ );
81
+
82
+ expect(result).toEqual({ ok: true, data: { rows: [{ id: "vendor-1" }] } });
83
+ expect(dispatcher.calls).toEqual([
84
+ {
85
+ type: "vendor-feature:query:vendor:list",
86
+ payload: {
87
+ filter: { field: "iban", op: "eq", value: "DE89370400440532013000" },
88
+ limit: 10,
89
+ },
90
+ user: CALLER,
91
+ },
92
+ ]);
93
+ });
94
+
95
+ test("findBy tool without its required field argument returns an error, no dispatcher call", async () => {
96
+ const dispatcher = recordingDispatcher();
97
+ const result = await dispatchToolCall(
98
+ dispatcher,
99
+ CALLER,
100
+ "find_vendor_by_iban",
101
+ {},
102
+ DISPATCH_TABLE,
103
+ );
104
+
105
+ expect(result.ok).toBe(false);
106
+ expect(dispatcher.calls).toHaveLength(0);
107
+ });
108
+
109
+ test("a dispatcher rejection (e.g. missing handler or failed cap check) becomes an error result, not a throw", async () => {
110
+ const dispatcher = {
111
+ query: async () => {
112
+ throw new Error("no handler registered for qn 'vendor:list'");
113
+ },
114
+ };
115
+
116
+ const result = await dispatchToolCall(
117
+ dispatcher,
118
+ CALLER,
119
+ "search_vendor",
120
+ { query: "anything" },
121
+ DISPATCH_TABLE,
122
+ );
123
+
124
+ expect(result).toEqual({ ok: false, error: "no handler registered for qn 'vendor:list'" });
125
+ });
126
+ });
@@ -0,0 +1,9 @@
1
+ export { buildToolCatalog } from "./tool-catalog";
2
+ export type { ToolCallResult, ToolDispatcher } from "./tool-dispatch";
3
+ export { dispatchToolCall } from "./tool-dispatch";
4
+ export type {
5
+ RegistrySearchView,
6
+ ToolCatalog,
7
+ ToolDefinition,
8
+ ToolDispatchDescriptor,
9
+ } from "./types";
@@ -0,0 +1,130 @@
1
+ import type { EntityDefinition, FieldDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type {
3
+ RegistrySearchView,
4
+ ToolCatalog,
5
+ ToolDefinition,
6
+ ToolDispatchDescriptor,
7
+ } from "./types";
8
+
9
+ /** Field types that declare `filterable` (per `packages/framework/src/engine/types/fields.ts`).
10
+ * Mapped to the JSON-Schema type an LLM tool-call argument should use. `undefined` = field type
11
+ * is skipped for exact-lookup tools (not filterable at the type level). */
12
+ function jsonSchemaTypeForField(
13
+ field: FieldDefinition,
14
+ ): Readonly<Record<string, unknown>> | undefined {
15
+ switch (field.type) {
16
+ case "text":
17
+ case "multiSelect":
18
+ case "date":
19
+ case "timestamp":
20
+ case "locatedTimestamp":
21
+ return { type: "string" };
22
+ case "select":
23
+ return { type: "string", enum: field.options };
24
+ case "boolean":
25
+ return { type: "boolean" };
26
+ case "number":
27
+ case "bigInt":
28
+ case "decimal":
29
+ case "money":
30
+ return { type: "number" };
31
+ case "reference":
32
+ return { type: "string", description: `ID referencing "${field.entity}"` };
33
+ default:
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ function isFilterable(field: FieldDefinition): boolean {
39
+ return "filterable" in field && field.filterable === true;
40
+ }
41
+
42
+ function buildSearchTool(entityName: string, searchableFields: readonly string[]): ToolDefinition {
43
+ return {
44
+ name: `search_${entityName}`,
45
+ description: `Full-text search over ${entityName} across fields: ${searchableFields.join(", ")}. Returns ranked candidates, not a single answer — may need a follow-up find_${entityName}_by_* call to disambiguate.`,
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ query: { type: "string", description: "Free-text search query" },
50
+ },
51
+ required: ["query"],
52
+ additionalProperties: false,
53
+ },
54
+ };
55
+ }
56
+
57
+ function buildFindByTool(
58
+ entityName: string,
59
+ fieldName: string,
60
+ fieldSchema: Readonly<Record<string, unknown>>,
61
+ ): ToolDefinition {
62
+ return {
63
+ name: `find_${entityName}_by_${fieldName}`,
64
+ description: `Exact lookup of ${entityName} where ${fieldName} matches.`,
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: { [fieldName]: fieldSchema },
68
+ required: [fieldName],
69
+ additionalProperties: false,
70
+ },
71
+ };
72
+ }
73
+
74
+ /** True for a `:list`-verb entity query handler, e.g. "my-feature:query:vendor:list" for entity
75
+ * "vendor" — the only handler shape whose payload accepts `search`/`filter` (see
76
+ * `packages/framework/src/db/event-store-executor-read.ts`). Handler-first iteration, not
77
+ * name-reconstruction: `entityName` here always comes from `registry.getHandlerEntity(qn)`,
78
+ * the registry's own answer for this exact qn, never guessed from kebab-casing rules. */
79
+ function isListHandlerQn(qn: string, entityName: string): boolean {
80
+ return qn.endsWith(`:${entityName}:list`);
81
+ }
82
+
83
+ function addToolsForListHandler(
84
+ registry: RegistrySearchView,
85
+ qn: string,
86
+ entityName: string,
87
+ entity: EntityDefinition,
88
+ tools: ToolDefinition[],
89
+ dispatchTable: Map<string, ToolDispatchDescriptor>,
90
+ ): void {
91
+ const searchableFields = registry.getSearchableFields(entityName);
92
+ if (searchableFields.length > 0) {
93
+ const tool = buildSearchTool(entityName, searchableFields);
94
+ tools.push(tool);
95
+ dispatchTable.set(tool.name, { kind: "search", entityName, qn });
96
+ }
97
+
98
+ for (const [fieldName, field] of Object.entries(
99
+ entity.fields as Record<string, FieldDefinition>,
100
+ )) {
101
+ if (!isFilterable(field)) continue;
102
+ const fieldSchema = jsonSchemaTypeForField(field);
103
+ if (!fieldSchema) continue;
104
+ const tool = buildFindByTool(entityName, fieldName, fieldSchema);
105
+ tools.push(tool);
106
+ dispatchTable.set(tool.name, { kind: "findBy", entityName, fieldName, qn });
107
+ }
108
+ }
109
+
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>();
118
+
119
+ for (const [qn] of registry.getAllQueryHandlers()) {
120
+ const entityName = registry.getHandlerEntity(qn);
121
+ if (!entityName || !isListHandlerQn(qn, entityName)) continue;
122
+
123
+ const entity = registry.getEntity(entityName);
124
+ if (!entity) continue;
125
+
126
+ addToolsForListHandler(registry, qn, entityName, entity, tools, dispatchTable);
127
+ }
128
+
129
+ return { tools, dispatchTable };
130
+ }
@@ -0,0 +1,82 @@
1
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ToolDispatchDescriptor } from "./types";
3
+
4
+ /** Narrow view of `Dispatcher` (packages/framework/src/pipeline/dispatcher.ts) — dispatch only
5
+ * ever needs `query`, and this keeps the module testable without constructing a real Dispatcher.
6
+ * Callers pass either the app's real `dispatcher` or `{ query: ctx.queryAs }` from inside a
7
+ * handler — both run the same `executeQuery` pipeline with a caller-supplied SessionUser, never
8
+ * `systemQuery`/`createSystemUser`/`createAnonymousUser` (all of which drop the caller's identity). */
9
+ export type ToolDispatcher = {
10
+ query(type: string, payload: unknown, user: SessionUser): Promise<unknown>;
11
+ };
12
+
13
+ export type ToolCallResult =
14
+ | { readonly ok: true; readonly data: unknown }
15
+ | { readonly ok: false; readonly error: string };
16
+
17
+ const RESULT_LIMIT = 10;
18
+
19
+ /** Execute one LLM-issued tool call. `callerUser` MUST be the real Tenant+User identity of
20
+ * whoever's search this is running on behalf of — every call goes through `<entity>:list`,
21
+ * the same handler + permission pipeline as a normal HTTP list request (search/filter are both
22
+ * native `<entity>:list` payload fields, see `packages/framework/src/db/event-store-executor-read.ts`).
23
+ * Never throws — a missing tool, a missing argument, or a rejected handler call (e.g. no
24
+ * `<entity>:list` handler mounted, or a cap check failing) all come back as `{ ok: false }` so
25
+ * the agent loop can feed the error back to the model instead of crashing. */
26
+ export async function dispatchToolCall(
27
+ dispatcher: ToolDispatcher,
28
+ callerUser: SessionUser,
29
+ toolName: string,
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
+ }
37
+
38
+ const payload =
39
+ descriptor.kind === "search"
40
+ ? buildSearchPayload(toolName, toolInput)
41
+ : buildFindByPayload(toolName, descriptor.fieldName, toolInput);
42
+ if (!payload.ok) {
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
+ }
53
+
54
+ type PayloadResult =
55
+ | { readonly ok: true; readonly value: Readonly<Record<string, unknown>> }
56
+ | { readonly ok: false; readonly error: string };
57
+
58
+ function buildSearchPayload(
59
+ toolName: string,
60
+ toolInput: Readonly<Record<string, unknown>>,
61
+ ): PayloadResult {
62
+ const query = toolInput["query"];
63
+ if (typeof query !== "string" || query.length === 0) {
64
+ return { ok: false, error: `Tool "${toolName}" requires a non-empty string "query" argument` };
65
+ }
66
+ return { ok: true, value: { search: query, limit: RESULT_LIMIT } };
67
+ }
68
+
69
+ function buildFindByPayload(
70
+ toolName: string,
71
+ fieldName: string,
72
+ toolInput: Readonly<Record<string, unknown>>,
73
+ ): PayloadResult {
74
+ const value = toolInput[fieldName];
75
+ if (value === undefined) {
76
+ return { ok: false, error: `Tool "${toolName}" requires a "${fieldName}" argument` };
77
+ }
78
+ return {
79
+ ok: true,
80
+ value: { filter: { field: fieldName, op: "eq", value }, limit: RESULT_LIMIT },
81
+ };
82
+ }