@things-factory/ai-client-base 10.1.28 → 10.1.29

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.
@@ -41,6 +41,7 @@ const tslib_1 = require("tslib");
41
41
  * 입력 — `chat` 함수와 dispatch helper 들을 의존성 주입. 부르는 쪽은 본 모듈에 의존하지만 본
42
42
  * 모듈은 부르는 쪽의 어떤 import 도 모름.
43
43
  */
44
+ const node_crypto_1 = require("node:crypto");
44
45
  const debug_1 = tslib_1.__importDefault(require("debug"));
45
46
  const index_js_1 = require("../utils/index.js");
46
47
  const log = (0, debug_1.default)('things-factory:ai:agentic-loop');
@@ -231,7 +232,18 @@ function proposalEffectKey(p) {
231
232
  if (!presentation.has(name))
232
233
  effect[name] = value;
233
234
  }
234
- return `${p?.tool ?? ''}|${stableStringify(effect)}`;
235
+ /*
236
+ * ── 왜 인자를 접어 넣나 ───────────────────────────────────────────────────
237
+ * 이 키는 **사람이 읽는 자리로 나간다.** 호스트가 제안의 결말을 알리면 서버가 그것을 system 행으로
238
+ * 적고(`formatProposalOutcomeLine`), 그 행이 대화에 남는다. 인자를 그대로 이어 붙였더니 트윈
239
+ * 모델러에서 이런 줄이 남았다 — `proposal proposeSceneEdit({"kind":"scene-edit","ops":[{"component":
240
+ * {"height":180,"left":100,…`. 새로 고쳐도 그대로다(2026-09-18, Twin 레인 :3000).
241
+ *
242
+ * 도구 이름은 남긴다 — 무엇에 대한 줄인지는 사람도 모델도 읽어야 한다. 인자는 같고 다름만 가리면
243
+ * 되므로 짧은 요약으로 접는다. 키가 하는 일은 그것뿐이다.
244
+ */
245
+ const digest = (0, node_crypto_1.createHash)('sha1').update(stableStringify(effect)).digest('hex').slice(0, 12);
246
+ return `${p?.tool ?? ''}#${digest}`;
235
247
  }
236
248
  /** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */
