@things-factory/ai-client-base 10.1.20 → 10.1.26
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/dist-server/service/assistant/index.d.ts +1 -0
- package/dist-server/service/assistant/index.js +1 -0
- package/dist-server/service/assistant/index.js.map +1 -1
- package/dist-server/service/assistant/registry-assistant.d.ts +10 -10
- package/dist-server/service/assistant/registry-assistant.js +11 -9
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/assistant/tool-scope.d.ts +25 -0
- package/dist-server/service/assistant/tool-scope.js +71 -0
- package/dist-server/service/assistant/tool-scope.js.map +1 -0
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +1 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/tool-call-context.d.ts +10 -0
- package/dist-server/service/testing/tool-call-context.js +13 -0
- package/dist-server/service/testing/tool-call-context.js.map +1 -0
- package/dist-server/service/tool-registry/index.d.ts +3 -3
- package/dist-server/service/tool-registry/index.js +2 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/service/tool-registry/registry.d.ts +16 -0
- package/dist-server/service/tool-registry/registry.js +36 -0
- package/dist-server/service/tool-registry/registry.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +52 -4
- package/dist-server/service/tool-registry/types.js +4 -4
- package/dist-server/service/tool-registry/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/docs/grounded-analysis-contract.md +1 -1
- package/package.json +2 -2
- package/server/service/analysis-contract.test.ts +0 -1
- package/server/service/assistant/index.ts +1 -0
- package/server/service/assistant/registry-assistant.test.ts +131 -4
- package/server/service/assistant/registry-assistant.ts +29 -24
- package/server/service/assistant/tool-scope.ts +86 -0
- package/server/service/index.ts +1 -0
- package/server/service/testing/tool-call-context.ts +28 -0
- package/server/service/tool-registry/index.ts +7 -3
- package/server/service/tool-registry/registry.test.ts +50 -1
- package/server/service/tool-registry/registry.ts +47 -0
- package/server/service/tool-registry/types.ts +60 -10
|
@@ -8,6 +8,7 @@ const tslib_1 = require("tslib");
|
|
|
8
8
|
*/
|
|
9
9
|
tslib_1.__exportStar(require("./abort-notice.js"), exports);
|
|
10
10
|
tslib_1.__exportStar(require("./summarize.js"), exports);
|
|
11
|
+
tslib_1.__exportStar(require("./tool-scope.js"), exports);
|
|
11
12
|
tslib_1.__exportStar(require("./registry-gate.js"), exports);
|
|
12
13
|
tslib_1.__exportStar(require("./assistant-chat.js"), exports);
|
|
13
14
|
tslib_1.__exportStar(require("./registry-assistant.js"), exports);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/assistant/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,4DAAiC;AACjC,yDAA8B;AAC9B,6DAAkC;AAClC,8DAAmC;AACnC,kEAAuC","sourcesContent":["/*\n * The neutral assistant layer — the agentic loop plus the guards that must not be optional.\n *\n * See `assistant-chat.ts` for why this is not in a product package.\n */\nexport * from './abort-notice.js'\nexport * from './summarize.js'\nexport * from './registry-gate.js'\nexport * from './assistant-chat.js'\nexport * from './registry-assistant.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/assistant/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,4DAAiC;AACjC,yDAA8B;AAC9B,0DAA+B;AAC/B,6DAAkC;AAClC,8DAAmC;AACnC,kEAAuC","sourcesContent":["/*\n * The neutral assistant layer — the agentic loop plus the guards that must not be optional.\n *\n * See `assistant-chat.ts` for why this is not in a product package.\n */\nexport * from './abort-notice.js'\nexport * from './summarize.js'\nexport * from './tool-scope.js'\nexport * from './registry-gate.js'\nexport * from './assistant-chat.js'\nexport * from './registry-assistant.js'\n"]}
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import type { AIClient, AIMessage } from '../types.js';
|
|
2
|
+
import { type ToolAccessCheck } from './tool-scope.js';
|
|
2
3
|
import { type AssistantChatResult } from './assistant-chat.js';
|
|
3
4
|
import type { GroundingSources } from '../utils/index.js';
|
|
4
|
-
/** What a registry tool is handed when it runs. `state` is the caller's koa state. */
|
|
5
|
-
export interface RegistryToolCallContext {
|
|
6
|
-
state: any;
|
|
7
|
-
/** What this surface is looking at — the host passes it through; tools read it as `host`. */
|
|
8
|
-
host?: any;
|
|
9
|
-
}
|
|
10
5
|
export interface RegistryAssistantInput {
|
|
11
6
|
/** Provider client. Must implement `chatWithTools`. */
|
|
12
7
|
client: AIClient;
|
|
@@ -15,12 +10,17 @@ export interface RegistryAssistantInput {
|
|
|
15
10
|
/** Who the assistant is and what this surface is. The host owns this prose. */
|
|
16
11
|
systemPrompt: string;
|
|
17
12
|
/**
|
|
18
|
-
* Categories this conversation may use.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* no tools at all. That is a legitimate surface (plain chat), so it is not corrected here.
|
|
13
|
+
* Categories this conversation may use. **Omitting it allows nothing** — the same as an empty
|
|
14
|
+
* list, a plain conversation with no tools (A1, 2026-09-17). It used to mean every registered
|
|
15
|
+
* category, which is how a Board-creating tool reached any conversation.
|
|
22
16
|
*/
|
|
23
17
|
toolCategories?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Answers whether the caller holds a tool's declared privilege. Without it, every tool that
|
|
20
|
+
* declares a privilege stays closed. The host builds it from the request — this package does
|
|
21
|
+
* not know auth.
|
|
22
|
+
*/
|
|
23
|
+
toolAccess?: ToolAccessCheck;
|
|
24
24
|
/** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
|
|
25
25
|
hostContext?: any;
|
|
26
26
|
/** Caller's koa state (domain, user, tx). Tools that read the database need it. */
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registryAssistantChat = registryAssistantChat;
|
|
4
|
-
const
|
|
4
|
+
const tool_scope_js_1 = require("./tool-scope.js");
|
|
5
5
|
const assistant_chat_js_1 = require("./assistant-chat.js");
|
|
6
6
|
const summarize_js_1 = require("./summarize.js");
|
|
7
|
-
/** `read` and `external` are the two kinds that answer a question without changing a document. */
|
|
8
|
-
function answeringSpecs(filter) {
|
|
9
|
-
return (0, index_js_1.getToolSpecsFor)(filter).filter(spec => spec.kind === 'read' || spec.kind === 'external');
|
|
10
|
-
}
|
|
11
7
|
/**
|
|
12
8
|
* Run one turn of a conversation whose only tools come from the registry.
|
|
13
9
|
*
|
|
@@ -19,8 +15,14 @@ async function registryAssistantChat(input) {
|
|
|
19
15
|
if (!client.chatWithTools) {
|
|
20
16
|
throw new Error('[registry-assistant] the configured AI client cannot call tools, so this conversation cannot run');
|
|
21
17
|
}
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
/*
|
|
19
|
+
* Only `read` is offered here. `external` means the tool changes something outside the
|
|
20
|
+
* conversation (board-import's `materializeImportSession` creates a Board); this door answers
|
|
21
|
+
* questions and has no confirm-and-apply step, so a tool with an effect does not go through it
|
|
22
|
+
* even when the caller holds the privilege.
|
|
23
|
+
*/
|
|
24
|
+
const scope = (0, tool_scope_js_1.narrowToolScope)(await (0, tool_scope_js_1.resolveToolScope)(toolCategories, input.toolAccess), spec => spec.kind === 'read');
|
|
25
|
+
const specs = scope.specs;
|
|
24
26
|
const byName = new Map(specs.map(spec => [spec.name, spec]));
|
|
25
27
|
const tools = specs.map(spec => ({
|
|
26
28
|
name: spec.name,
|
|
@@ -31,9 +33,9 @@ async function registryAssistantChat(input) {
|
|
|
31
33
|
* The rules for using a tool belong to whoever registered it. Collect only the exposed ones, so
|
|
32
34
|
* a surface never reads instructions for a category it cannot call.
|
|
33
35
|
*/
|
|
34
|
-
const guidance = (0,
|
|
36
|
+
const guidance = (0, tool_scope_js_1.toolScopeGuidance)(scope);
|
|
35
37
|
const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt;
|
|
36
|
-
const toolContext = { state, host: hostContext };
|
|
38
|
+
const toolContext = { state, host: hostContext ?? {} };
|
|
37
39
|
const result = await (0, assistant_chat_js_1.assistantChat)({
|
|
38
40
|
initialConversation: messages,
|
|
39
41
|
tools,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry-assistant.js","sourceRoot":"","sources":["../../../server/service/assistant/registry-assistant.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"registry-assistant.js","sourceRoot":"","sources":["../../../server/service/assistant/registry-assistant.ts"],"names":[],"mappings":";;AAyEA,sDAoEC;AAtHD,mDAA4G;AAC5G,2DAA6E;AAC7E,iDAAoD;AA0CpD;;;;;GAKG;AACI,KAAK,UAAU,qBAAqB,CAAC,KAA6B;IACvE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,KAAK,CAAA;IAEpF,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,kGAAkG,CAAC,CAAA;IACrH,CAAC;IAED;;;;;OAKG;IACH,MAAM,KAAK,GAAG,IAAA,+BAAe,EAC3B,MAAM,IAAA,gCAAgB,EAAC,cAAc,EAAE,KAAK,CAAC,UAAU,CAAC,EACxD,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,MAAM,CAC7B,CAAA;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAA;IACzB,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;IAE5D,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC/B,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,WAAW,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW;QACxF,UAAU,EAAE,IAAI,CAAC,MAAM;KACxB,CAAC,CAAC,CAAA;IAEH;;;OAGG;IACH,MAAM,QAAQ,GAAG,IAAA,iCAAiB,EAAC,KAAK,CAAC,CAAA;IACzC,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,YAAY,OAAO,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,YAAY,CAAA;IAEjG,MAAM,WAAW,GAAoB,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,IAAI,EAAE,EAAE,CAAA;IAEvE,MAAM,MAAM,GAAG,MAAM,IAAA,iCAAa,EAA2B;QAC3D,mBAAmB,EAAE,QAAQ;QAC7B,KAAK;QACL,OAAO,EAAE;YACP,YAAY,EAAE,MAAM;YACpB,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,WAAW,EAAE,KAAK,CAAC,WAAW;YAC9B,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,sBAAsB,EAAE,KAAK,CAAC,sBAAsB;SACrD;QACD,IAAI,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,aAAc,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC;QAC3E,OAAO,EAAE,WAAW;QACpB,QAAQ,EAAE;YACR,UAAU,EAAE,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC;YACpC;;;eAGG;YACH,WAAW,EAAE,GAAG,EAAE,CAAC,KAAK;YACxB,YAAY,EAAE,GAAG,EAAE,CAAC,KAAK;YACzB,eAAe,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE;gBACjC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;gBAClC,IAAI,CAAC,IAAI;oBAAE,OAAO,SAAS,CAAA;gBAC3B,OAAQ,IAAI,CAAC,OAAe,CAAC,IAAI,CAAC,SAAS,IAAI,EAAE,EAAE,OAAO,CAAC,CAAA;YAC7D,CAAC;YACD,qBAAqB,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,+CAA+C,CAAC,EAAE,CAAC;YAC1G,YAAY,EAAE,GAAG,EAAE,CAAC,IAAI;YACxB,mBAAmB,EAAnB,kCAAmB;SACpB;QACD,gBAAgB,EAAE,KAAK,CAAC,gBAAgB;KACzC,CAAC,CAAA;IAEF,OAAO,EAAE,GAAG,MAAM,EAAE,YAAY,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAA;AAClE,CAAC","sourcesContent":["/*\n * A conversation for a product that has no editor of its own.\n *\n * ── What this is for ───────────────────────────────────────────────────────────\n * `board-ai` assembles its conversation from two sources: its own board editing tools, and\n * whatever other packages registered in the tool registry. The second half is the same work for\n * every product — read the specs, build the definitions, dispatch by name — but it only existed\n * inside the board assistant, so a product without a board had nowhere to ask a question from.\n * operato-figure hit exactly that: the dock was mounted, the tools were registered, and the only\n * mutation that could run them lived in a package figure does not load.\n *\n * ── Read only ──────────────────────────────────────────────────────────────────\n * A registry category may also carry `write` and `action` tools. Those produce edit ops for a\n * document the host owns, and a host with no document cannot apply them. Offering them here would\n * declare a door with no road behind it — the model would call one and the call would go nowhere.\n * `external` tools change something outside the conversation (board-import's\n * `materializeImportSession` creates a Board) and this door has no confirm-and-apply step, so they\n * are not offered either (A1, 2026-09-17). This runner offers, and dispatches, `read` only.\n *\n * The gate answers from the same list the definitions are built from, so what is offered and what\n * may be dispatched cannot drift apart.\n */\nimport type { AIClient, AIMessage } from '../types.js'\nimport { narrowToolScope, resolveToolScope, toolScopeGuidance, type ToolAccessCheck } from './tool-scope.js'\nimport { assistantChat, type AssistantChatResult } from './assistant-chat.js'\nimport { summarizeToolResult } from './summarize.js'\nimport type { GroundingSources } from '../utils/index.js'\nimport type { ToolCallContext } from '../tool-registry/types.js'\n\n\nexport interface RegistryAssistantInput {\n /** Provider client. Must implement `chatWithTools`. */\n client: AIClient\n /** The conversation so far, newest last. The caller owns history and truncation. */\n messages: AIMessage[]\n /** Who the assistant is and what this surface is. The host owns this prose. */\n systemPrompt: string\n /**\n * Categories this conversation may use. **Omitting it allows nothing** — the same as an empty\n * list, a plain conversation with no tools (A1, 2026-09-17). It used to mean every registered\n * category, which is how a Board-creating tool reached any conversation.\n */\n toolCategories?: string[]\n /**\n * Answers whether the caller holds a tool's declared privilege. Without it, every tool that\n * declares a privilege stays closed. The host builds it from the request — this package does\n * not know auth.\n */\n toolAccess?: ToolAccessCheck\n /** Passed to tools as `context.host`. Client-supplied — never used for authorization. */\n hostContext?: any\n /** Caller's koa state (domain, user, tx). Tools that read the database need it. */\n state: any\n model?: string\n temperature?: number\n maxTokens?: number\n /** Make the model call a tool before it may answer. For surfaces that report live state. */\n requireToolOnFirstTurn?: boolean\n /** Texts the answer is allowed to draw identifiers from, besides the tool results. */\n groundingSources?: Omit<GroundingSources, 'toolResults'>\n}\n\nexport interface RegistryAssistantResult extends AssistantChatResult<unknown> {\n /** Tool names actually offered to the model this turn. The caller may log or return it. */\n offeredTools: string[]\n}\n\n/**\n * Run one turn of a conversation whose only tools come from the registry.\n *\n * Throws when the client cannot use tools at all — a surface built on registered tools that\n * silently ran without them would answer from nothing and look like it had checked.\n */\nexport async function registryAssistantChat(input: RegistryAssistantInput): Promise<RegistryAssistantResult> {\n const { client, messages, systemPrompt, toolCategories, hostContext, state } = input\n\n if (!client.chatWithTools) {\n throw new Error('[registry-assistant] the configured AI client cannot call tools, so this conversation cannot run')\n }\n\n /*\n * Only `read` is offered here. `external` means the tool changes something outside the\n * conversation (board-import's `materializeImportSession` creates a Board); this door answers\n * questions and has no confirm-and-apply step, so a tool with an effect does not go through it\n * even when the caller holds the privilege.\n */\n const scope = narrowToolScope(\n await resolveToolScope(toolCategories, input.toolAccess),\n spec => spec.kind === 'read'\n )\n const specs = scope.specs\n const byName = new Map(specs.map(spec => [spec.name, spec]))\n\n const tools = specs.map(spec => ({\n name: spec.name,\n description: spec.themeNote ? `${spec.description} ${spec.themeNote}` : spec.description,\n parameters: spec.schema\n }))\n\n /*\n * The rules for using a tool belong to whoever registered it. Collect only the exposed ones, so\n * a surface never reads instructions for a category it cannot call.\n */\n const guidance = toolScopeGuidance(scope)\n const prompt = guidance.length > 0 ? `${systemPrompt}\\n\\n${guidance.join('\\n\\n')}` : systemPrompt\n\n const toolContext: ToolCallContext = { state, host: hostContext ?? {} }\n\n const result = await assistantChat<ToolCallContext, unknown>({\n initialConversation: messages,\n tools,\n options: {\n systemPrompt: prompt,\n model: input.model,\n temperature: input.temperature,\n maxTokens: input.maxTokens,\n requireToolOnFirstTurn: input.requireToolOnFirstTurn\n },\n chat: (msgs, toolDefs, opts) => client.chatWithTools!(msgs, toolDefs, opts),\n context: toolContext,\n dispatch: {\n isReadTool: name => byName.has(name),\n /*\n * This surface has no document, so nothing here can write. Saying so plainly makes the loop\n * report an unknown name back to the model instead of accepting a call and dropping it.\n */\n isWriteTool: () => false,\n isActionTool: () => false,\n executeReadTool: (call, context) => {\n const spec = byName.get(call.name)\n if (!spec) return undefined\n return (spec.builder as any)(call.arguments ?? {}, context)\n },\n validateWriteToolCall: () => ({ valid: false, errors: ['this conversation has no document to write to'] }),\n toolCallToOp: () => null,\n summarizeToolResult\n },\n groundingSources: input.groundingSources\n })\n\n return { ...result, offeredTools: tools.map(tool => tool.name) }\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ToolCategoryFilter } from '../tool-registry/index.js';
|
|
2
|
+
import type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js';
|
|
3
|
+
/** Answers "may this caller use a door guarded by this privilege". */
|
|
4
|
+
export type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>;
|
|
5
|
+
export interface ScopedCategory {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly guidance: string;
|
|
8
|
+
/** The tools of this category this caller may use. Never empty — a category with none is left out. */
|
|
9
|
+
readonly specs: ReadonlyArray<ToolSpec>;
|
|
10
|
+
}
|
|
11
|
+
export interface ToolScope {
|
|
12
|
+
/** Categories with at least one usable tool, in registration order. */
|
|
13
|
+
readonly categories: ReadonlyArray<ScopedCategory>;
|
|
14
|
+
/** Every usable tool, flat. Offering and dispatch both read this. */
|
|
15
|
+
readonly specs: ReadonlyArray<ToolSpec>;
|
|
16
|
+
}
|
|
17
|
+
export declare const EMPTY_TOOL_SCOPE: ToolScope;
|
|
18
|
+
export declare function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope>;
|
|
19
|
+
/**
|
|
20
|
+
* Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category
|
|
21
|
+
* left with nothing drops out, so its guidance is not read to a model that cannot call it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function narrowToolScope(scope: ToolScope, keep: (spec: ToolSpec) => boolean): ToolScope;
|
|
24
|
+
/** The rules of the categories in scope, for the system prompt. */
|
|
25
|
+
export declare function toolScopeGuidance(scope: ToolScope): string[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EMPTY_TOOL_SCOPE = void 0;
|
|
4
|
+
exports.resolveToolScope = resolveToolScope;
|
|
5
|
+
exports.narrowToolScope = narrowToolScope;
|
|
6
|
+
exports.toolScopeGuidance = toolScopeGuidance;
|
|
7
|
+
/*
|
|
8
|
+
* Which registered tools one conversation may use — decided once per request, before anything is
|
|
9
|
+
* offered, so offering and dispatch read the same list.
|
|
10
|
+
*
|
|
11
|
+
* ── Two questions, one list ───────────────────────────────────────────────────────
|
|
12
|
+
* Which categories did this surface ask for? `toolCategories`
|
|
13
|
+
* Which of their tools may this caller use? each spec's `privilege`, asked of `access`
|
|
14
|
+
*
|
|
15
|
+
* **Nothing asked means nothing offered.** Omitting the category list used to mean "every
|
|
16
|
+
* registered category"; that default is the one that opened board-import's Board-creating tool to
|
|
17
|
+
* any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).
|
|
18
|
+
*
|
|
19
|
+
* **No access check means privileged tools stay closed.** A caller that cannot say who is asking
|
|
20
|
+
* gets only the tools that need no privilege.
|
|
21
|
+
*
|
|
22
|
+
* The permission check itself belongs to auth, which this package does not depend on, so the host
|
|
23
|
+
* hands it in (`ai-assistant` builds it from the request state).
|
|
24
|
+
*/
|
|
25
|
+
const index_js_1 = require("../tool-registry/index.js");
|
|
26
|
+
exports.EMPTY_TOOL_SCOPE = Object.freeze({ categories: [], specs: [] });
|
|
27
|
+
const privilegeKey = (p) => [p.category, p.privilege, !!p.owner, !!p.super].join('|');
|
|
28
|
+
async function resolveToolScope(requested, access) {
|
|
29
|
+
if (!requested || requested.length === 0)
|
|
30
|
+
return exports.EMPTY_TOOL_SCOPE;
|
|
31
|
+
/* One check per distinct privilege — a category of ten read tools asks once. */
|
|
32
|
+
const answers = new Map();
|
|
33
|
+
const mayUse = (spec) => {
|
|
34
|
+
const privilege = spec.privilege;
|
|
35
|
+
if (!privilege)
|
|
36
|
+
return Promise.resolve(true);
|
|
37
|
+
if (!access)
|
|
38
|
+
return Promise.resolve(false);
|
|
39
|
+
const key = privilegeKey(privilege);
|
|
40
|
+
if (!answers.has(key))
|
|
41
|
+
answers.set(key, access(privilege).catch(() => false));
|
|
42
|
+
return answers.get(key);
|
|
43
|
+
};
|
|
44
|
+
const categories = [];
|
|
45
|
+
for (const category of (0, index_js_1.getToolCategories)()) {
|
|
46
|
+
if (!requested.includes(category.name))
|
|
47
|
+
continue;
|
|
48
|
+
const allowed = await Promise.all(category.specs.map(mayUse));
|
|
49
|
+
const specs = category.specs.filter((_, i) => allowed[i]);
|
|
50
|
+
if (specs.length > 0)
|
|
51
|
+
categories.push({ name: category.name, guidance: String(category.guidance ?? '').trim(), specs });
|
|
52
|
+
}
|
|
53
|
+
return fromCategories(categories);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category
|
|
57
|
+
* left with nothing drops out, so its guidance is not read to a model that cannot call it.
|
|
58
|
+
*/
|
|
59
|
+
function narrowToolScope(scope, keep) {
|
|
60
|
+
return fromCategories(scope.categories
|
|
61
|
+
.map(category => ({ ...category, specs: category.specs.filter(keep) }))
|
|
62
|
+
.filter(category => category.specs.length > 0));
|
|
63
|
+
}
|
|
64
|
+
/** The rules of the categories in scope, for the system prompt. */
|
|
65
|
+
function toolScopeGuidance(scope) {
|
|
66
|
+
return scope.categories.map(category => category.guidance).filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
function fromCategories(categories) {
|
|
69
|
+
return { categories, specs: categories.flatMap(category => category.specs) };
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=tool-scope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-scope.js","sourceRoot":"","sources":["../../../server/service/assistant/tool-scope.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Which registered tools one conversation may use — decided once per request, before anything is\n * offered, so offering and dispatch read the same list.\n *\n * ── Two questions, one list ───────────────────────────────────────────────────────\n * Which categories did this surface ask for? `toolCategories`\n * Which of their tools may this caller use? each spec's `privilege`, asked of `access`\n *\n * **Nothing asked means nothing offered.** Omitting the category list used to mean \"every\n * registered category\"; that default is the one that opened board-import's Board-creating tool to\n * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).\n *\n * **No access check means privileged tools stay closed.** A caller that cannot say who is asking\n * gets only the tools that need no privilege.\n *\n * The permission check itself belongs to auth, which this package does not depend on, so the host\n * hands it in (`ai-assistant` builds it from the request state).\n */\nimport { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'\nimport type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js'\n\n/** Answers \"may this caller use a door guarded by this privilege\". */\nexport type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>\n\nexport interface ScopedCategory {\n readonly name: string\n readonly guidance: string\n /** The tools of this category this caller may use. Never empty — a category with none is left out. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport interface ToolScope {\n /** Categories with at least one usable tool, in registration order. */\n readonly categories: ReadonlyArray<ScopedCategory>\n /** Every usable tool, flat. Offering and dispatch both read this. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })\n\nconst privilegeKey = (p: ToolPrivilege) => [p.category, p.privilege, !!p.owner, !!p.super].join('|')\n\nexport async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {\n if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE\n\n /* One check per distinct privilege — a category of ten read tools asks once. */\n const answers = new Map<string, Promise<boolean>>()\n const mayUse = (spec: ToolSpec): Promise<boolean> => {\n const privilege = spec.privilege\n if (!privilege) return Promise.resolve(true)\n if (!access) return Promise.resolve(false)\n const key = privilegeKey(privilege)\n if (!answers.has(key)) answers.set(key, access(privilege).catch(() => false))\n return answers.get(key)!\n }\n\n const categories: ScopedCategory[] = []\n for (const category of getToolCategories()) {\n if (!requested.includes(category.name)) continue\n const allowed = await Promise.all(category.specs.map(mayUse))\n const specs = category.specs.filter((_, i) => allowed[i])\n if (specs.length > 0) categories.push({ name: category.name, guidance: String(category.guidance ?? '').trim(), specs })\n }\n return fromCategories(categories)\n}\n\n/**\n * Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category\n * left with nothing drops out, so its guidance is not read to a model that cannot call it.\n */\nexport function narrowToolScope(scope: ToolScope, keep: (spec: ToolSpec) => boolean): ToolScope {\n return fromCategories(\n scope.categories\n .map(category => ({ ...category, specs: category.specs.filter(keep) }))\n .filter(category => category.specs.length > 0)\n )\n}\n\n/** The rules of the categories in scope, for the system prompt. */\nexport function toolScopeGuidance(scope: ToolScope): string[] {\n return scope.categories.map(category => category.guidance).filter(Boolean)\n}\n\nfunction fromCategories(categories: ScopedCategory[]): ToolScope {\n return { categories, specs: categories.flatMap(category => category.specs) }\n}\n"]}
|
|
@@ -9,4 +9,5 @@ tslib_1.__exportStar(require("./tool-registry/index.js"), exports);
|
|
|
9
9
|
tslib_1.__exportStar(require("./agentic-loop/index.js"), exports);
|
|
10
10
|
tslib_1.__exportStar(require("./assistant/index.js"), exports);
|
|
11
11
|
tslib_1.__exportStar(require("./analysis-contract.js"), exports);
|
|
12
|
+
tslib_1.__exportStar(require("./testing/tool-call-context.js"), exports);
|
|
12
13
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;AAAA,qDAA0B;AAC1B,uDAA4B;AAC5B,+DAAoC;AACpC,2DAAgC;AAChC,mEAAwC;AACxC,kEAAuC;AACvC,+DAAoC;AACpC,iEAAsC","sourcesContent":["export * from './types.js'\nexport * from './factory.js'\nexport * from './providers/index.js'\nexport * from './utils/index.js'\nexport * from './tool-registry/index.js'\nexport * from './agentic-loop/index.js'\nexport * from './assistant/index.js'\nexport * from './analysis-contract.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/service/index.ts"],"names":[],"mappings":";;;AAAA,qDAA0B;AAC1B,uDAA4B;AAC5B,+DAAoC;AACpC,2DAAgC;AAChC,mEAAwC;AACxC,kEAAuC;AACvC,+DAAoC;AACpC,iEAAsC;AACtC,yEAA8C","sourcesContent":["export * from './types.js'\nexport * from './factory.js'\nexport * from './providers/index.js'\nexport * from './utils/index.js'\nexport * from './tool-registry/index.js'\nexport * from './agentic-loop/index.js'\nexport * from './assistant/index.js'\nexport * from './analysis-contract.js'\nexport * from './testing/tool-call-context.js'\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ToolCallContext, ToolCallMode } from '../tool-registry/types.js';
|
|
2
|
+
export interface ToolCallContextFixtureInput {
|
|
3
|
+
domainId?: string;
|
|
4
|
+
userId?: string;
|
|
5
|
+
/** Extra request-state fields a tool reads (e.g. `tx`). Merged after domain and user. */
|
|
6
|
+
state?: Record<string, any>;
|
|
7
|
+
host?: Record<string, any>;
|
|
8
|
+
mode?: ToolCallMode;
|
|
9
|
+
}
|
|
10
|
+
export declare function toolCallContextFixture(input?: ToolCallContextFixtureInput): ToolCallContext;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toolCallContextFixture = toolCallContextFixture;
|
|
4
|
+
function toolCallContextFixture(input = {}) {
|
|
5
|
+
const context = {
|
|
6
|
+
state: { domain: { id: input.domainId ?? 'domain-test' }, user: { id: input.userId ?? 'user-test' }, ...(input.state ?? {}) },
|
|
7
|
+
host: input.host ?? {}
|
|
8
|
+
};
|
|
9
|
+
if (input.mode)
|
|
10
|
+
context.mode = input.mode;
|
|
11
|
+
return context;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=tool-call-context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-call-context.js","sourceRoot":"","sources":["../../../server/service/testing/tool-call-context.ts"],"names":[],"mappings":";;AAoBA,wDAOC;AAPD,SAAgB,sBAAsB,CAAC,QAAqC,EAAE;IAC5E,MAAM,OAAO,GAAoB;QAC/B,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,QAAQ,IAAI,aAAa,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,MAAM,IAAI,WAAW,EAAE,EAAE,GAAG,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,EAAE;QAC7H,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,EAAE;KACvB,CAAA;IACD,IAAI,KAAK,CAAC,IAAI;QAAE,OAAO,CAAC,IAAI,GAAG,KAAK,CAAC,IAAI,CAAA;IACzC,OAAO,OAAO,CAAA;AAChB,CAAC","sourcesContent":["/*\n * The one way a test builds what a registered tool is handed (A3, 2026-09-17).\n *\n * The twin's space tools read `ctx.state.host` while the door passed `ctx.host`, and their tests\n * stayed green because each test wrote its own context in the shape the tool expected. A fixture\n * owned by each test agrees with its tool by construction. This one is owned by the package that\n * defines the neutral door, and that door's tests check it hands tools exactly this shape — so a tool\n * test built on it fails when the tool reads a field no door fills.\n */\nimport type { ToolCallContext, ToolCallMode } from '../tool-registry/types.js'\n\nexport interface ToolCallContextFixtureInput {\n domainId?: string\n userId?: string\n /** Extra request-state fields a tool reads (e.g. `tx`). Merged after domain and user. */\n state?: Record<string, any>\n host?: Record<string, any>\n mode?: ToolCallMode\n}\n\nexport function toolCallContextFixture(input: ToolCallContextFixtureInput = {}): ToolCallContext {\n const context: ToolCallContext = {\n state: { domain: { id: input.domainId ?? 'domain-test' }, user: { id: input.userId ?? 'user-test' }, ...(input.state ?? {}) },\n host: input.host ?? {}\n }\n if (input.mode) context.mode = input.mode\n return context\n}\n"]}
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.
|
|
5
5
|
*/
|
|
6
|
-
export { registerToolCategory, getToolCategories, getAllToolSpecs, getToolSpecsFor, getCategoryGuidance, findToolSpec, getToolKind, hasTool, getCategorySpecs, clearToolRegistry } from './registry';
|
|
7
|
-
export type { ToolCategoryFilter } from './registry';
|
|
8
|
-
export type { ToolSpec, ToolSpecBase, ReadToolSpec, WriteToolSpec, ActionToolSpec, ExternalToolSpec, ToolCategory, ReadToolBuilder, WriteToolBuilder, ActionToolBuilder, ExternalToolBuilder, ReadToolContext, WriteToolContext, ExternalToolContext } from './types';
|
|
6
|
+
export { registerToolCategory, getToolCategories, getAllToolSpecs, getToolSpecsFor, getCategoryGuidance, findToolSpec, getToolKind, hasTool, getCategorySpecs, clearToolRegistry, getToolRegistryWarnings } from './registry';
|
|
7
|
+
export type { ToolCategoryFilter, ToolRegistryWarning } from './registry';
|
|
8
|
+
export type { ToolSpec, ToolSpecBase, ToolPrivilege, ReadToolSpec, WriteToolSpec, ActionToolSpec, ExternalToolSpec, ToolCategory, ReadToolBuilder, WriteToolBuilder, ActionToolBuilder, ExternalToolBuilder, ReadToolContext, WriteToolContext, ExternalToolContext, ToolCallContext, ToolCallMode } from './types';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.clearToolRegistry = exports.getCategorySpecs = exports.hasTool = exports.getToolKind = exports.findToolSpec = exports.getCategoryGuidance = exports.getToolSpecsFor = exports.getAllToolSpecs = exports.getToolCategories = exports.registerToolCategory = void 0;
|
|
3
|
+
exports.getToolRegistryWarnings = exports.clearToolRegistry = exports.getCategorySpecs = exports.hasTool = exports.getToolKind = exports.findToolSpec = exports.getCategoryGuidance = exports.getToolSpecsFor = exports.getAllToolSpecs = exports.getToolCategories = exports.registerToolCategory = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.
|
|
6
6
|
*
|
|
@@ -17,4 +17,5 @@ Object.defineProperty(exports, "getToolKind", { enumerable: true, get: function
|
|
|
17
17
|
Object.defineProperty(exports, "hasTool", { enumerable: true, get: function () { return registry_1.hasTool; } });
|
|
18
18
|
Object.defineProperty(exports, "getCategorySpecs", { enumerable: true, get: function () { return registry_1.getCategorySpecs; } });
|
|
19
19
|
Object.defineProperty(exports, "clearToolRegistry", { enumerable: true, get: function () { return registry_1.clearToolRegistry; } });
|
|
20
|
+
Object.defineProperty(exports, "getToolRegistryWarnings", { enumerable: true, get: function () { return registry_1.getToolRegistryWarnings; } });
|
|
20
21
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,uCAYmB;AAXjB,gHAAA,oBAAoB,OAAA;AACpB,6GAAA,iBAAiB,OAAA;AACjB,2GAAA,eAAe,OAAA;AACf,2GAAA,eAAe,OAAA;AACf,+GAAA,mBAAmB,OAAA;AACnB,wGAAA,YAAY,OAAA;AACZ,uGAAA,WAAW,OAAA;AACX,mGAAA,OAAO,OAAA;AACP,4GAAA,gBAAgB,OAAA;AAChB,6GAAA,iBAAiB,OAAA;AACjB,mHAAA,uBAAuB,OAAA","sourcesContent":["/**\n * Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.\n *\n * 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.\n */\nexport {\n registerToolCategory,\n getToolCategories,\n getAllToolSpecs,\n getToolSpecsFor,\n getCategoryGuidance,\n findToolSpec,\n getToolKind,\n hasTool,\n getCategorySpecs,\n clearToolRegistry,\n getToolRegistryWarnings\n} from './registry'\n\n/* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */\nexport type { ToolCategoryFilter, ToolRegistryWarning } from './registry'\n\nexport type {\n ToolSpec,\n ToolSpecBase,\n ToolPrivilege,\n ReadToolSpec,\n WriteToolSpec,\n ActionToolSpec,\n ExternalToolSpec,\n ToolCategory,\n ReadToolBuilder,\n WriteToolBuilder,\n ActionToolBuilder,\n ExternalToolBuilder,\n ReadToolContext,\n WriteToolContext,\n ExternalToolContext,\n ToolCallContext,\n ToolCallMode\n} from './types'\n"]}
|
|
@@ -26,6 +26,22 @@
|
|
|
26
26
|
* - clear() 는 테스트 전용. 운영 환경에서 호출 금지.
|
|
27
27
|
*/
|
|
28
28
|
import type { ToolCategory, ToolSpec } from './types';
|
|
29
|
+
/** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */
|
|
30
|
+
export interface ToolRegistryWarning {
|
|
31
|
+
code: 'tool-privilege-undeclared';
|
|
32
|
+
params: {
|
|
33
|
+
category: string;
|
|
34
|
+
tool: string;
|
|
35
|
+
kind: ToolSpec['kind'];
|
|
36
|
+
};
|
|
37
|
+
/** For a person reading it; the code and params are what callers act on. */
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What registration accepted but the contract does not yet allow. Stage one of A1 records these
|
|
42
|
+
* instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
|
|
43
|
+
*/
|
|
44
|
+
export declare function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning>;
|
|
29
45
|
/**
|
|
30
46
|
* 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.
|
|
31
47
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getToolRegistryWarnings = getToolRegistryWarnings;
|
|
3
4
|
exports.registerToolCategory = registerToolCategory;
|
|
4
5
|
exports.getToolCategories = getToolCategories;
|
|
5
6
|
exports.getAllToolSpecs = getAllToolSpecs;
|
|
@@ -11,6 +12,14 @@ exports.hasTool = hasTool;
|
|
|
11
12
|
exports.getCategorySpecs = getCategorySpecs;
|
|
12
13
|
exports.clearToolRegistry = clearToolRegistry;
|
|
13
14
|
const categories = new Map();
|
|
15
|
+
const warnings = [];
|
|
16
|
+
/**
|
|
17
|
+
* What registration accepted but the contract does not yet allow. Stage one of A1 records these
|
|
18
|
+
* instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
|
|
19
|
+
*/
|
|
20
|
+
function getToolRegistryWarnings() {
|
|
21
|
+
return warnings.slice();
|
|
22
|
+
}
|
|
14
23
|
/**
|
|
15
24
|
* 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.
|
|
16
25
|
*/
|
|
@@ -34,6 +43,32 @@ function registerToolCategory(category) {
|
|
|
34
43
|
'Without it a model answers as though it called a tool it did not, and nothing errors. ' +
|
|
35
44
|
'Write the rules for these tools in `guidance` — the side that registers them owns them.');
|
|
36
45
|
}
|
|
46
|
+
/*
|
|
47
|
+
* A tool that changes something must say which privilege it goes through (A1, 2026-09-17).
|
|
48
|
+
*
|
|
49
|
+
* The conversation door checks one privilege — may this person talk to the assistant. Without a
|
|
50
|
+
* privilege on the tool, that single check opened every tool behind it: a user holding only
|
|
51
|
+
* `ai-assistant:mutation` could reach board-import's `materializeImportSession` and create a
|
|
52
|
+
* Board.
|
|
53
|
+
*
|
|
54
|
+
* **This is stage one of two: surface, then refuse** (ADR-0037, "먼저 드러내고, 목록이 비면 막는다").
|
|
55
|
+
* Refusing now would stop every app whose tools predate the `privilege` field from booting, and
|
|
56
|
+
* those apps compile against an installed copy that does not have the field yet. So an undeclared
|
|
57
|
+
* tool still registers, and the omission is recorded as a code (`getToolRegistryWarnings`), not
|
|
58
|
+
* a log line — a warning only a log carries is not distinguishable from no warning. When the list
|
|
59
|
+
* is empty across the apps, this becomes a refusal.
|
|
60
|
+
*/
|
|
61
|
+
warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name));
|
|
62
|
+
for (const spec of category.specs) {
|
|
63
|
+
if (spec.kind !== 'read' && !(spec.privilege?.category && spec.privilege?.privilege)) {
|
|
64
|
+
warnings.push({
|
|
65
|
+
code: 'tool-privilege-undeclared',
|
|
66
|
+
params: { category: category.name, tool: spec.name, kind: spec.kind },
|
|
67
|
+
message: `tool '${spec.name}' in category '${category.name}' is '${spec.kind}' but declares no privilege — ` +
|
|
68
|
+
'declare the same { category, privilege } its GraphQL door uses'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
37
72
|
if (categories.has(category.name)) {
|
|
38
73
|
console.warn(`[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
|
|
39
74
|
`(dev hot-reload 시 정상이지만 운영 환경에서 발생하면 buildup bug)`);
|
|
@@ -141,5 +176,6 @@ function getCategorySpecs(categoryName) {
|
|
|
141
176
|
*/
|
|
142
177
|
function clearToolRegistry() {
|
|
143
178
|
categories.clear();
|
|
179
|
+
warnings.length = 0;
|
|
144
180
|
}
|
|
145
181
|
//# sourceMappingURL=registry.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../server/service/tool-registry/registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ToolCategoryRegistry — 외부 도메인 패키지가 LLM tool category 를 동적 등록하는 점.\n *\n * 사용 패턴 (외부 패키지의 server bootstrap):\n * ```ts\n * import { registerToolCategory } from '@things-factory/board-ai'\n *\n * registerToolCategory({\n * name: 'board-import',\n * specs: [\n * { kind: 'external', name: 'importBoardAsync', schema: {...}, builder: ... },\n * { kind: 'read', name: 'getImportSession', schema: {...}, builder: ... },\n * ...\n * ]\n * })\n * ```\n *\n * board-ai assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이\n * 본 registry 에서 자동 도출.\n *\n * 정책:\n * - 카테고리 이름은 unique. 같은 이름으로 재등록하면 덮어쓰기 (외부 패키지의\n * hot-reload / dev 환경 대응). console.warn 으로 알림.\n * - tool name 은 카테고리 간 통틀어 unique. 충돌 시 등록 거부 + 명확한 에러\n * (LLM dispatch 가 모호해지지 않도록).\n * - clear() 는 테스트 전용. 운영 환경에서 호출 금지.\n */\nimport type { ToolCategory, ToolSpec } from './types'\n\nconst categories: Map<string, ToolCategory> = new Map()\n\n/**\n * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.\n */\nexport function registerToolCategory(category: ToolCategory): void {\n /*\n * 지침 없는 카테고리는 **등록하지 않는다.**\n *\n * 도구 목록만 보낸 모델이 「불러야 할 때 부른다」를 맞히지 못한 사고가 있었다 — 조치 요청에\n * `proposeAction` 을 부르지 않고 「제안해 두었습니다」라고 답해, 사용자에게 없는 버튼을 누르라고\n * 안내했다. 화면에는 아무 일도 없었고 오류도 나지 않았다(§`ToolCategory.guidance`).\n *\n * 그 뒤로 규율을 적는 자리를 만들었지만 **선택이었다.** 선택인 규율은 그 사고를 아는 사람만\n * 채운다. 여기서 거절하는 이유가 그것이다 — 빠진 것을 부팅에서 만든 사람이 보고, 모델이 헛말을\n * 한 뒤에 사용자가 보지 않는다.\n *\n * 빈 문자열·공백도 거절한다. 통과시키면 이 검사가 형식이 되고, 형식이 된 검사는 없는 것과 같다.\n */\n if (!String(category.guidance ?? '').trim()) {\n throw new Error(\n `[ai-client-base/tool-registry] category '${category.name}' has no guidance. ` +\n 'Tool descriptions say what a tool does; guidance says when the model must call one. ' +\n 'Without it a model answers as though it called a tool it did not, and nothing errors. ' +\n 'Write the rules for these tools in `guidance` — the side that registers them owns them.'\n )\n }\n\n if (categories.has(category.name)) {\n console.warn(\n `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +\n `(dev hot-reload 시 정상이지만 운영 환경에서 발생하면 buildup bug)`\n )\n }\n\n // tool name 충돌 검사 — 다른 카테고리에 같은 name 있으면 거부\n const newNames = new Set(category.specs.map(s => s.name))\n for (const [otherName, other] of categories) {\n if (otherName === category.name) continue // 같은 카테고리 재등록은 덮어쓰기 OK\n for (const spec of other.specs) {\n if (newNames.has(spec.name)) {\n throw new Error(\n `[board-ai/tool-registry] tool name '${spec.name}' already registered in category ` +\n `'${otherName}', cannot register in '${category.name}'. tool names must be globally unique.`\n )\n }\n }\n }\n\n categories.set(category.name, category)\n}\n\n/**\n * 대화별 도구 노출 제한 — 허용 카테고리 목록.\n *\n * `undefined` 는 **제한 없음**(등록된 전부). 호스트가 목록을 주면 그 카테고리에 속한 spec 만\n * 정의·실행 대상이 된다. 정의를 숨기는 것만으로는 부족하다 — 모델이 이름을 추측하거나 옛 대화가\n * 재생되면 숨긴 도구도 호출되므로 **dispatch 조회(findToolSpec)에도 같은 목록을 넘겨야** 한다.\n */\nexport type ToolCategoryFilter = ReadonlyArray<string> | undefined\n\n/** 허용 목록에 든 카테고리인가. 목록 없음 = 전부 허용. */\nfunction allowed(name: string, filter: ToolCategoryFilter): boolean {\n return !filter || filter.includes(name)\n}\n\n/**\n * 등록된 모든 카테고리 (등록 순서 보존). 외부에서 불변 read-only 로 사용.\n */\nexport function getToolCategories(): ReadonlyArray<ToolCategory> {\n return Array.from(categories.values())\n}\n\n/**\n * 등록된 모든 tool spec 의 평면 view. assistant.ts 의 buildBoardEditTools 가\n * LLM tool 정의 합집합으로 사용.\n */\nexport function getAllToolSpecs(): ReadonlyArray<ToolSpec> {\n return getToolSpecsFor(undefined)\n}\n\n/**\n * 허용 카테고리의 spec 평면 view. `undefined` = 전부(= getAllToolSpecs 와 동일).\n * LLM 도구 정의를 만들 때 이 함수를 쓰면 대화별 노출 제한이 그대로 반영된다.\n */\nexport function getToolSpecsFor(filter: ToolCategoryFilter): ReadonlyArray<ToolSpec> {\n const out: ToolSpec[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n for (const spec of cat.specs) out.push(spec)\n }\n return out\n}\n\n/**\n * 노출된 카테고리들의 **사용 규율** 모음(등록 순서). 시스템 프롬프트에 붙이는 용도.\n *\n * 도구 목록만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시 불러야 하는지\" 는 모른다.\n * 규율은 도구를 등록한 쪽이 소유하고, 호스트는 노출된 것만 모아 붙인다(도메인 지식이 새지 않는다).\n */\nexport function getCategoryGuidance(filter: ToolCategoryFilter): string[] {\n const out: string[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const guidance = (cat.guidance ?? '').trim()\n if (guidance) out.push(guidance)\n }\n return out\n}\n\n/**\n * tool name 으로 spec 검색. 없으면 undefined.\n */\nexport function findToolSpec(name: string, filter?: ToolCategoryFilter): ToolSpec | undefined {\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const spec = cat.specs.find(s => s.name === name)\n if (spec) return spec\n }\n return undefined\n}\n\n/**\n * tool name 의 카테고리 (read/write/action/external). 미등록 tool 은 undefined.\n * assistant.ts 의 isReadTool / isWriteTool / isActionTool 이 이 함수에 위임할 수 있다.\n */\nexport function getToolKind(name: string, filter?: ToolCategoryFilter): ToolSpec['kind'] | undefined {\n return findToolSpec(name, filter)?.kind\n}\n\n/**\n * 등록된 tool 인지. dispatch path 가 hardcoded set 와 registry 양쪽 fallback 시 사용.\n */\nexport function hasTool(name: string, filter?: ToolCategoryFilter): boolean {\n return findToolSpec(name, filter) !== undefined\n}\n\n/**\n * 카테고리 이름으로 spec 만 추출.\n */\nexport function getCategorySpecs(categoryName: string): ReadonlyArray<ToolSpec> {\n return categories.get(categoryName)?.specs ?? []\n}\n\n/**\n * 테스트 전용 — 모든 등록 해제. 운영 환경에서 호출 금지.\n *\n * jest 의 setupFilesAfterEach 또는 명시적 afterEach 에서 호출해 테스트 간 격리.\n * 실제로 STYLING_TOOLS 는 board-ai 자체 부트스트랩에서 다시 등록되므로 clear 후\n * 명시 register 가 필요한 것은 동적 카테고리 (board-import 등) 뿐.\n */\nexport function clearToolRegistry(): void {\n categories.clear()\n}\n"]}
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../server/service/tool-registry/registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ToolCategoryRegistry — 외부 도메인 패키지가 LLM tool category 를 동적 등록하는 점.\n *\n * 사용 패턴 (외부 패키지의 server bootstrap):\n * ```ts\n * import { registerToolCategory } from '@things-factory/board-ai'\n *\n * registerToolCategory({\n * name: 'board-import',\n * specs: [\n * { kind: 'external', name: 'importBoardAsync', schema: {...}, builder: ... },\n * { kind: 'read', name: 'getImportSession', schema: {...}, builder: ... },\n * ...\n * ]\n * })\n * ```\n *\n * board-ai assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이\n * 본 registry 에서 자동 도출.\n *\n * 정책:\n * - 카테고리 이름은 unique. 같은 이름으로 재등록하면 덮어쓰기 (외부 패키지의\n * hot-reload / dev 환경 대응). console.warn 으로 알림.\n * - tool name 은 카테고리 간 통틀어 unique. 충돌 시 등록 거부 + 명확한 에러\n * (LLM dispatch 가 모호해지지 않도록).\n * - clear() 는 테스트 전용. 운영 환경에서 호출 금지.\n */\nimport type { ToolCategory, ToolSpec } from './types'\n\nconst categories: Map<string, ToolCategory> = new Map()\n\n/** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */\nexport interface ToolRegistryWarning {\n code: 'tool-privilege-undeclared'\n params: { category: string; tool: string; kind: ToolSpec['kind'] }\n /** For a person reading it; the code and params are what callers act on. */\n message: string\n}\n\nconst warnings: ToolRegistryWarning[] = []\n\n/**\n * What registration accepted but the contract does not yet allow. Stage one of A1 records these\n * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.\n */\nexport function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning> {\n return warnings.slice()\n}\n\n/**\n * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.\n */\nexport function registerToolCategory(category: ToolCategory): void {\n /*\n * 지침 없는 카테고리는 **등록하지 않는다.**\n *\n * 도구 목록만 보낸 모델이 「불러야 할 때 부른다」를 맞히지 못한 사고가 있었다 — 조치 요청에\n * `proposeAction` 을 부르지 않고 「제안해 두었습니다」라고 답해, 사용자에게 없는 버튼을 누르라고\n * 안내했다. 화면에는 아무 일도 없었고 오류도 나지 않았다(§`ToolCategory.guidance`).\n *\n * 그 뒤로 규율을 적는 자리를 만들었지만 **선택이었다.** 선택인 규율은 그 사고를 아는 사람만\n * 채운다. 여기서 거절하는 이유가 그것이다 — 빠진 것을 부팅에서 만든 사람이 보고, 모델이 헛말을\n * 한 뒤에 사용자가 보지 않는다.\n *\n * 빈 문자열·공백도 거절한다. 통과시키면 이 검사가 형식이 되고, 형식이 된 검사는 없는 것과 같다.\n */\n if (!String(category.guidance ?? '').trim()) {\n throw new Error(\n `[ai-client-base/tool-registry] category '${category.name}' has no guidance. ` +\n 'Tool descriptions say what a tool does; guidance says when the model must call one. ' +\n 'Without it a model answers as though it called a tool it did not, and nothing errors. ' +\n 'Write the rules for these tools in `guidance` — the side that registers them owns them.'\n )\n }\n\n /*\n * A tool that changes something must say which privilege it goes through (A1, 2026-09-17).\n *\n * The conversation door checks one privilege — may this person talk to the assistant. Without a\n * privilege on the tool, that single check opened every tool behind it: a user holding only\n * `ai-assistant:mutation` could reach board-import's `materializeImportSession` and create a\n * Board.\n *\n * **This is stage one of two: surface, then refuse** (ADR-0037, \"먼저 드러내고, 목록이 비면 막는다\").\n * Refusing now would stop every app whose tools predate the `privilege` field from booting, and\n * those apps compile against an installed copy that does not have the field yet. So an undeclared\n * tool still registers, and the omission is recorded as a code (`getToolRegistryWarnings`), not\n * a log line — a warning only a log carries is not distinguishable from no warning. When the list\n * is empty across the apps, this becomes a refusal.\n */\n warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))\n for (const spec of category.specs) {\n if (spec.kind !== 'read' && !(spec.privilege?.category && spec.privilege?.privilege)) {\n warnings.push({\n code: 'tool-privilege-undeclared',\n params: { category: category.name, tool: spec.name, kind: spec.kind },\n message:\n `tool '${spec.name}' in category '${category.name}' is '${spec.kind}' but declares no privilege — ` +\n 'declare the same { category, privilege } its GraphQL door uses'\n })\n }\n }\n\n if (categories.has(category.name)) {\n console.warn(\n `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +\n `(dev hot-reload 시 정상이지만 운영 환경에서 발생하면 buildup bug)`\n )\n }\n\n // tool name 충돌 검사 — 다른 카테고리에 같은 name 있으면 거부\n const newNames = new Set(category.specs.map(s => s.name))\n for (const [otherName, other] of categories) {\n if (otherName === category.name) continue // 같은 카테고리 재등록은 덮어쓰기 OK\n for (const spec of other.specs) {\n if (newNames.has(spec.name)) {\n throw new Error(\n `[board-ai/tool-registry] tool name '${spec.name}' already registered in category ` +\n `'${otherName}', cannot register in '${category.name}'. tool names must be globally unique.`\n )\n }\n }\n }\n\n categories.set(category.name, category)\n}\n\n/**\n * 대화별 도구 노출 제한 — 허용 카테고리 목록.\n *\n * `undefined` 는 **제한 없음**(등록된 전부). 호스트가 목록을 주면 그 카테고리에 속한 spec 만\n * 정의·실행 대상이 된다. 정의를 숨기는 것만으로는 부족하다 — 모델이 이름을 추측하거나 옛 대화가\n * 재생되면 숨긴 도구도 호출되므로 **dispatch 조회(findToolSpec)에도 같은 목록을 넘겨야** 한다.\n */\nexport type ToolCategoryFilter = ReadonlyArray<string> | undefined\n\n/** 허용 목록에 든 카테고리인가. 목록 없음 = 전부 허용. */\nfunction allowed(name: string, filter: ToolCategoryFilter): boolean {\n return !filter || filter.includes(name)\n}\n\n/**\n * 등록된 모든 카테고리 (등록 순서 보존). 외부에서 불변 read-only 로 사용.\n */\nexport function getToolCategories(): ReadonlyArray<ToolCategory> {\n return Array.from(categories.values())\n}\n\n/**\n * 등록된 모든 tool spec 의 평면 view. assistant.ts 의 buildBoardEditTools 가\n * LLM tool 정의 합집합으로 사용.\n */\nexport function getAllToolSpecs(): ReadonlyArray<ToolSpec> {\n return getToolSpecsFor(undefined)\n}\n\n/**\n * 허용 카테고리의 spec 평면 view. `undefined` = 전부(= getAllToolSpecs 와 동일).\n * LLM 도구 정의를 만들 때 이 함수를 쓰면 대화별 노출 제한이 그대로 반영된다.\n */\nexport function getToolSpecsFor(filter: ToolCategoryFilter): ReadonlyArray<ToolSpec> {\n const out: ToolSpec[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n for (const spec of cat.specs) out.push(spec)\n }\n return out\n}\n\n/**\n * 노출된 카테고리들의 **사용 규율** 모음(등록 순서). 시스템 프롬프트에 붙이는 용도.\n *\n * 도구 목록만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시 불러야 하는지\" 는 모른다.\n * 규율은 도구를 등록한 쪽이 소유하고, 호스트는 노출된 것만 모아 붙인다(도메인 지식이 새지 않는다).\n */\nexport function getCategoryGuidance(filter: ToolCategoryFilter): string[] {\n const out: string[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const guidance = (cat.guidance ?? '').trim()\n if (guidance) out.push(guidance)\n }\n return out\n}\n\n/**\n * tool name 으로 spec 검색. 없으면 undefined.\n */\nexport function findToolSpec(name: string, filter?: ToolCategoryFilter): ToolSpec | undefined {\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const spec = cat.specs.find(s => s.name === name)\n if (spec) return spec\n }\n return undefined\n}\n\n/**\n * tool name 의 카테고리 (read/write/action/external). 미등록 tool 은 undefined.\n * assistant.ts 의 isReadTool / isWriteTool / isActionTool 이 이 함수에 위임할 수 있다.\n */\nexport function getToolKind(name: string, filter?: ToolCategoryFilter): ToolSpec['kind'] | undefined {\n return findToolSpec(name, filter)?.kind\n}\n\n/**\n * 등록된 tool 인지. dispatch path 가 hardcoded set 와 registry 양쪽 fallback 시 사용.\n */\nexport function hasTool(name: string, filter?: ToolCategoryFilter): boolean {\n return findToolSpec(name, filter) !== undefined\n}\n\n/**\n * 카테고리 이름으로 spec 만 추출.\n */\nexport function getCategorySpecs(categoryName: string): ReadonlyArray<ToolSpec> {\n return categories.get(categoryName)?.specs ?? []\n}\n\n/**\n * 테스트 전용 — 모든 등록 해제. 운영 환경에서 호출 금지.\n *\n * jest 의 setupFilesAfterEach 또는 명시적 afterEach 에서 호출해 테스트 간 격리.\n * 실제로 STYLING_TOOLS 는 board-ai 자체 부트스트랩에서 다시 등록되므로 clear 후\n * 명시 register 가 필요한 것은 동적 카테고리 (board-import 등) 뿐.\n */\nexport function clearToolRegistry(): void {\n categories.clear()\n warnings.length = 0\n}\n"]}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 설계 원칙:
|
|
14
14
|
* - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,
|
|
15
|
-
* write 는 (args, components) →
|
|
15
|
+
* write 는 (args, components) → SceneEditOp[], action 은 (args) → SceneActionOp.
|
|
16
16
|
* 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는
|
|
17
17
|
* builder 만 채우면 되도록 카테고리별 sub-interface 분리.
|
|
18
18
|
* - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.
|
|
@@ -21,6 +21,30 @@
|
|
|
21
21
|
* 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도
|
|
22
22
|
* 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a
|
|
26
|
+
* registered tool when it runs.
|
|
27
|
+
*
|
|
28
|
+
* `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door
|
|
29
|
+
* passed `ctx.host`, so they never saw the space; the two are never merged into one object.
|
|
30
|
+
*
|
|
31
|
+
* The board product's door (board-ai) passes its own shape and is not part of this contract.
|
|
32
|
+
*/
|
|
33
|
+
export interface ToolCallContext {
|
|
34
|
+
/** The request state the server built — domain, user, tx. The only basis for authorization. */
|
|
35
|
+
state: any;
|
|
36
|
+
/**
|
|
37
|
+
* The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads
|
|
38
|
+
* an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.
|
|
39
|
+
*/
|
|
40
|
+
host: Record<string, any>;
|
|
41
|
+
/** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */
|
|
42
|
+
mode?: ToolCallMode;
|
|
43
|
+
}
|
|
44
|
+
export interface ToolCallMode {
|
|
45
|
+
kind: string;
|
|
46
|
+
target?: string;
|
|
47
|
+
}
|
|
24
48
|
/**
|
|
25
49
|
* Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.
|
|
26
50
|
* 예: getSelection / findComponents / importSession (query)
|
|
@@ -41,7 +65,7 @@ export interface ReadToolContext {
|
|
|
41
65
|
* 예: board-ai 의 addComponent / setFill / modifyBoard.
|
|
42
66
|
*
|
|
43
67
|
* builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의
|
|
44
|
-
*
|
|
68
|
+
* toolCallToSceneEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.
|
|
45
69
|
*/
|
|
46
70
|
export type WriteToolBuilder = (args: any, context: WriteToolContext) => any[] | {
|
|
47
71
|
ops: any[];
|
|
@@ -55,8 +79,8 @@ export interface WriteToolContext {
|
|
|
55
79
|
* Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).
|
|
56
80
|
* board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.
|
|
57
81
|
*
|
|
58
|
-
* return type 은 unknown — 도메인별 dispatcher (예: board-ai 의
|
|
59
|
-
* 가 자기
|
|
82
|
+
* return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToSceneActionOp)
|
|
83
|
+
* 가 자기 SceneActionOp 으로 cast.
|
|
60
84
|
*/
|
|
61
85
|
export type ActionToolBuilder = (args: any) => any | null;
|
|
62
86
|
/**
|
|
@@ -71,6 +95,22 @@ export interface ExternalToolContext {
|
|
|
71
95
|
state?: any;
|
|
72
96
|
[k: string]: any;
|
|
73
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* The privilege a tool requires — the same `category`/`privilege` the tool's GraphQL door declares
|
|
100
|
+
* (`@privilege(category, privilege, owner?, super?)`), so the fields mirror `PrivilegeObject`.
|
|
101
|
+
*
|
|
102
|
+
* A tool is not a new door; it is another way into a door that already exists. A tool that creates
|
|
103
|
+
* a Board goes through the privilege that creating a Board already needs. Minting an AI-only
|
|
104
|
+
* privilege name would put two doors on one effect, and one of them would end up looser.
|
|
105
|
+
*/
|
|
106
|
+
export interface ToolPrivilege {
|
|
107
|
+
category: string;
|
|
108
|
+
privilege: string;
|
|
109
|
+
/** Domain owners pass without the privilege — mirror the door's `domainOwnerGranted`. */
|
|
110
|
+
owner?: boolean;
|
|
111
|
+
/** Super users pass without the privilege — mirror the door's `superUserGranted`. */
|
|
112
|
+
super?: boolean;
|
|
113
|
+
}
|
|
74
114
|
/**
|
|
75
115
|
* 모든 ToolSpec 공통 메타데이터.
|
|
76
116
|
*/
|
|
@@ -86,6 +126,14 @@ export interface ToolSpecBase {
|
|
|
86
126
|
themeNote?: string;
|
|
87
127
|
/** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */
|
|
88
128
|
schema: any;
|
|
129
|
+
/**
|
|
130
|
+
* The privilege a caller needs before this tool is offered or run. See `ToolPrivilege`.
|
|
131
|
+
*
|
|
132
|
+
* Expected for `write`, `action` and `external` — registration records a
|
|
133
|
+
* `tool-privilege-undeclared` warning for one without it (A1 stage one). A `read` tool without it is
|
|
134
|
+
* offered on domain membership alone, and its builder must check what it reads.
|
|
135
|
+
*/
|
|
136
|
+
privilege?: ToolPrivilege;
|
|
89
137
|
}
|
|
90
138
|
export interface ReadToolSpec extends ToolSpecBase {
|
|
91
139
|
kind: 'read';
|