@things-factory/ai-client-base 10.1.212 → 10.1.215
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/registry-assistant.d.ts +4 -1
- package/dist-server/service/assistant/registry-assistant.js +2 -1
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/assistant/tool-budget.js +28 -6
- package/dist-server/service/assistant/tool-budget.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +8 -0
- package/dist-server/service/tool-registry/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/assistant/registry-assistant.ts +4 -2
- package/server/service/assistant/tool-budget.test.ts +11 -0
- package/server/service/assistant/tool-budget.ts +29 -6
- package/server/service/tool-registry/types.ts +6 -0
|
@@ -77,7 +77,10 @@ export interface RegistryAssistantInput {
|
|
|
77
77
|
*/
|
|
78
78
|
onToolCall?: (record: ToolCallRecord) => void;
|
|
79
79
|
/** Set for an unattended run; tools and recorders see it as `context.run`. */
|
|
80
|
-
run?: UnattendedRunRef;
|
|
80
|
+
run?: UnattendedRunRef; /** The stored conversation of this turn; tools see it as `context.conversation`. The caller's value, never the client's. */
|
|
81
|
+
conversation?: {
|
|
82
|
+
id: string;
|
|
83
|
+
};
|
|
81
84
|
}
|
|
82
85
|
export interface RegistryAssistantResult extends AssistantChatResult<unknown> {
|
|
83
86
|
/** Tool names actually offered to the model this turn. The caller may log or return it. */
|
|
@@ -55,7 +55,8 @@ async function registryAssistantChat(input) {
|
|
|
55
55
|
/* 번역기와 첨부는 서버의 값이다 — `host` 에서 받지 않는다(ADR-0061 2026-10-16 판정 1·2). */
|
|
56
56
|
t: input.t ?? translate_js_1.defaultValueTranslate,
|
|
57
57
|
attachments: input.attachments ?? [],
|
|
58
|
-
...(input.run ? { run: input.run } : {})
|
|
58
|
+
...(input.run ? { run: input.run } : {}),
|
|
59
|
+
...(input.conversation?.id ? { conversation: { id: input.conversation.id } } : {})
|
|
59
60
|
};
|
|
60
61
|
const recorded = async (run, context) => {
|
|
61
62
|
if (input.onToolCall) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry-assistant.js","sourceRoot":"","sources":["../../../server/service/assistant/registry-assistant.ts"],"names":[],"mappings":"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","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, type ToolAccessCheck } from './tool-scope.js'\nimport { assistantChat, type AssistantChatResult } from './assistant-chat.js'\nimport { resultShapeOf } from './summarize.js'\nimport type { GroundingSources } from '../utils/index.js'\nimport type { ToolAttachment, ToolCallContext, UnattendedRunRef } from '../tool-registry/types.js'\nimport { defaultValueTranslate } from '../tool-registry/translate.js'\nimport { digestOf, recordToolCall, type ToolCallRecord, type ToolCallRun } from '../tool-registry/call-recorder.js'\nimport { OPEN_TOOLS, TOOL_BUDGET_CATEGORY, toolOfferState, type ToolFront } from './tool-budget.js'\n\n\n/** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */\nexport const REGISTRY_MAX_ITERATIONS = 16\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 * What goes in front when the scope is larger than one call carries (ADR-0061 decision 9) - the screen's records,\n * the tools this conversation used. The rest the model opens with `openTools`. Without it, only `openTools` is in\n * front of a scope that does not fit.\n */\n front?: ToolFront\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 /**\n * Which conversation this is — `{ kind, target }` from the registered mode (ADR-0061 decision 3).\n *\n * Tools see it as `context.mode`. It is the server's value, resolved from the registry before the\n * turn runs, so a tool that behaves differently in two conversations reads it here and not from\n * `host`, which the client fills.\n */\n mode?: { kind: string; target?: string }\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 /**\n * The request's translator — the stop notice is then in the reader's language, and tools write what a person\n * reads with it (`context.t`). Without it tools get `defaultValueTranslate`: the English default values.\n */\n t?: (key: string, params?: Record<string, any>) => string\n /**\n * The files sent with this turn, after the caller's server checked them — stored with this conversation, the\n * conversation the caller's to write, the mode's policy met (ADR-0061, 2026-10-16 decision 2). Tools see them as\n * `context.attachments`. Never built from the client's request: what a client puts in `hostContext` stays there.\n */\n attachments?: ToolAttachment[]\n /** Texts the answer is allowed to draw identifiers from, besides the tool results. */\n groundingSources?: Omit<GroundingSources, 'toolResults'>\n /** Stops the loop and the provider request - an unattended run's time limit. */\n signal?: AbortSignal\n /** Lookups before the answer; `REGISTRY_MAX_ITERATIONS` when absent. */\n maxIterations?: number\n /** At the lookup limit, one more call without tools to answer from what was read. On when absent. */\n salvageOnMaxIterations?: boolean\n /**\n * Each call as the recorders get it (`digestOf`, never its raw values) - an unattended run lists the reads its draft\n * rested on from the same digest the audit keeps, so the two cannot describe one call differently.\n */\n onToolCall?: (record: ToolCallRecord) => void\n /** Set for an unattended run; tools and recorders see it as `context.run`. */\n run?: UnattendedRunRef\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 /** Tools of the scope the model could have opened and did not (ADR-0061 decision 9) - with `offeredTools`, all it may use. */\n openableTools: 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 /* What one call carries is within the budget; what is offered and what may run are the same set as it grows. */\n const offer = toolOfferState(scope, input.front)\n const categoryOf = new Map(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category.name] as [string, string])))\n categoryOf.set(OPEN_TOOLS, TOOL_BUDGET_CATEGORY)\n\n const tools = () =>\n offer.offered().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 offered ones, so\n * a surface never reads instructions for a category it cannot call - an opened tool brings its\n * category's with the opening.\n */\n const guidance = [...offer.frontGuidance(), offer.catalog()].filter(Boolean)\n const prompt = guidance.length > 0 ? `${systemPrompt}\\n\\n${guidance.join('\\n\\n')}` : systemPrompt\n\n /*\n * `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기\n * 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.\n */\n const toolContext: ToolCallContext = {\n state,\n host: hostContext ?? {},\n ...(input.mode ? { mode: input.mode } : {}),\n /* 번역기와 첨부는 서버의 값이다 — `host` 에서 받지 않는다(ADR-0061 2026-10-16 판정 1·2). */\n t: input.t ?? defaultValueTranslate,\n attachments: input.attachments ?? [],\n ...(input.run ? { run: input.run } : {})\n }\n\n const recorded = async (run: ToolCallRun, context: ToolCallContext) => {\n if (input.onToolCall) {\n try {\n input.onToolCall(digestOf(run))\n } catch (e) {\n console.warn(`[registry-assistant] onToolCall failed for ${run.tool}:`, (e as Error)?.message ?? e)\n }\n }\n await recordToolCall(run, context)\n }\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 * A question across a product's records takes many lookups. At the limit the model answers from\n * what it read and says what is missing — the person is never told to split the question.\n */\n maxIterations: input.maxIterations ?? REGISTRY_MAX_ITERATIONS,\n salvageOnMaxIterations: input.salvageOnMaxIterations ?? true,\n signal: input.signal\n },\n chat: (msgs, toolDefs, opts) => client.chatWithTools!(msgs, toolDefs, opts),\n context: toolContext,\n dispatch: {\n isReadTool: name => offer.isOffered(name) || offer.isOpenable(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 /* Every call is handed to the recorders - an application's audit sees the tools other packages declared too. */\n executeReadTool: async (call, context) => {\n if (offer.isOpenable(call.name)) {\n return { rejected: true, error: 'tool-not-offered', message: `${call.name} is not open yet. Open it with ${OPEN_TOOLS} first, then call it.` }\n }\n const spec = offer.offered().find(one => one.name === call.name)\n if (!spec) return undefined\n const args = call.arguments ?? {}\n const started = Date.now()\n const record = { tool: spec.name, category: categoryOf.get(spec.name) ?? '', args }\n try {\n const result = await (spec.builder as any)(args, context)\n await recorded({ ...record, result, durationMs: Date.now() - started }, context)\n return result\n } catch (e) {\n await recorded({ ...record, error: e, durationMs: Date.now() - started }, context)\n throw e\n }\n },\n validateWriteToolCall: () => ({ valid: false, errors: ['this conversation has no document to write to'] }),\n toolCallToOp: () => null,\n /* The conversation row keeps the shape of each answer, as the audit does - never its values (ADR-0061 decision 8). */\n summarizeToolResult: resultShapeOf\n },\n groundingSources: input.groundingSources,\n t: input.t\n })\n\n return { ...result, offeredTools: offer.offered().map(spec => spec.name), openableTools: offer.openable() }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"registry-assistant.js","sourceRoot":"","sources":["../../../server/service/assistant/registry-assistant.ts"],"names":[],"mappings":"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","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, type ToolAccessCheck } from './tool-scope.js'\nimport { assistantChat, type AssistantChatResult } from './assistant-chat.js'\nimport { resultShapeOf } from './summarize.js'\nimport type { GroundingSources } from '../utils/index.js'\nimport type { ToolAttachment, ToolCallContext, UnattendedRunRef } from '../tool-registry/types.js'\nimport { defaultValueTranslate } from '../tool-registry/translate.js'\nimport { digestOf, recordToolCall, type ToolCallRecord, type ToolCallRun } from '../tool-registry/call-recorder.js'\nimport { OPEN_TOOLS, TOOL_BUDGET_CATEGORY, toolOfferState, type ToolFront } from './tool-budget.js'\n\n\n/** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */\nexport const REGISTRY_MAX_ITERATIONS = 16\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 * What goes in front when the scope is larger than one call carries (ADR-0061 decision 9) - the screen's records,\n * the tools this conversation used. The rest the model opens with `openTools`. Without it, only `openTools` is in\n * front of a scope that does not fit.\n */\n front?: ToolFront\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 /**\n * Which conversation this is — `{ kind, target }` from the registered mode (ADR-0061 decision 3).\n *\n * Tools see it as `context.mode`. It is the server's value, resolved from the registry before the\n * turn runs, so a tool that behaves differently in two conversations reads it here and not from\n * `host`, which the client fills.\n */\n mode?: { kind: string; target?: string }\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 /**\n * The request's translator — the stop notice is then in the reader's language, and tools write what a person\n * reads with it (`context.t`). Without it tools get `defaultValueTranslate`: the English default values.\n */\n t?: (key: string, params?: Record<string, any>) => string\n /**\n * The files sent with this turn, after the caller's server checked them — stored with this conversation, the\n * conversation the caller's to write, the mode's policy met (ADR-0061, 2026-10-16 decision 2). Tools see them as\n * `context.attachments`. Never built from the client's request: what a client puts in `hostContext` stays there.\n */\n attachments?: ToolAttachment[]\n /** Texts the answer is allowed to draw identifiers from, besides the tool results. */\n groundingSources?: Omit<GroundingSources, 'toolResults'>\n /** Stops the loop and the provider request - an unattended run's time limit. */\n signal?: AbortSignal\n /** Lookups before the answer; `REGISTRY_MAX_ITERATIONS` when absent. */\n maxIterations?: number\n /** At the lookup limit, one more call without tools to answer from what was read. On when absent. */\n salvageOnMaxIterations?: boolean\n /**\n * Each call as the recorders get it (`digestOf`, never its raw values) - an unattended run lists the reads its draft\n * rested on from the same digest the audit keeps, so the two cannot describe one call differently.\n */\n onToolCall?: (record: ToolCallRecord) => void\n /** Set for an unattended run; tools and recorders see it as `context.run`. */\n run?: UnattendedRunRef /** The stored conversation of this turn; tools see it as `context.conversation`. The caller's value, never the client's. */\n conversation?: { id: string }\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 /** Tools of the scope the model could have opened and did not (ADR-0061 decision 9) - with `offeredTools`, all it may use. */\n openableTools: 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 /* What one call carries is within the budget; what is offered and what may run are the same set as it grows. */\n const offer = toolOfferState(scope, input.front)\n const categoryOf = new Map(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category.name] as [string, string])))\n categoryOf.set(OPEN_TOOLS, TOOL_BUDGET_CATEGORY)\n\n const tools = () =>\n offer.offered().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 offered ones, so\n * a surface never reads instructions for a category it cannot call - an opened tool brings its\n * category's with the opening.\n */\n const guidance = [...offer.frontGuidance(), offer.catalog()].filter(Boolean)\n const prompt = guidance.length > 0 ? `${systemPrompt}\\n\\n${guidance.join('\\n\\n')}` : systemPrompt\n\n /*\n * `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기\n * 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.\n */\n const toolContext: ToolCallContext = {\n state,\n host: hostContext ?? {},\n ...(input.mode ? { mode: input.mode } : {}),\n /* 번역기와 첨부는 서버의 값이다 — `host` 에서 받지 않는다(ADR-0061 2026-10-16 판정 1·2). */\n t: input.t ?? defaultValueTranslate,\n attachments: input.attachments ?? [],\n ...(input.run ? { run: input.run } : {}),\n ...(input.conversation?.id ? { conversation: { id: input.conversation.id } } : {})\n }\n\n const recorded = async (run: ToolCallRun, context: ToolCallContext) => {\n if (input.onToolCall) {\n try {\n input.onToolCall(digestOf(run))\n } catch (e) {\n console.warn(`[registry-assistant] onToolCall failed for ${run.tool}:`, (e as Error)?.message ?? e)\n }\n }\n await recordToolCall(run, context)\n }\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 * A question across a product's records takes many lookups. At the limit the model answers from\n * what it read and says what is missing — the person is never told to split the question.\n */\n maxIterations: input.maxIterations ?? REGISTRY_MAX_ITERATIONS,\n salvageOnMaxIterations: input.salvageOnMaxIterations ?? true,\n signal: input.signal\n },\n chat: (msgs, toolDefs, opts) => client.chatWithTools!(msgs, toolDefs, opts),\n context: toolContext,\n dispatch: {\n isReadTool: name => offer.isOffered(name) || offer.isOpenable(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 /* Every call is handed to the recorders - an application's audit sees the tools other packages declared too. */\n executeReadTool: async (call, context) => {\n if (offer.isOpenable(call.name)) {\n return { rejected: true, error: 'tool-not-offered', message: `${call.name} is not open yet. Open it with ${OPEN_TOOLS} first, then call it.` }\n }\n const spec = offer.offered().find(one => one.name === call.name)\n if (!spec) return undefined\n const args = call.arguments ?? {}\n const started = Date.now()\n const record = { tool: spec.name, category: categoryOf.get(spec.name) ?? '', args }\n try {\n const result = await (spec.builder as any)(args, context)\n await recorded({ ...record, result, durationMs: Date.now() - started }, context)\n return result\n } catch (e) {\n await recorded({ ...record, error: e, durationMs: Date.now() - started }, context)\n throw e\n }\n },\n validateWriteToolCall: () => ({ valid: false, errors: ['this conversation has no document to write to'] }),\n toolCallToOp: () => null,\n /* The conversation row keeps the shape of each answer, as the audit does - never its values (ADR-0061 decision 8). */\n summarizeToolResult: resultShapeOf\n },\n groundingSources: input.groundingSources,\n t: input.t\n })\n\n return { ...result, offeredTools: offer.offered().map(spec => spec.name), openableTools: offer.openable() }\n}\n"]}
|
|
@@ -93,11 +93,31 @@ function toolOfferState(scope, front = {}) {
|
|
|
93
93
|
const opening = openable.size > 0 ? openToolsSpec(names => open(names)) : undefined;
|
|
94
94
|
const offered = new Map([...plan.offered, ...(opening ? [opening] : [])].map(spec => [spec.name, spec]));
|
|
95
95
|
const categoryOf = new Map(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category])));
|
|
96
|
+
/*
|
|
97
|
+
* The tools of this scope a wrong name most likely meant - the same name after its verb (`readOfficeExpenditureRequest`
|
|
98
|
+
* for `queryOfficeExpenditureRequest`). Only this scope's: a tool the person may not use is never named.
|
|
99
|
+
*/
|
|
100
|
+
const meant = (name) => {
|
|
101
|
+
const stem = name.replace(/^[a-z]+/, '');
|
|
102
|
+
return stem ? [...openable.keys(), ...offered.keys()].filter(one => one !== name && one.replace(/^[a-z]+/, '') === stem) : [];
|
|
103
|
+
};
|
|
104
|
+
/*
|
|
105
|
+
* A wrong name among right ones does not hold the right ones back: asked for a card, the model opened the card tool with
|
|
106
|
+
* a read it named wrong, got nothing open, and told the person the tools were not there (2026-10-25). The right ones
|
|
107
|
+
* open; each wrong one is said with what it most likely meant.
|
|
108
|
+
*/
|
|
96
109
|
function open(names) {
|
|
97
|
-
const unknown = names.filter(name => !openable.has(name) && !offered.has(name));
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
const unknown = [...new Set(names.filter(name => !openable.has(name) && !offered.has(name)))];
|
|
111
|
+
const suggestions = Object.fromEntries(unknown.map(name => [name, meant(name)]).filter(([, near]) => near.length));
|
|
112
|
+
const notInList = unknown.length
|
|
113
|
+
? `${unknown.join(', ')} ${unknown.length > 1 ? 'are' : 'is'} not in the list of tools you can open` +
|
|
114
|
+
(Object.keys(suggestions).length ? ` - ${Object.entries(suggestions).map(([name, near]) => `for ${name} use ${near.join(' or ')}`).join('; ')}` : '') +
|
|
115
|
+
'. Use the exact names from the list.'
|
|
116
|
+
: '';
|
|
117
|
+
if (unknown.length && unknown.length === new Set(names).size) {
|
|
118
|
+
return { rejected: true, error: 'tool-not-offered', names: unknown, ...(Object.keys(suggestions).length ? { suggestions } : {}), message: `${notInList} Call ${exports.OPEN_TOOLS} again now.` };
|
|
100
119
|
}
|
|
120
|
+
names = names.filter(name => !unknown.includes(name));
|
|
101
121
|
const fresh = [...new Set(names)].filter(name => !offered.has(name));
|
|
102
122
|
const already = [...new Set(names)].filter(name => offered.has(name) && name !== exports.OPEN_TOOLS);
|
|
103
123
|
const room = exports.MAX_OFFERED_TOOLS - offered.size;
|
|
@@ -112,13 +132,15 @@ function toolOfferState(scope, front = {}) {
|
|
|
112
132
|
for (const name of fresh)
|
|
113
133
|
offered.set(name, openable.get(name));
|
|
114
134
|
const guidance = [...new Set(fresh.map(name => categoryOf.get(name)?.guidance).filter(Boolean))];
|
|
135
|
+
const said = already.length
|
|
136
|
+
? `${already.join(', ')} ${already.length > 1 ? 'are' : 'is'} already open - call ${already.length > 1 ? 'them' : 'it'} now. ${fresh.length ? 'The others are open from your next step on.' : ''}`.trim()
|
|
137
|
+
: 'these tools are open from your next step on. Call them as the question needs.';
|
|
115
138
|
return {
|
|
116
139
|
opened: fresh,
|
|
117
140
|
...(already.length ? { alreadyOffered: already } : {}),
|
|
141
|
+
...(unknown.length ? { notOffered: unknown, ...(Object.keys(suggestions).length ? { suggestions } : {}) } : {}),
|
|
118
142
|
...(guidance.length ? { guidance: guidance.join('\n') } : {}),
|
|
119
|
-
message:
|
|
120
|
-
? `${already.join(', ')} ${already.length > 1 ? 'are' : 'is'} already open - call ${already.length > 1 ? 'them' : 'it'} now. ${fresh.length ? 'The others are open from your next step on.' : ''}`.trim()
|
|
121
|
-
: 'these tools are open from your next step on. Call them as the question needs.'
|
|
143
|
+
message: notInList ? `${said} ${notInList} Open it now and go on.` : said
|
|
122
144
|
};
|
|
123
145
|
}
|
|
124
146
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-budget.js","sourceRoot":"","sources":["../../../server/service/assistant/tool-budget.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * How many tools one model call carries (ADR-0061 decision 9, operato-application design/ai/tool-budget.md).\n *\n * A conversation used to send every tool its mode opened on every call. Office's conversation opened 165, and OpenAI\n * refuses more than 128 - every turn of a person who may read everything failed. The tools also went with their\n * guidance, some 140,000 characters a call.\n *\n * Now a call carries at most `MAX_OFFERED_TOOLS`. When the scope is larger, the caller names what goes in front (the\n * screen's records, the tools this conversation used); the rest is listed once in the prompt and the model opens what\n * it needs with `openTools` for the rest of the turn. Whether a person may use a tool is decided as before - the list\n * holds only the tools of their scope, and opening runs no tool.\n */\nimport type { ToolSpec } from '../tool-registry/types.js'\nimport type { ScopedCategory, ToolScope } from './tool-scope.js'\n\n/** Half the smallest provider limit known (OpenAI, 128). One constant - an application or a mode does not change it. */\nexport const MAX_OFFERED_TOOLS = 64\n\nexport const OPEN_TOOLS = 'openTools'\n\n/** The category the opening tool is recorded under - it is not registered; no mode names it. */\nexport const TOOL_BUDGET_CATEGORY = 'tool-budget'\n\n/** What the caller puts in front when the scope does not fit: whole categories first, then single tools while there is room. */\nexport interface ToolFront {\n categories?: string[]\n tools?: string[]\n}\n\nexport class ToolBudgetError extends Error {\n code = 'tool-budget-exceeded'\n constructor(public front: number) {\n super(`[tool-budget] the tools put in front (${front}) and openTools do not fit in ${MAX_OFFERED_TOOLS} - narrow the mode's own categories`)\n }\n}\n\nexport interface ToolOffer {\n /** What the first call carries, openTools last when anything is left to open. */\n offered: ToolSpec[]\n /** In the scope and not offered - what openTools may open. */\n openable: ToolSpec[]\n}\n\n/**\n * What a turn starts with. The whole scope when it fits; otherwise the front categories (all of them, or it throws),\n * the front tools while there is room, and openTools.\n */\nexport function planToolOffer(scope: ToolScope, front: ToolFront = {}): ToolOffer {\n if (scope.specs.length <= MAX_OFFERED_TOOLS) return { offered: [...scope.specs], openable: [] }\n\n const room = MAX_OFFERED_TOOLS - 1\n const picked = new Map<string, ToolSpec>()\n for (const name of front.categories ?? []) {\n for (const spec of scope.categories.find(category => category.name === name)?.specs ?? []) picked.set(spec.name, spec)\n }\n if (picked.size > room) throw new ToolBudgetError(picked.size)\n\n const byName = new Map(scope.specs.map(spec => [spec.name, spec]))\n for (const name of front.tools ?? []) {\n if (picked.size >= room) break\n const spec = byName.get(name)\n if (spec) picked.set(name, spec)\n }\n\n const offered = [...picked.values()]\n return { offered, openable: scope.specs.filter(spec => !picked.has(spec.name)) }\n}\n\nconst ONE_LINE = 160\n\n/** The first sentence of a description, on one line - what the list says of a tool. */\nfunction oneLine(description: string | undefined): string {\n const flat = String(description ?? '').replace(/\\s+/g, ' ').trim()\n const sentence = /^(.+?[.!?])(\\s|$)/.exec(flat)?.[1] ?? flat\n return sentence.length > ONE_LINE ? `${sentence.slice(0, ONE_LINE - 1)}…` : sentence\n}\n\n/**\n * The list of tools the model may open - one function builds it, wherever it goes. It goes in the prompt: OpenAI is\n * reported to refuse a tool description over 1024 characters, and Office's list runs to some 150 lines.\n */\nexport function openableToolCatalog(openable: ReadonlyArray<ToolSpec>): string {\n if (openable.length === 0) return ''\n return [\n /*\n * Office's model, offered a bank lines' total it could open, told the person that read was not open and asked them\n * to pick a range (2026-10-22) - an app's prompt said a missing tool means a missing permission. What is on this\n * list is the model's to use.\n */\n `Tools you can open with ${OPEN_TOOLS} (name: what it does). They are yours to use in this conversation, only not loaded yet. ` +\n `When the question needs one, open it with ${OPEN_TOOLS} and call it: never tell the person a tool on this list is missing, ` +\n 'and never ask the person to choose instead of opening it.',\n ...openable.map(spec => `- ${spec.name}: ${oneLine(spec.description)}`)\n ].join('\\n')\n}\n\n/** The opening tool's definition. */\nexport function openToolsSpec(open: (names: string[]) => unknown): ToolSpec {\n return {\n kind: 'read',\n name: OPEN_TOOLS,\n description:\n `Open more tools for the rest of this answer. Pass the exact names from the list of tools you can open; they are offered from your next step on, with how to use them. At most ${MAX_OFFERED_TOOLS} tools are open at once.`,\n schema: {\n type: 'object',\n required: ['names'],\n properties: { names: { type: 'array', items: { type: 'string' }, description: 'Names from the list of tools you can open.' } }\n },\n builder: (args: any) => open(Array.isArray(args?.names) ? args.names.map(String) : [])\n } as ToolSpec\n}\n\n/**\n * The offer of one turn, as it grows. `open` adds tools to what the next call carries; it never runs one.\n */\nexport function toolOfferState(scope: ToolScope, front: ToolFront = {}) {\n const plan = planToolOffer(scope, front)\n const openable = new Map(plan.openable.map(spec => [spec.name, spec]))\n const opening = openable.size > 0 ? openToolsSpec(names => open(names)) : undefined\n const offered = new Map<string, ToolSpec>([...plan.offered, ...(opening ? [opening] : [])].map(spec => [spec.name, spec]))\n const categoryOf = new Map<string, ScopedCategory>(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category] as [string, ScopedCategory])))\n\n function open(names: string[]) {\n const unknown = names.filter(name => !openable.has(name) && !offered.has(name))\n if (unknown.length) {\n return { rejected: true, error: 'tool-not-offered', names: unknown, message: `these are not in the list of tools you can open: ${unknown.join(', ')}. Use the exact names from the list.` }\n }\n const fresh = [...new Set(names)].filter(name => !offered.has(name))\n const already = [...new Set(names)].filter(name => offered.has(name) && name !== OPEN_TOOLS)\n const room = MAX_OFFERED_TOOLS - offered.size\n if (fresh.length > room) {\n return {\n rejected: true,\n error: 'tool-budget-exceeded',\n room,\n message: `only ${room} more tools fit in this answer. Open fewer - the ones this question needs - or answer from what you have read.`\n }\n }\n for (const name of fresh) offered.set(name, openable.get(name)!)\n const guidance = [...new Set(fresh.map(name => categoryOf.get(name)?.guidance).filter(Boolean))]\n return {\n opened: fresh,\n ...(already.length ? { alreadyOffered: already } : {}),\n ...(guidance.length ? { guidance: guidance.join('\\n') } : {}),\n message: already.length\n ? `${already.join(', ')} ${already.length > 1 ? 'are' : 'is'} already open - call ${already.length > 1 ? 'them' : 'it'} now. ${fresh.length ? 'The others are open from your next step on.' : ''}`.trim()\n : 'these tools are open from your next step on. Call them as the question needs.'\n }\n }\n\n return {\n /** What a call carries now. */\n offered: () => [...offered.values()],\n isOffered: (name: string) => offered.has(name),\n /** In the scope and still to be opened - a call to one is answered with how to open it. */\n isOpenable: (name: string) => openable.has(name) && !offered.has(name),\n /** In the scope and not offered now. */\n openable: () => [...openable.keys()].filter(name => !offered.has(name)),\n /** The guidance of the categories in front - opened ones bring theirs with the result. */\n frontGuidance: () => [...new Set(plan.offered.map(spec => categoryOf.get(spec.name)?.guidance).filter(Boolean) as string[])],\n catalog: () => openableToolCatalog(plan.openable)\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"tool-budget.js","sourceRoot":"","sources":["../../../server/service/assistant/tool-budget.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * How many tools one model call carries (ADR-0061 decision 9, operato-application design/ai/tool-budget.md).\n *\n * A conversation used to send every tool its mode opened on every call. Office's conversation opened 165, and OpenAI\n * refuses more than 128 - every turn of a person who may read everything failed. The tools also went with their\n * guidance, some 140,000 characters a call.\n *\n * Now a call carries at most `MAX_OFFERED_TOOLS`. When the scope is larger, the caller names what goes in front (the\n * screen's records, the tools this conversation used); the rest is listed once in the prompt and the model opens what\n * it needs with `openTools` for the rest of the turn. Whether a person may use a tool is decided as before - the list\n * holds only the tools of their scope, and opening runs no tool.\n */\nimport type { ToolSpec } from '../tool-registry/types.js'\nimport type { ScopedCategory, ToolScope } from './tool-scope.js'\n\n/** Half the smallest provider limit known (OpenAI, 128). One constant - an application or a mode does not change it. */\nexport const MAX_OFFERED_TOOLS = 64\n\nexport const OPEN_TOOLS = 'openTools'\n\n/** The category the opening tool is recorded under - it is not registered; no mode names it. */\nexport const TOOL_BUDGET_CATEGORY = 'tool-budget'\n\n/** What the caller puts in front when the scope does not fit: whole categories first, then single tools while there is room. */\nexport interface ToolFront {\n categories?: string[]\n tools?: string[]\n}\n\nexport class ToolBudgetError extends Error {\n code = 'tool-budget-exceeded'\n constructor(public front: number) {\n super(`[tool-budget] the tools put in front (${front}) and openTools do not fit in ${MAX_OFFERED_TOOLS} - narrow the mode's own categories`)\n }\n}\n\nexport interface ToolOffer {\n /** What the first call carries, openTools last when anything is left to open. */\n offered: ToolSpec[]\n /** In the scope and not offered - what openTools may open. */\n openable: ToolSpec[]\n}\n\n/**\n * What a turn starts with. The whole scope when it fits; otherwise the front categories (all of them, or it throws),\n * the front tools while there is room, and openTools.\n */\nexport function planToolOffer(scope: ToolScope, front: ToolFront = {}): ToolOffer {\n if (scope.specs.length <= MAX_OFFERED_TOOLS) return { offered: [...scope.specs], openable: [] }\n\n const room = MAX_OFFERED_TOOLS - 1\n const picked = new Map<string, ToolSpec>()\n for (const name of front.categories ?? []) {\n for (const spec of scope.categories.find(category => category.name === name)?.specs ?? []) picked.set(spec.name, spec)\n }\n if (picked.size > room) throw new ToolBudgetError(picked.size)\n\n const byName = new Map(scope.specs.map(spec => [spec.name, spec]))\n for (const name of front.tools ?? []) {\n if (picked.size >= room) break\n const spec = byName.get(name)\n if (spec) picked.set(name, spec)\n }\n\n const offered = [...picked.values()]\n return { offered, openable: scope.specs.filter(spec => !picked.has(spec.name)) }\n}\n\nconst ONE_LINE = 160\n\n/** The first sentence of a description, on one line - what the list says of a tool. */\nfunction oneLine(description: string | undefined): string {\n const flat = String(description ?? '').replace(/\\s+/g, ' ').trim()\n const sentence = /^(.+?[.!?])(\\s|$)/.exec(flat)?.[1] ?? flat\n return sentence.length > ONE_LINE ? `${sentence.slice(0, ONE_LINE - 1)}…` : sentence\n}\n\n/**\n * The list of tools the model may open - one function builds it, wherever it goes. It goes in the prompt: OpenAI is\n * reported to refuse a tool description over 1024 characters, and Office's list runs to some 150 lines.\n */\nexport function openableToolCatalog(openable: ReadonlyArray<ToolSpec>): string {\n if (openable.length === 0) return ''\n return [\n /*\n * Office's model, offered a bank lines' total it could open, told the person that read was not open and asked them\n * to pick a range (2026-10-22) - an app's prompt said a missing tool means a missing permission. What is on this\n * list is the model's to use.\n */\n `Tools you can open with ${OPEN_TOOLS} (name: what it does). They are yours to use in this conversation, only not loaded yet. ` +\n `When the question needs one, open it with ${OPEN_TOOLS} and call it: never tell the person a tool on this list is missing, ` +\n 'and never ask the person to choose instead of opening it.',\n ...openable.map(spec => `- ${spec.name}: ${oneLine(spec.description)}`)\n ].join('\\n')\n}\n\n/** The opening tool's definition. */\nexport function openToolsSpec(open: (names: string[]) => unknown): ToolSpec {\n return {\n kind: 'read',\n name: OPEN_TOOLS,\n description:\n `Open more tools for the rest of this answer. Pass the exact names from the list of tools you can open; they are offered from your next step on, with how to use them. At most ${MAX_OFFERED_TOOLS} tools are open at once.`,\n schema: {\n type: 'object',\n required: ['names'],\n properties: { names: { type: 'array', items: { type: 'string' }, description: 'Names from the list of tools you can open.' } }\n },\n builder: (args: any) => open(Array.isArray(args?.names) ? args.names.map(String) : [])\n } as ToolSpec\n}\n\n/**\n * The offer of one turn, as it grows. `open` adds tools to what the next call carries; it never runs one.\n */\nexport function toolOfferState(scope: ToolScope, front: ToolFront = {}) {\n const plan = planToolOffer(scope, front)\n const openable = new Map(plan.openable.map(spec => [spec.name, spec]))\n const opening = openable.size > 0 ? openToolsSpec(names => open(names)) : undefined\n const offered = new Map<string, ToolSpec>([...plan.offered, ...(opening ? [opening] : [])].map(spec => [spec.name, spec]))\n const categoryOf = new Map<string, ScopedCategory>(scope.categories.flatMap(category => category.specs.map(spec => [spec.name, category] as [string, ScopedCategory])))\n\n /*\n * The tools of this scope a wrong name most likely meant - the same name after its verb (`readOfficeExpenditureRequest`\n * for `queryOfficeExpenditureRequest`). Only this scope's: a tool the person may not use is never named.\n */\n const meant = (name: string) => {\n const stem = name.replace(/^[a-z]+/, '')\n return stem ? [...openable.keys(), ...offered.keys()].filter(one => one !== name && one.replace(/^[a-z]+/, '') === stem) : []\n }\n\n /*\n * A wrong name among right ones does not hold the right ones back: asked for a card, the model opened the card tool with\n * a read it named wrong, got nothing open, and told the person the tools were not there (2026-10-25). The right ones\n * open; each wrong one is said with what it most likely meant.\n */\n function open(names: string[]) {\n const unknown = [...new Set(names.filter(name => !openable.has(name) && !offered.has(name)))]\n const suggestions = Object.fromEntries(unknown.map(name => [name, meant(name)]).filter(([, near]) => near.length))\n const notInList = unknown.length\n ? `${unknown.join(', ')} ${unknown.length > 1 ? 'are' : 'is'} not in the list of tools you can open` +\n (Object.keys(suggestions).length ? ` - ${Object.entries(suggestions).map(([name, near]) => `for ${name} use ${(near as string[]).join(' or ')}`).join('; ')}` : '') +\n '. Use the exact names from the list.'\n : ''\n if (unknown.length && unknown.length === new Set(names).size) {\n return { rejected: true, error: 'tool-not-offered', names: unknown, ...(Object.keys(suggestions).length ? { suggestions } : {}), message: `${notInList} Call ${OPEN_TOOLS} again now.` }\n }\n names = names.filter(name => !unknown.includes(name))\n const fresh = [...new Set(names)].filter(name => !offered.has(name))\n const already = [...new Set(names)].filter(name => offered.has(name) && name !== OPEN_TOOLS)\n const room = MAX_OFFERED_TOOLS - offered.size\n if (fresh.length > room) {\n return {\n rejected: true,\n error: 'tool-budget-exceeded',\n room,\n message: `only ${room} more tools fit in this answer. Open fewer - the ones this question needs - or answer from what you have read.`\n }\n }\n for (const name of fresh) offered.set(name, openable.get(name)!)\n const guidance = [...new Set(fresh.map(name => categoryOf.get(name)?.guidance).filter(Boolean))]\n const said = already.length\n ? `${already.join(', ')} ${already.length > 1 ? 'are' : 'is'} already open - call ${already.length > 1 ? 'them' : 'it'} now. ${fresh.length ? 'The others are open from your next step on.' : ''}`.trim()\n : 'these tools are open from your next step on. Call them as the question needs.'\n return {\n opened: fresh,\n ...(already.length ? { alreadyOffered: already } : {}),\n ...(unknown.length ? { notOffered: unknown, ...(Object.keys(suggestions).length ? { suggestions } : {}) } : {}),\n ...(guidance.length ? { guidance: guidance.join('\\n') } : {}),\n message: notInList ? `${said} ${notInList} Open it now and go on.` : said\n }\n }\n\n return {\n /** What a call carries now. */\n offered: () => [...offered.values()],\n isOffered: (name: string) => offered.has(name),\n /** In the scope and still to be opened - a call to one is answered with how to open it. */\n isOpenable: (name: string) => openable.has(name) && !offered.has(name),\n /** In the scope and not offered now. */\n openable: () => [...openable.keys()].filter(name => !offered.has(name)),\n /** The guidance of the categories in front - opened ones bring theirs with the result. */\n frontGuidance: () => [...new Set(plan.offered.map(spec => categoryOf.get(spec.name)?.guidance).filter(Boolean) as string[])],\n catalog: () => openableToolCatalog(plan.openable)\n }\n}\n"]}
|
|
@@ -67,6 +67,14 @@ export interface ToolCallContext {
|
|
|
67
67
|
* it; a turn has none. The server's value - never from `host`.
|
|
68
68
|
*/
|
|
69
69
|
run?: UnattendedRunRef;
|
|
70
|
+
/**
|
|
71
|
+
* The stored conversation this turn belongs to - the server's value, never from `host`. A tool that acts on the room
|
|
72
|
+
* itself (inviting someone into it) reads it and asks the room's own rules; absent on a surface with no stored
|
|
73
|
+
* conversation and in an unattended run.
|
|
74
|
+
*/
|
|
75
|
+
conversation?: {
|
|
76
|
+
id: string;
|
|
77
|
+
};
|
|
70
78
|
}
|
|
71
79
|
export interface ToolCallMode {
|
|
72
80
|
kind: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → SceneEditOp[], action 은 (args) → SceneActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (SceneEditOp,\n// SceneActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToSceneEditOp / toolCallToSceneActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── ToolCallContext — what the neutral conversation door hands a registered tool (A3) ──\n\n/**\n * What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a\n * registered tool when it runs.\n *\n * `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door\n * passed `ctx.host`, so they never saw the space; the two are never merged into one object.\n *\n * The board product's door (board-ai) passes its own shape and is not part of this contract.\n */\nexport interface ToolCallContext {\n /** The request state the server built — domain, user, tx. The only basis for authorization. */\n state: any\n /**\n * The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads\n * an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.\n */\n host: Record<string, any>\n /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */\n mode?: ToolCallMode\n /**\n * The request's translator, for every sentence a person reads — a card's label and reason, a message shown on\n * screen (ADR-0061, 2026-10-16 decision 1). The server builds it in the request's locale; it never comes from\n * `host`. Codes and values stay apart from it. Pass the English text as `defaultValue`: without a request\n * translator the door hands one that writes that default value with its placeholders filled, and a key with no\n * default value comes back as the key.\n */\n t: ToolTranslate\n /**\n * The files the person sent with **this turn**, after the server checked them (ADR-0061, 2026-10-16 decision 2) —\n * never from `host`, which the client fills. `[]` when none were sent.\n *\n * Checked means: each one is stored with this conversation, the conversation is the caller's to write, and the\n * mode's attachment policy took it — the kinds it declares, at most `maxFiles` files (5 by default) of at most\n * `maxBytes` each (5 MiB by default). **Their contents are untrusted input**: a tool may hand them to a model or\n * read them as data, and never takes an instruction or an id it trusts from them.\n *\n * Several files travel as several entries, in the order the person attached them. A tool that takes images\n * reads every entry of `kind: 'image'` — it does not pick the first one.\n */\n attachments: ToolAttachment[]\n /**\n * Set when no person is in this conversation: a scheduled run of the mode, as the caller named it\n * (operato-application/design/ai/unattended-run.md §6). A recorder tells an unattended run's calls from a turn's by\n * it; a turn has none. The server's value - never from `host`.\n */\n run?: UnattendedRunRef\n}\n\nexport interface ToolCallMode {\n kind: string\n target?: string\n}\n\n/** Which unattended run a call belongs to - the stored run's id and the identity its caller gave it. */\nexport interface UnattendedRunRef {\n id: string\n /** Who scheduled it - `<app>.<work>`, e.g. `office.strategy-review`. */\n source: string\n /** What it is about in the source's terms - a policy id. */\n subject: string\n /** The occurrence it is, ISO 8601. */\n scheduledAt: string\n}\n\n/** `t(key, { defaultValue, ...params })` — the request's translator, as i18next's `t` is called. */\nexport type ToolTranslate = (key: string, params?: Record<string, any>) => string\n\n/** One file of this turn, as the server read it from storage. */\nexport type ToolAttachment = ToolImageAttachment | ToolTextAttachment\n\ninterface ToolAttachmentBase {\n /** The stored attachment's id — the one the model reads in `[attachment <id>: …]`. */\n id: string\n /** The file name the person gave. Untrusted text. */\n name: string\n /** The stored media type. */\n mediaType: string\n /** Bytes as stored. */\n size: number\n}\n\n/** An `image/*` file, its bytes as stored. */\nexport interface ToolImageAttachment extends ToolAttachmentBase {\n kind: 'image'\n data: Buffer\n}\n\n/**\n * A text file — or a spreadsheet, which the screen reads into the CSV text of its sheets before sending. `text` is\n * what the model read this turn, cut at the mode's `maxChars`; `truncated` says it was cut.\n */\nexport interface ToolTextAttachment extends ToolAttachmentBase {\n kind: 'text'\n text: string\n truncated: boolean\n}\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToSceneEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToSceneActionOp)\n * 가 자기 SceneActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n /**\n * Which GraphQL doors this tool reaches — `['Query.twinNow']`,\n * `['Query.productionSummary', 'Query.pendingSummary']`.\n *\n * **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,\n * only plant's tools asked whether the caller may read what they read; twin's and figure's\n * checked the tenant and nothing else, so holding the conversation's own privilege was enough to\n * reach their business data.\n *\n * **Always a list, even for one** — one shape to read. A tool that answers from several doors\n * needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):\n * plant's factory overview answers from two, and asking for only one of them would hand back\n * half of what the caller may not see.\n *\n * **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its\n * own copy of what its door asks; the copy went stale the day a door changed its declaration, and\n * the looser of the two won. Now the gate reads the door's own declaration from AUTH's field\n * table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same\n * answer, one declaration.\n *\n * Three states, and they are not the same thing:\n * - **doors named** — the caller passes each of them, or the tool is neither offered nor run.\n * - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface\n * sent (`getOpenScene`) are this: there is nothing behind them to gate.\n * - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every\n * conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it\n * would be hidden from every reader including its author (ADR-0073), so opening it would\n * produce a tool whose answers no one can read.\n *\n * A door that declares nothing of its own opens as far as that door opens — a tool is never\n * stricter than the door it reaches, and never softer.\n */\n doors?: string[]\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n /**\n * 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.\n *\n * 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시\n * 불러야 하는지\" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지\n * 않고 \"제안해 두었습니다\" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.\n *\n * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의\n * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.\n *\n * ── 왜 필수인가 (2026-09-09) ──────────────────────────────────────────────\n * 선택 필드였다. 그래서 위에 적힌 사고를 아는 사람만 채웠고, 세 제품이 각자 도구를 붙이기\n * 시작한 지금 **채운 넷과 안 채운 하나**가 있었다.\n *\n * 안 채운 쪽은 조용하다 — 등록은 성공하고 부팅도 성공하고, 모델이 도구를 덜 부르거나 지어낸\n * 뒤에 사람이 화면을 보고 안다. **두 개를 기억해야 성립하는 규칙은 다음 새 카테고리까지만\n * 간다.** 그래서 기억이 아니라 등록이 거절한다(§`registerToolCategory`).\n *\n * 빈 문자열도 거절한다. 「없음」을 「적었다」로 위장하는 값이 통과하면 이 강제가 형식이 된다.\n */\n guidance: string\n}\n"]}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → SceneEditOp[], action 은 (args) → SceneActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (SceneEditOp,\n// SceneActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToSceneEditOp / toolCallToSceneActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── ToolCallContext — what the neutral conversation door hands a registered tool (A3) ──\n\n/**\n * What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a\n * registered tool when it runs.\n *\n * `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door\n * passed `ctx.host`, so they never saw the space; the two are never merged into one object.\n *\n * The board product's door (board-ai) passes its own shape and is not part of this contract.\n */\nexport interface ToolCallContext {\n /** The request state the server built — domain, user, tx. The only basis for authorization. */\n state: any\n /**\n * The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads\n * an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.\n */\n host: Record<string, any>\n /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */\n mode?: ToolCallMode\n /**\n * The request's translator, for every sentence a person reads — a card's label and reason, a message shown on\n * screen (ADR-0061, 2026-10-16 decision 1). The server builds it in the request's locale; it never comes from\n * `host`. Codes and values stay apart from it. Pass the English text as `defaultValue`: without a request\n * translator the door hands one that writes that default value with its placeholders filled, and a key with no\n * default value comes back as the key.\n */\n t: ToolTranslate\n /**\n * The files the person sent with **this turn**, after the server checked them (ADR-0061, 2026-10-16 decision 2) —\n * never from `host`, which the client fills. `[]` when none were sent.\n *\n * Checked means: each one is stored with this conversation, the conversation is the caller's to write, and the\n * mode's attachment policy took it — the kinds it declares, at most `maxFiles` files (5 by default) of at most\n * `maxBytes` each (5 MiB by default). **Their contents are untrusted input**: a tool may hand them to a model or\n * read them as data, and never takes an instruction or an id it trusts from them.\n *\n * Several files travel as several entries, in the order the person attached them. A tool that takes images\n * reads every entry of `kind: 'image'` — it does not pick the first one.\n */\n attachments: ToolAttachment[]\n /**\n * Set when no person is in this conversation: a scheduled run of the mode, as the caller named it\n * (operato-application/design/ai/unattended-run.md §6). A recorder tells an unattended run's calls from a turn's by\n * it; a turn has none. The server's value - never from `host`.\n */\n run?: UnattendedRunRef\n /**\n * The stored conversation this turn belongs to - the server's value, never from `host`. A tool that acts on the room\n * itself (inviting someone into it) reads it and asks the room's own rules; absent on a surface with no stored\n * conversation and in an unattended run.\n */\n conversation?: { id: string }\n}\n\nexport interface ToolCallMode {\n kind: string\n target?: string\n}\n\n/** Which unattended run a call belongs to - the stored run's id and the identity its caller gave it. */\nexport interface UnattendedRunRef {\n id: string\n /** Who scheduled it - `<app>.<work>`, e.g. `office.strategy-review`. */\n source: string\n /** What it is about in the source's terms - a policy id. */\n subject: string\n /** The occurrence it is, ISO 8601. */\n scheduledAt: string\n}\n\n/** `t(key, { defaultValue, ...params })` — the request's translator, as i18next's `t` is called. */\nexport type ToolTranslate = (key: string, params?: Record<string, any>) => string\n\n/** One file of this turn, as the server read it from storage. */\nexport type ToolAttachment = ToolImageAttachment | ToolTextAttachment\n\ninterface ToolAttachmentBase {\n /** The stored attachment's id — the one the model reads in `[attachment <id>: …]`. */\n id: string\n /** The file name the person gave. Untrusted text. */\n name: string\n /** The stored media type. */\n mediaType: string\n /** Bytes as stored. */\n size: number\n}\n\n/** An `image/*` file, its bytes as stored. */\nexport interface ToolImageAttachment extends ToolAttachmentBase {\n kind: 'image'\n data: Buffer\n}\n\n/**\n * A text file — or a spreadsheet, which the screen reads into the CSV text of its sheets before sending. `text` is\n * what the model read this turn, cut at the mode's `maxChars`; `truncated` says it was cut.\n */\nexport interface ToolTextAttachment extends ToolAttachmentBase {\n kind: 'text'\n text: string\n truncated: boolean\n}\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToSceneEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToSceneActionOp)\n * 가 자기 SceneActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n /**\n * Which GraphQL doors this tool reaches — `['Query.twinNow']`,\n * `['Query.productionSummary', 'Query.pendingSummary']`.\n *\n * **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,\n * only plant's tools asked whether the caller may read what they read; twin's and figure's\n * checked the tenant and nothing else, so holding the conversation's own privilege was enough to\n * reach their business data.\n *\n * **Always a list, even for one** — one shape to read. A tool that answers from several doors\n * needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):\n * plant's factory overview answers from two, and asking for only one of them would hand back\n * half of what the caller may not see.\n *\n * **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its\n * own copy of what its door asks; the copy went stale the day a door changed its declaration, and\n * the looser of the two won. Now the gate reads the door's own declaration from AUTH's field\n * table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same\n * answer, one declaration.\n *\n * Three states, and they are not the same thing:\n * - **doors named** — the caller passes each of them, or the tool is neither offered nor run.\n * - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface\n * sent (`getOpenScene`) are this: there is nothing behind them to gate.\n * - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every\n * conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it\n * would be hidden from every reader including its author (ADR-0073), so opening it would\n * produce a tool whose answers no one can read.\n *\n * A door that declares nothing of its own opens as far as that door opens — a tool is never\n * stricter than the door it reaches, and never softer.\n */\n doors?: string[]\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n /**\n * 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.\n *\n * 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시\n * 불러야 하는지\" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지\n * 않고 \"제안해 두었습니다\" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.\n *\n * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의\n * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.\n *\n * ── 왜 필수인가 (2026-09-09) ──────────────────────────────────────────────\n * 선택 필드였다. 그래서 위에 적힌 사고를 아는 사람만 채웠고, 세 제품이 각자 도구를 붙이기\n * 시작한 지금 **채운 넷과 안 채운 하나**가 있었다.\n *\n * 안 채운 쪽은 조용하다 — 등록은 성공하고 부팅도 성공하고, 모델이 도구를 덜 부르거나 지어낸\n * 뒤에 사람이 화면을 보고 안다. **두 개를 기억해야 성립하는 규칙은 다음 새 카테고리까지만\n * 간다.** 그래서 기억이 아니라 등록이 거절한다(§`registerToolCategory`).\n *\n * 빈 문자열도 거절한다. 「없음」을 「적었다」로 위장하는 값이 통과하면 이 강제가 형식이 된다.\n */\n guidance: string\n}\n"]}
|