237
249
  function stableStringify(value) {
@@ -1 +1 @@
1
- {"version":3,"file":"agentic-loop.js","sourceRoot":"","sources":["../../../server/service/agentic-loop/agentic-loop.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * agentic-loop — multi-turn LLM ↔ tool 루프. **도메인 중립.**\n *\n * ── 왜 여기로 왔나 (2026-09-05) ─────────────────────────────────────────────\n * 같은 모양의 루프가 두 벌로 돌고 있었다(board-ai · twin-ai). 그리고 **안전 장치가 서로 반대로\n * 갈려 있었다.**\n *\n * board-ai AbortSignal · 반복실패 조기종료 · iter 로그 접지 가드 없음\n * twin-ai 접지 가드(checkGrounding) 중단 없음 · 조기종료 없음\n *\n * 어느 쪽도 둘 다 갖고 있지 않았다. 새 보호 장치가 들어올 때마다 둘 중 아무 데나 우연히\n * 떨어졌다는 뜻이고, 그래서 **같은 질문을 어느 문으로 던지느냐에 따라 안전이 달랐다** — 그 값을\n * 이미 한 번 치른 기록이 `board-ai/…/grounding.ts` 머리에 있다.\n *\n * 중복이 아니라 결함이다. 그래서 한 곳으로 모은다.\n *\n * 원래 board-ai 에 있던 파일이고, 그 머리에 「향후 split 의 자연 경계」라고 적혀 있었다 — 옮기라고\n * 지어진 자리였다.\n *\n * ── 도메인을 어떻게 뺐나 ────────────────────────────────────────────────────\n * 타입 매개변수 둘이다.\n *\n * TCtx 도구가 알아야 하는 것 한 뭉치. 루프는 **들여다보지 않고 나르기만** 한다\n * TOp 쌓이는 편집 op. 루프는 모양을 모른다\n *\n * 전에는 루프가 `currentBoard?.components` 로 문서 속을 한 번 들여다봤다. 그 한 줄이 「board 모델은\n * 이렇게 생겼다」를 이 파일에 심고 있었다. 검증기가 자기 문맥에서 꺼내 쓰도록 옮겼다 — 이제 이\n * 파일은 문서의 **모양을 모른다.**\n *\n * loop 자체를 *순수 함수* 로 만들어:\n * 1. 단위 테스트 가능 (assistant.ts 의 board-import → @aws-sdk transitive 경로 회피)\n * 2. 관측성 (error boundary / iter log / AbortSignal / repeated-failure 조기 종료)\n * 을 한 곳에 응집\n * 3. 향후 split (Phase 3) 의 자연 경계\n *\n * 입력 — `chat` 함수와 dispatch helper 들을 의존성 주입. 부르는 쪽은 본 모듈에 의존하지만 본\n * 모듈은 부르는 쪽의 어떤 import 도 모름.\n */\nimport debug from 'debug'\nimport type { AIMessage, AIToolCall, AIToolChatResult } from '../types.js'\nimport { retryTransient } from '../utils/index.js'\n\nconst log = debug('things-factory:ai:agentic-loop')\n\n// ── Public types ─────────────────────────────────────────────────\n\n/**\n * AI 가 응답을 만드는 동안 호출한 도구의 시간순 trace.\n *\n * UX 목적 — 사용자가 \"왜 이렇게 답했지\" 의문 가질 때 fold-able 박스에서 확인.\n * 디버그용 + 신뢰도 향상.\n */\nexport interface ToolUsage {\n /** 도구 이름 (예: 'getSelection', 'modifyBoard', 'addComponent') */\n name: string\n /** 호출 인자 JSON */\n arguments: any\n /** 도구 결과 — read tool 은 실제 값 (truncated 가능), write tool 은 queued 마커 */\n result: any\n /** read tool / write tool 분류 */\n kind: 'read' | 'write' | 'unknown'\n /**\n * 몇 번째 LLM 턴에서 불렀는가(0부터). 판단 과정을 단계로 읽을 수 있게 한다 —\n * \"조회 → 거절 → 정정 → 제안\" 이 한 턴 안의 일인지 여러 턴에 걸친 일인지가 진단의 절반이다.\n */\n iter?: number\n /**\n * 이 호출이 **어떻게 끝났는가** — 화면이 배지로 보여줄 판정.\n * ok 조회·실행이 정상 완료\n * rejected 도구가 거절(인자 누락·대상 없음 등). 무엇이 빠졌는지는 result 에 있다.\n * queued 보드 편집처럼 클라이언트에서 적용될 예정\n * proposed 조치 제안이 만들어졌다(실행 아님 — 사용자가 버튼을 누른다)\n * folded 같은 효과의 제안이 이미 있어 접혔다. **조용히 버리지 않는다** — 접힌 사실이 보여야\n * \"왜 카드가 하나뿐인가\" 를 추적할 수 있다.\n * error 알 수 없는 도구·검증 실패\n */\n outcome?: 'ok' | 'rejected' | 'queued' | 'proposed' | 'folded' | 'error'\n}\n\n/**\n * Loop 가 호출하는 dispatch 함수들. 모두 의존성 주입.\n *\n * assistant.ts 가 자기 자신의 함수 (isReadTool, executeReadTool 등) 를 그대로\n * 넘겨주는 구조. 본 모듈은 이 함수들의 *시그니처* 만 알면 됨.\n */\nexport interface ToolDispatchHelpers<TCtx = unknown, TOp = unknown> {\n isReadTool: (name: string) => boolean\n isWriteTool: (name: string) => boolean\n isActionTool: (name: string) => boolean\n /** read/external 도구 실행. **Promise 를 반환할 수 있다** — 호출부는 반드시 await 한다. */\n executeReadTool: (tc: AIToolCall, context: TCtx) => any | Promise<any>\n /**\n * 쓰기 도구 호출이 유효한가.\n *\n * **문맥을 통째로 받는다** — 전에는 루프가 `currentBoard?.components` 를 꺼내 넘겼고, 그 한 줄이\n * 문서의 모양을 이 파일에 심고 있었다. 꺼내는 일은 검증기 자신이 한다.\n */\n validateWriteToolCall: (tc: AIToolCall, context: TCtx) => { valid: boolean; errors?: any[]; suggestion?: string }\n /** 도구 호출을 쌓아 둘 op 로. 모양은 부르는 쪽의 것이다 — 루프는 나르기만 한다. */\n toolCallToOp: (tc: AIToolCall) => TOp | null\n summarizeToolResult: (value: any) => any\n}\n\nexport interface AgenticLoopOptions {\n systemPrompt: string\n model?: string\n temperature?: number\n maxTokens?: number\n /** 최대 iteration. 기본 8. */\n maxIterations?: number\n /**\n * 사용자 abort 신호. iteration 시작 직전 / chat() 호출 후 체크.\n * 이미 abort 된 신호로 들어오면 첫 iteration 도 안 돔.\n */\n signal?: AbortSignal\n /**\n * 같은 tool 의 validation 실패가 N회 연속이면 조기 종료. 기본 3.\n * LLM 이 같은 잘못을 무한 반복하는 경우 방어.\n */\n repeatedFailureLimit?: number\n /**\n * **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).\n *\n * 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다\n * (\"지게차들이 바쁘게 움직이고 있다\" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한\n * 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할\n * 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.\n *\n * 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.\n */\n requireToolOnFirstTurn?: boolean\n /**\n * **한도에 닿아도 알아낸 것을 버리지 않는다.** 기본 꺼짐.\n *\n * ── 왜 (twin-ai 에서 옮겨 옴, 2026-08-30 → 2026-09-05) ──────────────────────\n * 예전에는 한도에 닿으면 「질문을 좁혀 달라」 한 줄만 냈다. 그때까지 여러 번 조회해서 얻은\n * 사실이 있는데도 전부 버렸다 — 사용자는 기다린 시간만 쓰고 아무것도 못 받는다.\n *\n * 켜면 마지막으로 한 번 더 부른다. **이번에는 도구를 안 준다**(`toolChoice: 'none'`) — 그래야\n * 모델이 더 조회하지 않고 지금까지 받은 것으로 답한다. 못 채운 것이 있으면 그것을 말하게 한다.\n *\n * 그 호출마저 실패하면 원래대로 `max_iterations` 로 끝난다 — 마지막 바닥은 그대로다.\n *\n * ── 왜 루프가 하나 ─────────────────────────────────────────────────────────\n * 이 문제는 도메인과 무관하다. 어느 대화면이든 한도는 있고, 한도에 닿으면 같은 손실이 난다.\n * 두 곳에 두면 한 곳만 갖게 되고 — 실제로 그랬다(twin-ai 만 갖고 board-ai 는 없었다).\n */\n salvageOnMaxIterations?: boolean\n /**\n * 마무리 호출에 덧붙일 지시. 없으면 아래 기본 문장을 쓴다.\n *\n * 기본 문장은 도메인 중립이다 — 대화면이 자기 어휘로 바꾸고 싶으면 여기에 준다.\n */\n salvagePrompt?: string\n}\n\nexport interface AgenticLoopInput<TCtx = unknown, TOp = unknown> {\n initialConversation: AIMessage[]\n tools: any[]\n options: AgenticLoopOptions\n /** LLM provider 의 chatWithTools 위임. provider-specific 구현. */\n chat: (\n messages: AIMessage[],\n tools: any[],\n options: any\n ) => Promise<AIToolChatResult>\n dispatch: ToolDispatchHelpers<TCtx, TOp>\n /** 도구가 알아야 하는 것 한 뭉치 — 루프는 들여다보지 않고 나른다. */\n context: TCtx\n}\n\nexport interface AgenticLoopResult<TOp = unknown> {\n lastText: string | undefined\n accumulatedWriteCalls: AIToolCall[]\n accumulatedActions: TOp[]\n toolUsages: ToolUsage[]\n /**\n * LLM 에게 실제로 회신된 도구 결과 문자열(시간순) — **접지 판정의 근거집합**.\n *\n * `toolUsages[].result` 를 쓰면 안 된다: 그건 UI 표시용으로 압축된(잘린) 값이라 근거로 삼으면\n * 잘려 나간 식별자를 \"근거 없음\" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.\n */\n groundedTexts: string[]\n /**\n * **제안** — 도구가 \"실행하지 않고 제안만 했다\" 고 알린 결과들(시간순).\n *\n * 규약: read/external 도구의 결과에 `proposed === true` 가 있으면 그것은 조치 제안이다. 그 도구가\n * 어느 도메인인지 이 루프는 모른다 — 결과를 그대로 실어 올리고, 해석·실행 확인은 대화면이 한다.\n * 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다(사람이 버튼을 누른다).\n */\n proposals: any[]\n /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */\n stopReason: string\n /**\n * 비정상 종료 사유 — 정상 종료 시 undefined.\n * 호출자 (assistant.ts) 가 사용자 메시지에 반영 (예: '시도 중 오류가 발생했어요').\n */\n abortReason?:\n | { type: 'provider_error'; message: string; iter: number }\n | { type: 'aborted'; iter: number }\n | { type: 'max_iterations'; iter: number }\n | { type: 'repeated_validation_failure'; toolName: string; consecutive: number; iter: number }\n}\n\n// ── Loop ─────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_ITERATIONS = 8\n\n/**\n * The wrap-up call's default instruction — domain neutral, and in English because the reader is the model.\n * The reply it produces is in the language the person wrote in; that is the model's job, not this string's.\n */\nconst DEFAULT_SALVAGE_PROMPT =\n '[wrap up] No more lookups are possible. Answer from the tool output you already have. ' +\n 'State what you found first, then say in one line what is still missing to answer the rest.'\n\n/**\n * multi-turn agentic loop.\n *\n * LLM 이 read tool 을 호출 → 서버에서 즉시 실행 → 결과 회신 → LLM 추론 반복.\n * write tool 은 누적해 client 에 patch 로 전달. 무한 루프 방지를 위해 iteration cap.\n *\n * 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.\n */\nexport async function runAgenticLoop<TCtx = unknown, TOp = unknown>(\n input: AgenticLoopInput<TCtx, TOp>\n): Promise<AgenticLoopResult<TOp>> {\n const {\n initialConversation,\n tools,\n options,\n chat,\n dispatch,\n context\n } = input\n\n const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS\n const failureLimit = options.repeatedFailureLimit ?? 3\n const signal = options.signal\n\n let conversation = initialConversation\n const accumulatedWriteCalls: AIToolCall[] = []\n const accumulatedActions: TOp[] = []\n const toolUsages: ToolUsage[] = []\n /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */\n const groundedTexts: string[] = []\n /* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */\n const proposals: any[] = []\n let lastText: string | undefined\n let stopReason: string = 'end_turn'\n let abortReason: AgenticLoopResult['abortReason']\n\n // 같은 tool 의 validation 실패 연속 카운터 — 다른 tool 호출 시 reset\n let lastFailedToolName: string | undefined\n let consecutiveFailures = 0\n\n let iter = 0\n for (; iter < maxIterations; iter++) {\n // (1) AbortSignal — iteration 시작 시 체크\n if (signal?.aborted) {\n log('iter %d: aborted by signal', iter)\n abortReason = { type: 'aborted', iter }\n break\n }\n\n log('iter %d: calling chat()', iter)\n let result: AIToolChatResult\n try {\n /* (2) Error boundary — provider 예외 시 graceful 종료.\n *\n * 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini\n * 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는\n * 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */\n result = await retryTransient(() => chat(conversation, tools, {\n systemPrompt: options.systemPrompt,\n model: options.model,\n temperature: options.temperature,\n maxTokens: options.maxTokens,\n /* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */\n toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',\n allowParallelToolCalls: true,\n signal\n }), {\n onRetry: ({ attempt, delayMs, error }) =>\n log('iter %d: transient failure, retry %d in %dms: %s', iter, attempt, delayMs, error?.message)\n })\n } catch (e: any) {\n // AbortError — 별도 분류\n if (e?.name === 'AbortError' || signal?.aborted) {\n log('iter %d: provider AbortError', iter)\n abortReason = { type: 'aborted', iter }\n } else {\n log('iter %d: provider error: %s', iter, e?.message)\n abortReason = {\n type: 'provider_error',\n message: e?.message ?? String(e),\n iter\n }\n }\n break\n }\n\n lastText = result.text || lastText\n stopReason = result.stopReason\n\n log(\n 'iter %d: text=%s, toolCalls=%d, stopReason=%s',\n iter,\n result.text ? `\"${result.text.slice(0, 60)}...\"` : '(none)',\n result.toolCalls.length,\n result.stopReason\n )\n\n if (result.toolCalls.length === 0) break\n\n // (1b) 삭제: chat() 직후 abort 는 다음 iter 시작 시 (1) 에서 감지.\n // 현재 iter tool 실행을 중단하면 read tool 결과도 버려져 테스트/동작 불일치.\n\n // tool 결과 빌드 — read 는 즉시 실행, write 는 누적, action 은 별도 채널\n const beforeUsages = toolUsages.length\n const toolResultParts = await processToolCalls(\n result.toolCalls,\n context,\n dispatch,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages,\n proposals,\n iter\n )\n\n /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */\n for (const part of toolResultParts) {\n if (typeof part?.content === 'string') groundedTexts.push(part.content)\n }\n\n // (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort\n const newUsages = toolUsages.slice(beforeUsages)\n const repeatedFailureAbort = detectRepeatedValidationFailure(\n newUsages,\n lastFailedToolName,\n consecutiveFailures,\n failureLimit\n )\n if (repeatedFailureAbort.shouldAbort) {\n log(\n 'iter %d: repeated validation failure on %s (%d consecutive) — abort',\n iter,\n repeatedFailureAbort.toolName,\n repeatedFailureAbort.consecutive\n )\n abortReason = {\n type: 'repeated_validation_failure',\n toolName: repeatedFailureAbort.toolName,\n consecutive: repeatedFailureAbort.consecutive,\n iter\n }\n break\n }\n lastFailedToolName = repeatedFailureAbort.lastFailedToolName\n consecutiveFailures = repeatedFailureAbort.consecutive\n\n // assistant 의 tool_use turn + user 의 tool_result turn 을 conversation 에 누적\n const assistantContent: any[] = []\n if (result.text) assistantContent.push({ type: 'text', text: result.text })\n for (const tc of result.toolCalls) {\n // providerMeta (Gemini thoughtSignature 등) 를 그대로 round-trip — 안 보내면 400.\n assistantContent.push({\n type: 'tool_use',\n id: tc.id,\n name: tc.name,\n arguments: tc.arguments,\n ...(tc.providerMeta && { providerMeta: tc.providerMeta })\n })\n }\n\n conversation = [\n ...conversation,\n { role: 'assistant', content: assistantContent },\n { role: 'user', content: toolResultParts }\n ]\n }\n\n // (3) max iterations 도달 시 사용자에게 표면화\n if (iter >= maxIterations && !abortReason) {\n log('iter %d: max iterations reached', iter)\n abortReason = { type: 'max_iterations', iter }\n\n /*\n * 버리기 전에 한 번 더 — 도구 없이. 실패하면 원래대로 한도 사유만 남는다(바닥은 그대로).\n */\n if (options.salvageOnMaxIterations) {\n try {\n const salvaged = await chat(conversation, tools, {\n systemPrompt: `${options.systemPrompt}\\n\\n${options.salvagePrompt ?? DEFAULT_SALVAGE_PROMPT}`,\n model: options.model,\n temperature: options.temperature,\n maxTokens: options.maxTokens,\n toolChoice: 'none',\n signal\n })\n if (salvaged?.text) {\n lastText = salvaged.text\n stopReason = salvaged.stopReason ?? stopReason\n log('iter %d: salvaged a final answer without tools', iter)\n }\n } catch (e: any) {\n /* 마무리마저 실패했다 — 알리되 삼키지 않는다. 사유는 이미 max_iterations 로 남아 있다. */\n log('iter %d: salvage call failed — %s', iter, e?.message ?? e)\n }\n }\n }\n\n return {\n lastText,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages,\n groundedTexts,\n proposals,\n stopReason,\n abortReason\n }\n}\n\n/**\n * 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).\n *\n * 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로\n * 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.\n */\nexport function proposalEffectKey(p: any): string {\n /*\n * 카드에만 있는 것은 효과가 아니다 — 문구가 다르다고 다른 조치가 되지 않는다. `key` 도 뺀다:\n * 이 함수가 만든 값이 다음 계산의 입력이 되면 키가 키를 낳는다.\n */\n const presentation = new Set(['label', 'reason', 'icon', 'choices', 'key', 'proposed', 'source', 'autoStage'])\n const effect: Record<string, any> = {}\n for (const [name, value] of Object.entries(p ?? {})) {\n if (!presentation.has(name)) effect[name] = value\n }\n return `${p?.tool ?? ''}|${stableStringify(effect)}`\n}\n\n/** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */\nfunction stableStringify(value: any): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`\n const keys = Object.keys(value).sort()\n return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`\n}\n\n/**\n * 같은 tool 의 validation 실패 연속 감지 — pure helper.\n *\n * 같은 tool 이 반복 fail 하면 LLM 이 self-correct 못 하는 상태.\n * 다른 tool 이 fail 하면 시퀀스 새로 시작 (전혀 다른 패턴이라).\n * tool 이 succeed 하면 카운터 reset.\n */\nfunction detectRepeatedValidationFailure(\n newUsages: ToolUsage[],\n lastFailedToolName: string | undefined,\n consecutiveFailures: number,\n limit: number\n): {\n shouldAbort: boolean\n toolName: string\n consecutive: number\n lastFailedToolName: string | undefined\n} {\n // 이번 iteration 에서 실패한 write tool 들 (validation 거절)\n const failed = newUsages.filter(\n u => u.kind === 'write' && (u.result as any)?.error === 'Tool args failed validation'\n )\n\n if (failed.length === 0) {\n // 실패 없음 — reset\n return {\n shouldAbort: false,\n toolName: '',\n consecutive: 0,\n lastFailedToolName: undefined\n }\n }\n\n // 모든 실패가 같은 tool 인지 확인\n const firstName = failed[0].name\n const allSame = failed.every(u => u.name === firstName)\n\n if (allSame && firstName === lastFailedToolName) {\n const consecutive = consecutiveFailures + 1\n return {\n shouldAbort: consecutive >= limit,\n toolName: firstName,\n consecutive,\n lastFailedToolName: firstName\n }\n }\n\n // 다른 tool 또는 첫 실패\n return {\n shouldAbort: false,\n toolName: firstName,\n consecutive: 1,\n lastFailedToolName: firstName\n }\n}\n\n/**\n * 한 iteration 의 tool 결과를 분류·실행·누적. (loop 에서 분리해 읽기 쉽게.)\n */\nasync function processToolCalls<TCtx, TOp>(\n toolCalls: AIToolCall[],\n context: TCtx,\n dispatch: ToolDispatchHelpers<TCtx, TOp>,\n accumulatedWriteCalls: AIToolCall[],\n accumulatedActions: TOp[],\n toolUsages: ToolUsage[],\n proposals: any[],\n /** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */\n iter: number\n): Promise<any[]> {\n const toolResultParts: any[] = []\n\n for (const tc of toolCalls) {\n if (dispatch.isReadTool(tc.name)) {\n /* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).\n * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다\n * (문서 가져오기·트윈 구조 조회 등 — 무엇인지는 부르는 쪽이 안다). */\n const value = await dispatch.executeReadTool(tc, context)\n /* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).\n * 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */\n /* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */\n let outcome: ToolUsage['outcome'] = 'ok'\n if (value && typeof value === 'object' && (value as any).proposed === true) {\n /* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가\n * 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.\n * 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */\n const candidate = { tool: tc.name, ...(value as any) }\n const key = proposalEffectKey(candidate)\n if (proposals.some(p => p.key === key)) {\n outcome = 'folded' // 접힌 사실을 남긴다 — \"왜 카드가 하나뿐인가\" 가 추적 가능해야 한다\n } else {\n /* 키를 함께 실어 보낸다 — 카드가 같은 규칙으로 다시 세지 않게(원본은 하나). */\n proposals.push({ ...candidate, key })\n outcome = 'proposed'\n }\n } else if (value && typeof value === 'object' && ((value as any).rejected === true || (value as any).error)) {\n outcome = 'rejected'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(value)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: dispatch.summarizeToolResult(value),\n kind: 'read',\n iter,\n outcome\n })\n } else if (dispatch.isWriteTool(tc.name)) {\n // 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.\n // 실패 시 LLM 한테 detailed error 반환 → 다음 turn 에서 자기 회복 (retry).\n const validation = dispatch.validateWriteToolCall(tc, context)\n if (!validation.valid) {\n const errorResult = {\n error: 'Tool args failed validation',\n issues: validation.errors,\n suggestion: validation.suggestion,\n note: 'Fix the args and call the tool again.'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errorResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errorResult,\n kind: 'write',\n iter,\n outcome: 'rejected'\n })\n // continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.\n } else {\n accumulatedWriteCalls.push(tc)\n const queuedResult = { queued: true, note: 'Will be applied on the client.' }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write',\n iter,\n outcome: 'queued'\n })\n }\n } else if (dispatch.isActionTool(tc.name)) {\n // ephemeral scene 조작 — accumulatedActions 로 누적, ChatResponse.actions 로 client 전달\n const action = dispatch.toolCallToOp(tc)\n if (action) accumulatedActions.push(action)\n const queuedResult = {\n queued: true,\n note: 'Will be executed on the modeller (ephemeral).'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write',\n iter,\n outcome: 'queued'\n })\n } else {\n const errResult = { error: `Unknown tool: ${tc.name}` }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errResult),\n isError: true\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errResult,\n kind: 'unknown',\n iter,\n outcome: 'error'\n })\n }\n }\n\n return toolResultParts\n}\n"]}
1
+ {"version":3,"file":"agentic-loop.js","sourceRoot":"","sources":["../../../server/service/agentic-loop/agentic-loop.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * agentic-loop — multi-turn LLM ↔ tool 루프. **도메인 중립.**\n *\n * ── 왜 여기로 왔나 (2026-09-05) ─────────────────────────────────────────────\n * 같은 모양의 루프가 두 벌로 돌고 있었다(board-ai · twin-ai). 그리고 **안전 장치가 서로 반대로\n * 갈려 있었다.**\n *\n * board-ai AbortSignal · 반복실패 조기종료 · iter 로그 접지 가드 없음\n * twin-ai 접지 가드(checkGrounding) 중단 없음 · 조기종료 없음\n *\n * 어느 쪽도 둘 다 갖고 있지 않았다. 새 보호 장치가 들어올 때마다 둘 중 아무 데나 우연히\n * 떨어졌다는 뜻이고, 그래서 **같은 질문을 어느 문으로 던지느냐에 따라 안전이 달랐다** — 그 값을\n * 이미 한 번 치른 기록이 `board-ai/…/grounding.ts` 머리에 있다.\n *\n * 중복이 아니라 결함이다. 그래서 한 곳으로 모은다.\n *\n * 원래 board-ai 에 있던 파일이고, 그 머리에 「향후 split 의 자연 경계」라고 적혀 있었다 — 옮기라고\n * 지어진 자리였다.\n *\n * ── 도메인을 어떻게 뺐나 ────────────────────────────────────────────────────\n * 타입 매개변수 둘이다.\n *\n * TCtx 도구가 알아야 하는 것 한 뭉치. 루프는 **들여다보지 않고 나르기만** 한다\n * TOp 쌓이는 편집 op. 루프는 모양을 모른다\n *\n * 전에는 루프가 `currentBoard?.components` 로 문서 속을 한 번 들여다봤다. 그 한 줄이 「board 모델은\n * 이렇게 생겼다」를 이 파일에 심고 있었다. 검증기가 자기 문맥에서 꺼내 쓰도록 옮겼다 — 이제 이\n * 파일은 문서의 **모양을 모른다.**\n *\n * loop 자체를 *순수 함수* 로 만들어:\n * 1. 단위 테스트 가능 (assistant.ts 의 board-import → @aws-sdk transitive 경로 회피)\n * 2. 관측성 (error boundary / iter log / AbortSignal / repeated-failure 조기 종료)\n * 을 한 곳에 응집\n * 3. 향후 split (Phase 3) 의 자연 경계\n *\n * 입력 — `chat` 함수와 dispatch helper 들을 의존성 주입. 부르는 쪽은 본 모듈에 의존하지만 본\n * 모듈은 부르는 쪽의 어떤 import 도 모름.\n */\nimport { createHash } from 'node:crypto'\n\nimport debug from 'debug'\nimport type { AIMessage, AIToolCall, AIToolChatResult } from '../types.js'\nimport { retryTransient } from '../utils/index.js'\n\nconst log = debug('things-factory:ai:agentic-loop')\n\n// ── Public types ─────────────────────────────────────────────────\n\n/**\n * AI 가 응답을 만드는 동안 호출한 도구의 시간순 trace.\n *\n * UX 목적 — 사용자가 \"왜 이렇게 답했지\" 의문 가질 때 fold-able 박스에서 확인.\n * 디버그용 + 신뢰도 향상.\n */\nexport interface ToolUsage {\n /** 도구 이름 (예: 'getSelection', 'modifyBoard', 'addComponent') */\n name: string\n /** 호출 인자 JSON */\n arguments: any\n /** 도구 결과 — read tool 은 실제 값 (truncated 가능), write tool 은 queued 마커 */\n result: any\n /** read tool / write tool 분류 */\n kind: 'read' | 'write' | 'unknown'\n /**\n * 몇 번째 LLM 턴에서 불렀는가(0부터). 판단 과정을 단계로 읽을 수 있게 한다 —\n * \"조회 → 거절 → 정정 → 제안\" 이 한 턴 안의 일인지 여러 턴에 걸친 일인지가 진단의 절반이다.\n */\n iter?: number\n /**\n * 이 호출이 **어떻게 끝났는가** — 화면이 배지로 보여줄 판정.\n * ok 조회·실행이 정상 완료\n * rejected 도구가 거절(인자 누락·대상 없음 등). 무엇이 빠졌는지는 result 에 있다.\n * queued 보드 편집처럼 클라이언트에서 적용될 예정\n * proposed 조치 제안이 만들어졌다(실행 아님 — 사용자가 버튼을 누른다)\n * folded 같은 효과의 제안이 이미 있어 접혔다. **조용히 버리지 않는다** — 접힌 사실이 보여야\n * \"왜 카드가 하나뿐인가\" 를 추적할 수 있다.\n * error 알 수 없는 도구·검증 실패\n */\n outcome?: 'ok' | 'rejected' | 'queued' | 'proposed' | 'folded' | 'error'\n}\n\n/**\n * Loop 가 호출하는 dispatch 함수들. 모두 의존성 주입.\n *\n * assistant.ts 가 자기 자신의 함수 (isReadTool, executeReadTool 등) 를 그대로\n * 넘겨주는 구조. 본 모듈은 이 함수들의 *시그니처* 만 알면 됨.\n */\nexport interface ToolDispatchHelpers<TCtx = unknown, TOp = unknown> {\n isReadTool: (name: string) => boolean\n isWriteTool: (name: string) => boolean\n isActionTool: (name: string) => boolean\n /** read/external 도구 실행. **Promise 를 반환할 수 있다** — 호출부는 반드시 await 한다. */\n executeReadTool: (tc: AIToolCall, context: TCtx) => any | Promise<any>\n /**\n * 쓰기 도구 호출이 유효한가.\n *\n * **문맥을 통째로 받는다** — 전에는 루프가 `currentBoard?.components` 를 꺼내 넘겼고, 그 한 줄이\n * 문서의 모양을 이 파일에 심고 있었다. 꺼내는 일은 검증기 자신이 한다.\n */\n validateWriteToolCall: (tc: AIToolCall, context: TCtx) => { valid: boolean; errors?: any[]; suggestion?: string }\n /** 도구 호출을 쌓아 둘 op 로. 모양은 부르는 쪽의 것이다 — 루프는 나르기만 한다. */\n toolCallToOp: (tc: AIToolCall) => TOp | null\n summarizeToolResult: (value: any) => any\n}\n\nexport interface AgenticLoopOptions {\n systemPrompt: string\n model?: string\n temperature?: number\n maxTokens?: number\n /** 최대 iteration. 기본 8. */\n maxIterations?: number\n /**\n * 사용자 abort 신호. iteration 시작 직전 / chat() 호출 후 체크.\n * 이미 abort 된 신호로 들어오면 첫 iteration 도 안 돔.\n */\n signal?: AbortSignal\n /**\n * 같은 tool 의 validation 실패가 N회 연속이면 조기 종료. 기본 3.\n * LLM 이 같은 잘못을 무한 반복하는 경우 방어.\n */\n repeatedFailureLimit?: number\n /**\n * **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).\n *\n * 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다\n * (\"지게차들이 바쁘게 움직이고 있다\" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한\n * 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할\n * 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.\n *\n * 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.\n */\n requireToolOnFirstTurn?: boolean\n /**\n * **한도에 닿아도 알아낸 것을 버리지 않는다.** 기본 꺼짐.\n *\n * ── 왜 (twin-ai 에서 옮겨 옴, 2026-08-30 → 2026-09-05) ──────────────────────\n * 예전에는 한도에 닿으면 「질문을 좁혀 달라」 한 줄만 냈다. 그때까지 여러 번 조회해서 얻은\n * 사실이 있는데도 전부 버렸다 — 사용자는 기다린 시간만 쓰고 아무것도 못 받는다.\n *\n * 켜면 마지막으로 한 번 더 부른다. **이번에는 도구를 안 준다**(`toolChoice: 'none'`) — 그래야\n * 모델이 더 조회하지 않고 지금까지 받은 것으로 답한다. 못 채운 것이 있으면 그것을 말하게 한다.\n *\n * 그 호출마저 실패하면 원래대로 `max_iterations` 로 끝난다 — 마지막 바닥은 그대로다.\n *\n * ── 왜 루프가 하나 ─────────────────────────────────────────────────────────\n * 이 문제는 도메인과 무관하다. 어느 대화면이든 한도는 있고, 한도에 닿으면 같은 손실이 난다.\n * 두 곳에 두면 한 곳만 갖게 되고 — 실제로 그랬다(twin-ai 만 갖고 board-ai 는 없었다).\n */\n salvageOnMaxIterations?: boolean\n /**\n * 마무리 호출에 덧붙일 지시. 없으면 아래 기본 문장을 쓴다.\n *\n * 기본 문장은 도메인 중립이다 — 대화면이 자기 어휘로 바꾸고 싶으면 여기에 준다.\n */\n salvagePrompt?: string\n}\n\nexport interface AgenticLoopInput<TCtx = unknown, TOp = unknown> {\n initialConversation: AIMessage[]\n tools: any[]\n options: AgenticLoopOptions\n /** LLM provider 의 chatWithTools 위임. provider-specific 구현. */\n chat: (\n messages: AIMessage[],\n tools: any[],\n options: any\n ) => Promise<AIToolChatResult>\n dispatch: ToolDispatchHelpers<TCtx, TOp>\n /** 도구가 알아야 하는 것 한 뭉치 — 루프는 들여다보지 않고 나른다. */\n context: TCtx\n}\n\nexport interface AgenticLoopResult<TOp = unknown> {\n lastText: string | undefined\n accumulatedWriteCalls: AIToolCall[]\n accumulatedActions: TOp[]\n toolUsages: ToolUsage[]\n /**\n * LLM 에게 실제로 회신된 도구 결과 문자열(시간순) — **접지 판정의 근거집합**.\n *\n * `toolUsages[].result` 를 쓰면 안 된다: 그건 UI 표시용으로 압축된(잘린) 값이라 근거로 삼으면\n * 잘려 나간 식별자를 \"근거 없음\" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.\n */\n groundedTexts: string[]\n /**\n * **제안** — 도구가 \"실행하지 않고 제안만 했다\" 고 알린 결과들(시간순).\n *\n * 규약: read/external 도구의 결과에 `proposed === true` 가 있으면 그것은 조치 제안이다. 그 도구가\n * 어느 도메인인지 이 루프는 모른다 — 결과를 그대로 실어 올리고, 해석·실행 확인은 대화면이 한다.\n * 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다(사람이 버튼을 누른다).\n */\n proposals: any[]\n /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */\n stopReason: string\n /**\n * 비정상 종료 사유 — 정상 종료 시 undefined.\n * 호출자 (assistant.ts) 가 사용자 메시지에 반영 (예: '시도 중 오류가 발생했어요').\n */\n abortReason?:\n | { type: 'provider_error'; message: string; iter: number }\n | { type: 'aborted'; iter: number }\n | { type: 'max_iterations'; iter: number }\n | { type: 'repeated_validation_failure'; toolName: string; consecutive: number; iter: number }\n}\n\n// ── Loop ─────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_ITERATIONS = 8\n\n/**\n * The wrap-up call's default instruction — domain neutral, and in English because the reader is the model.\n * The reply it produces is in the language the person wrote in; that is the model's job, not this string's.\n */\nconst DEFAULT_SALVAGE_PROMPT =\n '[wrap up] No more lookups are possible. Answer from the tool output you already have. ' +\n 'State what you found first, then say in one line what is still missing to answer the rest.'\n\n/**\n * multi-turn agentic loop.\n *\n * LLM 이 read tool 을 호출 → 서버에서 즉시 실행 → 결과 회신 → LLM 추론 반복.\n * write tool 은 누적해 client 에 patch 로 전달. 무한 루프 방지를 위해 iteration cap.\n *\n * 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.\n */\nexport async function runAgenticLoop<TCtx = unknown, TOp = unknown>(\n input: AgenticLoopInput<TCtx, TOp>\n): Promise<AgenticLoopResult<TOp>> {\n const {\n initialConversation,\n tools,\n options,\n chat,\n dispatch,\n context\n } = input\n\n const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS\n const failureLimit = options.repeatedFailureLimit ?? 3\n const signal = options.signal\n\n let conversation = initialConversation\n const accumulatedWriteCalls: AIToolCall[] = []\n const accumulatedActions: TOp[] = []\n const toolUsages: ToolUsage[] = []\n /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */\n const groundedTexts: string[] = []\n /* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */\n const proposals: any[] = []\n let lastText: string | undefined\n let stopReason: string = 'end_turn'\n let abortReason: AgenticLoopResult['abortReason']\n\n // 같은 tool 의 validation 실패 연속 카운터 — 다른 tool 호출 시 reset\n let lastFailedToolName: string | undefined\n let consecutiveFailures = 0\n\n let iter = 0\n for (; iter < maxIterations; iter++) {\n // (1) AbortSignal — iteration 시작 시 체크\n if (signal?.aborted) {\n log('iter %d: aborted by signal', iter)\n abortReason = { type: 'aborted', iter }\n break\n }\n\n log('iter %d: calling chat()', iter)\n let result: AIToolChatResult\n try {\n /* (2) Error boundary — provider 예외 시 graceful 종료.\n *\n * 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini\n * 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는\n * 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */\n result = await retryTransient(() => chat(conversation, tools, {\n systemPrompt: options.systemPrompt,\n model: options.model,\n temperature: options.temperature,\n maxTokens: options.maxTokens,\n /* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */\n toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',\n allowParallelToolCalls: true,\n signal\n }), {\n onRetry: ({ attempt, delayMs, error }) =>\n log('iter %d: transient failure, retry %d in %dms: %s', iter, attempt, delayMs, error?.message)\n })\n } catch (e: any) {\n // AbortError — 별도 분류\n if (e?.name === 'AbortError' || signal?.aborted) {\n log('iter %d: provider AbortError', iter)\n abortReason = { type: 'aborted', iter }\n } else {\n log('iter %d: provider error: %s', iter, e?.message)\n abortReason = {\n type: 'provider_error',\n message: e?.message ?? String(e),\n iter\n }\n }\n break\n }\n\n lastText = result.text || lastText\n stopReason = result.stopReason\n\n log(\n 'iter %d: text=%s, toolCalls=%d, stopReason=%s',\n iter,\n result.text ? `\"${result.text.slice(0, 60)}...\"` : '(none)',\n result.toolCalls.length,\n result.stopReason\n )\n\n if (result.toolCalls.length === 0) break\n\n // (1b) 삭제: chat() 직후 abort 는 다음 iter 시작 시 (1) 에서 감지.\n // 현재 iter tool 실행을 중단하면 read tool 결과도 버려져 테스트/동작 불일치.\n\n // tool 결과 빌드 — read 는 즉시 실행, write 는 누적, action 은 별도 채널\n const beforeUsages = toolUsages.length\n const toolResultParts = await processToolCalls(\n result.toolCalls,\n context,\n dispatch,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages,\n proposals,\n iter\n )\n\n /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */\n for (const part of toolResultParts) {\n if (typeof part?.content === 'string') groundedTexts.push(part.content)\n }\n\n // (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort\n const newUsages = toolUsages.slice(beforeUsages)\n const repeatedFailureAbort = detectRepeatedValidationFailure(\n newUsages,\n lastFailedToolName,\n consecutiveFailures,\n failureLimit\n )\n if (repeatedFailureAbort.shouldAbort) {\n log(\n 'iter %d: repeated validation failure on %s (%d consecutive) — abort',\n iter,\n repeatedFailureAbort.toolName,\n repeatedFailureAbort.consecutive\n )\n abortReason = {\n type: 'repeated_validation_failure',\n toolName: repeatedFailureAbort.toolName,\n consecutive: repeatedFailureAbort.consecutive,\n iter\n }\n break\n }\n lastFailedToolName = repeatedFailureAbort.lastFailedToolName\n consecutiveFailures = repeatedFailureAbort.consecutive\n\n // assistant 의 tool_use turn + user 의 tool_result turn 을 conversation 에 누적\n const assistantContent: any[] = []\n if (result.text) assistantContent.push({ type: 'text', text: result.text })\n for (const tc of result.toolCalls) {\n // providerMeta (Gemini thoughtSignature 등) 를 그대로 round-trip — 안 보내면 400.\n assistantContent.push({\n type: 'tool_use',\n id: tc.id,\n name: tc.name,\n arguments: tc.arguments,\n ...(tc.providerMeta && { providerMeta: tc.providerMeta })\n })\n }\n\n conversation = [\n ...conversation,\n { role: 'assistant', content: assistantContent },\n { role: 'user', content: toolResultParts }\n ]\n }\n\n // (3) max iterations 도달 시 사용자에게 표면화\n if (iter >= maxIterations && !abortReason) {\n log('iter %d: max iterations reached', iter)\n abortReason = { type: 'max_iterations', iter }\n\n /*\n * 버리기 전에 한 번 더 — 도구 없이. 실패하면 원래대로 한도 사유만 남는다(바닥은 그대로).\n */\n if (options.salvageOnMaxIterations) {\n try {\n const salvaged = await chat(conversation, tools, {\n systemPrompt: `${options.systemPrompt}\\n\\n${options.salvagePrompt ?? DEFAULT_SALVAGE_PROMPT}`,\n model: options.model,\n temperature: options.temperature,\n maxTokens: options.maxTokens,\n toolChoice: 'none',\n signal\n })\n if (salvaged?.text) {\n lastText = salvaged.text\n stopReason = salvaged.stopReason ?? stopReason\n log('iter %d: salvaged a final answer without tools', iter)\n }\n } catch (e: any) {\n /* 마무리마저 실패했다 — 알리되 삼키지 않는다. 사유는 이미 max_iterations 로 남아 있다. */\n log('iter %d: salvage call failed — %s', iter, e?.message ?? e)\n }\n }\n }\n\n return {\n lastText,\n accumulatedWriteCalls,\n accumulatedActions,\n toolUsages,\n groundedTexts,\n proposals,\n stopReason,\n abortReason\n }\n}\n\n/**\n * 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).\n *\n * 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로\n * 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.\n */\nexport function proposalEffectKey(p: any): string {\n /*\n * 카드에만 있는 것은 효과가 아니다 — 문구가 다르다고 다른 조치가 되지 않는다. `key` 도 뺀다:\n * 이 함수가 만든 값이 다음 계산의 입력이 되면 키가 키를 낳는다.\n */\n const presentation = new Set(['label', 'reason', 'icon', 'choices', 'key', 'proposed', 'source', 'autoStage'])\n const effect: Record<string, any> = {}\n for (const [name, value] of Object.entries(p ?? {})) {\n if (!presentation.has(name)) effect[name] = value\n }\n /*\n * ── 왜 인자를 접어 넣나 ───────────────────────────────────────────────────\n * 이 키는 **사람이 읽는 자리로 나간다.** 호스트가 제안의 결말을 알리면 서버가 그것을 system 행으로\n * 적고(`formatProposalOutcomeLine`), 그 행이 대화에 남는다. 인자를 그대로 이어 붙였더니 트윈\n * 모델러에서 이런 줄이 남았다 — `proposal proposeSceneEdit({\"kind\":\"scene-edit\",\"ops\":[{\"component\":\n * {\"height\":180,\"left\":100,…`. 새로 고쳐도 그대로다(2026-09-18, Twin 레인 :3000).\n *\n * 도구 이름은 남긴다 — 무엇에 대한 줄인지는 사람도 모델도 읽어야 한다. 인자는 같고 다름만 가리면\n * 되므로 짧은 요약으로 접는다. 키가 하는 일은 그것뿐이다.\n */\n const digest = createHash('sha1').update(stableStringify(effect)).digest('hex').slice(0, 12)\n return `${p?.tool ?? ''}#${digest}`\n}\n\n/** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */\nfunction stableStringify(value: any): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`\n const keys = Object.keys(value).sort()\n return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`\n}\n\n/**\n * 같은 tool 의 validation 실패 연속 감지 — pure helper.\n *\n * 같은 tool 이 반복 fail 하면 LLM 이 self-correct 못 하는 상태.\n * 다른 tool 이 fail 하면 시퀀스 새로 시작 (전혀 다른 패턴이라).\n * tool 이 succeed 하면 카운터 reset.\n */\nfunction detectRepeatedValidationFailure(\n newUsages: ToolUsage[],\n lastFailedToolName: string | undefined,\n consecutiveFailures: number,\n limit: number\n): {\n shouldAbort: boolean\n toolName: string\n consecutive: number\n lastFailedToolName: string | undefined\n} {\n // 이번 iteration 에서 실패한 write tool 들 (validation 거절)\n const failed = newUsages.filter(\n u => u.kind === 'write' && (u.result as any)?.error === 'Tool args failed validation'\n )\n\n if (failed.length === 0) {\n // 실패 없음 — reset\n return {\n shouldAbort: false,\n toolName: '',\n consecutive: 0,\n lastFailedToolName: undefined\n }\n }\n\n // 모든 실패가 같은 tool 인지 확인\n const firstName = failed[0].name\n const allSame = failed.every(u => u.name === firstName)\n\n if (allSame && firstName === lastFailedToolName) {\n const consecutive = consecutiveFailures + 1\n return {\n shouldAbort: consecutive >= limit,\n toolName: firstName,\n consecutive,\n lastFailedToolName: firstName\n }\n }\n\n // 다른 tool 또는 첫 실패\n return {\n shouldAbort: false,\n toolName: firstName,\n consecutive: 1,\n lastFailedToolName: firstName\n }\n}\n\n/**\n * 한 iteration 의 tool 결과를 분류·실행·누적. (loop 에서 분리해 읽기 쉽게.)\n */\nasync function processToolCalls<TCtx, TOp>(\n toolCalls: AIToolCall[],\n context: TCtx,\n dispatch: ToolDispatchHelpers<TCtx, TOp>,\n accumulatedWriteCalls: AIToolCall[],\n accumulatedActions: TOp[],\n toolUsages: ToolUsage[],\n proposals: any[],\n /** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */\n iter: number\n): Promise<any[]> {\n const toolResultParts: any[] = []\n\n for (const tc of toolCalls) {\n if (dispatch.isReadTool(tc.name)) {\n /* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).\n * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다\n * (문서 가져오기·트윈 구조 조회 등 — 무엇인지는 부르는 쪽이 안다). */\n const value = await dispatch.executeReadTool(tc, context)\n /* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).\n * 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */\n /* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */\n let outcome: ToolUsage['outcome'] = 'ok'\n if (value && typeof value === 'object' && (value as any).proposed === true) {\n /* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가\n * 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.\n * 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */\n const candidate = { tool: tc.name, ...(value as any) }\n const key = proposalEffectKey(candidate)\n if (proposals.some(p => p.key === key)) {\n outcome = 'folded' // 접힌 사실을 남긴다 — \"왜 카드가 하나뿐인가\" 가 추적 가능해야 한다\n } else {\n /* 키를 함께 실어 보낸다 — 카드가 같은 규칙으로 다시 세지 않게(원본은 하나). */\n proposals.push({ ...candidate, key })\n outcome = 'proposed'\n }\n } else if (value && typeof value === 'object' && ((value as any).rejected === true || (value as any).error)) {\n outcome = 'rejected'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(value)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: dispatch.summarizeToolResult(value),\n kind: 'read',\n iter,\n outcome\n })\n } else if (dispatch.isWriteTool(tc.name)) {\n // 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.\n // 실패 시 LLM 한테 detailed error 반환 → 다음 turn 에서 자기 회복 (retry).\n const validation = dispatch.validateWriteToolCall(tc, context)\n if (!validation.valid) {\n const errorResult = {\n error: 'Tool args failed validation',\n issues: validation.errors,\n suggestion: validation.suggestion,\n note: 'Fix the args and call the tool again.'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errorResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errorResult,\n kind: 'write',\n iter,\n outcome: 'rejected'\n })\n // continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.\n } else {\n accumulatedWriteCalls.push(tc)\n const queuedResult = { queued: true, note: 'Will be applied on the client.' }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write',\n iter,\n outcome: 'queued'\n })\n }\n } else if (dispatch.isActionTool(tc.name)) {\n // ephemeral scene 조작 — accumulatedActions 로 누적, ChatResponse.actions 로 client 전달\n const action = dispatch.toolCallToOp(tc)\n if (action) accumulatedActions.push(action)\n const queuedResult = {\n queued: true,\n note: 'Will be executed on the modeller (ephemeral).'\n }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(queuedResult)\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: queuedResult,\n kind: 'write',\n iter,\n outcome: 'queued'\n })\n } else {\n const errResult = { error: `Unknown tool: ${tc.name}` }\n toolResultParts.push({\n type: 'tool_result',\n toolUseId: tc.id,\n content: JSON.stringify(errResult),\n isError: true\n })\n toolUsages.push({\n name: tc.name,\n arguments: tc.arguments ?? {},\n result: errResult,\n kind: 'unknown',\n iter,\n outcome: 'error'\n })\n }\n }\n\n return toolResultParts\n}\n"]}
@@ -23,6 +23,17 @@ export interface RegistryAssistantInput {
23
23
  toolAccess?: ToolAccessCheck;
24
24
  /** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
25
25
  hostContext?: any;
26
+ /**
27
+ * Which conversation this is — `{ kind, target }` from the registered mode (ADR-0061 decision 3).
28
+ *
29
+ * Tools see it as `context.mode`. It is the server's value, resolved from the registry before the
30
+ * turn runs, so a tool that behaves differently in two conversations reads it here and not from
31
+ * `host`, which the client fills.
32
+ */
33
+ mode?: {
34
+ kind: string;
35
+ target?: string;
36
+ };
26
37
  /** Caller's koa state (domain, user, tx). Tools that read the database need it. */
27
38
  state: any;
28
39
  model?: string;
@@ -35,7 +35,11 @@ async function registryAssistantChat(input) {
35
35
  */
36
36
  const guidance = (0, tool_scope_js_1.toolScopeGuidance)(scope);
37
37
  const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt;
38
- const toolContext = { state, host: hostContext ?? {} };
38
+ /*
39
+ * `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기
40
+ * 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.
41
+ */
42
+ const toolContext = { state, host: hostContext ?? {}, ...(input.mode ? { mode: input.mode } : {}) };
39
43
  const result = await (0, assistant_chat_js_1.assistantChat)({
40
44
  initialConversation: messages,
41
45
  tools,
@@ -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, 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 /** The request's translator — the stop notice is then in the reader's language. */\n t?: (key: string, params?: Record<string, any>) => string\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 t: input.t\n })\n\n return { ...result, offeredTools: tools.map(tool => tool.name) }\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, 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 /**\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 /** The request's translator — the stop notice is then in the reader's language. */\n t?: (key: string, params?: Record<string, any>) => string\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 /*\n * `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기\n * 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.\n */\n const toolContext: ToolCallContext = { state, host: hostContext ?? {}, ...(input.mode ? { mode: input.mode } : {}) }\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 t: input.t\n })\n\n return { ...result, offeredTools: tools.map(tool => tool.name) }\n}\n"]}
@@ -34,7 +34,7 @@ export interface ToolRegistryWarning {
34
34
  * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so
35
35
  * this is a different fact from saying nothing, and the report keeps the two apart.
36
36
  */
37
- code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege';
37
+ code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege' | 'tool-doors-undeclared';
38
38
  params: {
39
39
  category: string;
40
40
  tool: string;
@@ -59,6 +59,27 @@ function registerToolCategory(category) {
59
59
  * is empty across the apps, this becomes a refusal.
60
60
  */
61
61
  warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name));
62
+ /*
63
+ * ── 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** ─────────────────
64
+ *
65
+ * ADR-0073 뒤로 어시스턴트의 답 행에는 그 답을 만든 도구들이 지난 문이 적힌다. 선언이 없는 도구는
66
+ * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면
67
+ * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.
68
+ *
69
+ * 이 검사를 권한 검사와 따로 도는 이유가 그것이다. 아래 고리는 `privilege` 가 있으면 건너뛰는데,
70
+ * 문을 안 적은 해는 권한을 적었는지와 상관이 없다. 트윈의 저작 도구 넷이 정확히 그 자리였다 —
71
+ * 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.
72
+ */
73
+ for (const spec of category.specs) {
74
+ if (Array.isArray(spec.doors))
75
+ continue;
76
+ warnings.push({
77
+ code: 'tool-doors-undeclared',
78
+ params: { category: category.name, tool: spec.name, kind: spec.kind },
79
+ message: `tool '${spec.name}' in category '${category.name}' declares no doors, so answers built with it ` +
80
+ 'are hidden from every reader including their author — declare `doors`, or `doors: []` when it reaches none'
81
+ });
82
+ }
62
83
  for (const spec of category.specs) {
63
84
  const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege);
64
85
  if (declared)
@@ -78,16 +99,18 @@ function registerToolCategory(category) {
78
99
  warnings.push({
79
100
  code: 'tool-door-has-no-privilege',
80
101
  params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
81
- message: `tool '${spec.name}' reaches ${spec.doors.join(' + ')}, which declare no privilege — ` +
82
- 'the tool is as open as the doors it reaches until they declare one'
102
+ message: `tool '${spec.name}' reaches ${spec.doors.join(' + ')} and declares no privilege of its own — ` +
103
+ 'whether those doors require anything is asked at boot, against the schema, not copied here ' +
104
+ '(ADR-0072 decision 5)'
83
105
  });
84
106
  continue;
85
107
  }
86
108
  warnings.push({
87
109
  code: 'tool-privilege-undeclared',
88
110
  params: { category: category.name, tool: spec.name, kind: spec.kind },
89
- message: `tool '${spec.name}' in category '${category.name}' says nothing about the doors it reaches — ` +
90
- "declare `doors` with the same { category, privilege } those doors use, or `doors: []` when it reaches none"
111
+ message: `tool '${spec.name}' in category '${category.name}' declares no privilege of its own — ` +
112
+ 'a tool that changes something says who may run it. What the doors it reaches require is a ' +
113
+ "separate question, answered at boot against the schema (`tool-door-not-in-schema`)."
91
114
  });
92
115
  }
93
116
  if (categories.has(category.name)) {
@@ -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/** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */\nexport interface ToolRegistryWarning {\n /**\n * `tool-privilege-undeclared` — nothing said which door this tool reaches.\n * `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet\n * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so\n * this is a different fact from saying nothing, and the report keeps the two apart.\n */\n code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege'\n params: { category: string; tool: string; kind: ToolSpec['kind']; doors?: string[] }\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 const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)\n if (declared) continue\n\n /*\n * An empty list says this tool reaches no door — the ones that hand back what the surface sent\n * are that. Nothing to gate, nothing to report.\n */\n if (Array.isArray(spec.doors) && spec.doors.length === 0) continue\n\n if (spec.doors?.length) {\n /*\n * The door is named and carries no privilege yet. The tool cannot be stricter than the door\n * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn\n * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.\n */\n warnings.push({\n code: 'tool-door-has-no-privilege',\n params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },\n message:\n `tool '${spec.name}' reaches ${spec.doors.join(' + ')}, which declare no privilege — ` +\n 'the tool is as open as the doors it reaches until they declare one'\n })\n continue\n }\n\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}' says nothing about the doors it reaches — ` +\n \"declare `doors` with the same { category, privilege } those doors use, or `doors: []` when it reaches none\"\n })\n }\n\n if (categories.has(category.name)) {\n console.warn(\n `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +\n '(normal on a dev hot reload; in production it means the same category is registered twice)'\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"]}
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 /**\n * `tool-privilege-undeclared` — nothing said which door this tool reaches.\n * `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet\n * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so\n * this is a different fact from saying nothing, and the report keeps the two apart.\n */\n code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege' | 'tool-doors-undeclared'\n params: { category: string; tool: string; kind: ToolSpec['kind']; doors?: string[] }\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\n /*\n * ── 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** ─────────────────\n *\n * ADR-0073 뒤로 어시스턴트의 답 행에는 그 답을 만든 도구들이 지난 문이 적힌다. 선언이 없는 도구는\n * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면\n * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.\n *\n * 이 검사를 권한 검사와 따로 도는 이유가 그것이다. 아래 고리는 `privilege` 가 있으면 건너뛰는데,\n * 문을 안 적은 해는 권한을 적었는지와 상관이 없다. 트윈의 저작 도구 넷이 정확히 그 자리였다 —\n * 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.\n */\n for (const spec of category.specs) {\n if (Array.isArray(spec.doors)) continue\n\n warnings.push({\n code: 'tool-doors-undeclared',\n params: { category: category.name, tool: spec.name, kind: spec.kind },\n message:\n `tool '${spec.name}' in category '${category.name}' declares no doors, so answers built with it ` +\n 'are hidden from every reader including their author — declare `doors`, or `doors: []` when it reaches none'\n })\n }\n\n for (const spec of category.specs) {\n const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)\n if (declared) continue\n\n /*\n * An empty list says this tool reaches no door — the ones that hand back what the surface sent\n * are that. Nothing to gate, nothing to report.\n */\n if (Array.isArray(spec.doors) && spec.doors.length === 0) continue\n\n if (spec.doors?.length) {\n /*\n * The door is named and carries no privilege yet. The tool cannot be stricter than the door\n * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn\n * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.\n */\n warnings.push({\n code: 'tool-door-has-no-privilege',\n params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },\n message:\n `tool '${spec.name}' reaches ${spec.doors.join(' + ')} and declares no privilege of its own — ` +\n 'whether those doors require anything is asked at boot, against the schema, not copied here ' +\n '(ADR-0072 decision 5)'\n })\n continue\n }\n\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}' declares no privilege of its own — ` +\n 'a tool that changes something says who may run it. What the doors it reaches require is a ' +\n \"separate question, answered at boot against the schema (`tool-door-not-in-schema`).\"\n })\n }\n\n if (categories.has(category.name)) {\n console.warn(\n `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +\n '(normal on a dev hot reload; in production it means the same category is registered twice)'\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"]}