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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/dist-server/service/agentic-loop/agentic-loop.js +13 -1
  2. package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
  3. package/dist-server/service/assistant/registry-assistant.d.ts +11 -0
  4. package/dist-server/service/assistant/registry-assistant.js +5 -1
  5. package/dist-server/service/assistant/registry-assistant.js.map +1 -1
  6. package/dist-server/service/assistant/tool-scope.d.ts +3 -3
  7. package/dist-server/service/assistant/tool-scope.js +32 -15
  8. package/dist-server/service/assistant/tool-scope.js.map +1 -1
  9. package/dist-server/service/tool-registry/index.d.ts +1 -1
  10. package/dist-server/service/tool-registry/index.js.map +1 -1
  11. package/dist-server/service/tool-registry/registry.d.ts +9 -8
  12. package/dist-server/service/tool-registry/registry.js +26 -38
  13. package/dist-server/service/tool-registry/registry.js.map +1 -1
  14. package/dist-server/service/tool-registry/types.d.ts +16 -31
  15. package/dist-server/service/tool-registry/types.js.map +1 -1
  16. package/dist-server/tsconfig.tsbuildinfo +1 -1
  17. package/package.json +2 -2
  18. package/server/service/agentic-loop/agentic-loop.ts +14 -1
  19. package/server/service/agentic-loop/proposal-key.test.ts +31 -0
  20. package/server/service/assistant/registry-assistant.test.ts +65 -15
  21. package/server/service/assistant/registry-assistant.ts +13 -1
  22. package/server/service/assistant/tool-scope.test.ts +123 -0
  23. package/server/service/assistant/tool-scope.ts +34 -17
  24. package/server/service/tool-registry/index.ts +0 -1
  25. package/server/service/tool-registry/registry.test.ts +34 -66
  26. package/server/service/tool-registry/registry.ts +35 -46
  27. package/server/service/tool-registry/types.ts +16 -33
@@ -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"]}
@@ -1,7 +1,7 @@
1
1
  import { type ToolCategoryFilter } from '../tool-registry/index.js';
2
- import type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js';
3
- /** Answers "may this caller use a door guarded by this privilege". */
4
- export type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>;
2
+ import type { ToolSpec } from '../tool-registry/types.js';
3
+ /** Answers "may this caller pass this door" — `'Query.jobOrders'`. */
4
+ export type ToolAccessCheck = (door: string) => Promise<boolean>;
5
5
  export interface ScopedCategory {
6
6
  readonly name: string;
7
7
  readonly guidance: string;
@@ -10,36 +10,53 @@ exports.toolScopeGuidance = toolScopeGuidance;
10
10
  *
11
11
  * ── Two questions, one list ───────────────────────────────────────────────────────
12
12
  * Which categories did this surface ask for? `toolCategories`
13
- * Which of their tools may this caller use? each spec's `privilege`, asked of `access`
13
+ * Which of their tools may this caller use? each spec's `doors`, asked of `access`
14
14
  *
15
15
  * **Nothing asked means nothing offered.** Omitting the category list used to mean "every
16
16
  * registered category"; that default is the one that opened board-import's Board-creating tool to
17
17
  * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).
18
18
  *
19
- * **No access check means privileged tools stay closed.** A caller that cannot say who is asking
20
- * gets only the tools that need no privilege.
19
+ * **The doors are the gate** (ADR-0071 decision 2). A tool used to declare its own copy of what its
20
+ * door asks, and this asked that copy. Two declarations of one fact drift, and the looser one wins;
21
+ * plant's hand-written read check and its `doors` list were that pair. Now a tool names the doors it
22
+ * reaches and the host resolves each one against the door's own declaration, so what passes here is
23
+ * what passes at the door.
24
+ *
25
+ * **All the doors, not one of them.** A tool that answers from two doors hands back what is behind
26
+ * both, so the caller holds both (AND, the same reading as AUTH's `requiresAll`).
27
+ *
28
+ * **No access check means a tool that reaches a door stays closed.** A caller that cannot say who is
29
+ * asking gets only the tools that reach nothing.
21
30
  *
22
31
  * The permission check itself belongs to auth, which this package does not depend on, so the host
23
- * hands it in (`ai-assistant` builds it from the request state).
32
+ * hands it in (`ai-assistant` builds it from the request state and AUTH's field table).
24
33
  */
25
34
  const index_js_1 = require("../tool-registry/index.js");
26
35
  exports.EMPTY_TOOL_SCOPE = Object.freeze({ categories: [], specs: [] });
27
- const privilegeKey = (p) => [p.category, p.privilege, !!p.owner, !!p.super].join('|');
28
36
  async function resolveToolScope(requested, access) {
29
37
  if (!requested || requested.length === 0)
30
38
  return exports.EMPTY_TOOL_SCOPE;
31
- /* One check per distinct privilege — a category of ten read tools asks once. */
39
+ /* One check per distinct door — five tools behind `Query.jobOrders` ask once. */
32
40
  const answers = new Map();
33
- const mayUse = (spec) => {
34
- const privilege = spec.privilege;
35
- if (!privilege)
36
- return Promise.resolve(true);
41
+ const passes = (door) => {
42
+ if (!answers.has(door))
43
+ answers.set(door, access(door).catch(() => false));
44
+ return answers.get(door);
45
+ };
46
+ const mayUse = async (spec) => {
47
+ /*
48
+ * Said nothing. Not open — left out, and the boot report names it. An answer this tool built
49
+ * would be readable by no one (ADR-0073), so offering it would only produce dead answers.
50
+ */
51
+ if (!Array.isArray(spec.doors))
52
+ return false;
53
+ /* Reaches no door: it hands back what the surface itself sent. There is nothing to gate. */
54
+ if (spec.doors.length === 0)
55
+ return true;
37
56
  if (!access)
38
- return Promise.resolve(false);
39
- const key = privilegeKey(privilege);
40
- if (!answers.has(key))
41
- answers.set(key, access(privilege).catch(() => false));
42
- return answers.get(key);
57
+ return false;
58
+ const held = await Promise.all(spec.doors.map(passes));
59
+ return held.every(Boolean);
43
60
  };
44
61
  const categories = [];
45
62
  for (const category of (0, index_js_1.getToolCategories)()) {
@@ -1 +1 @@
1
- {"version":3,"file":"tool-scope.js","sourceRoot":"","sources":["../../../server/service/assistant/tool-scope.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Which registered tools one conversation may use — decided once per request, before anything is\n * offered, so offering and dispatch read the same list.\n *\n * ── Two questions, one list ───────────────────────────────────────────────────────\n * Which categories did this surface ask for? `toolCategories`\n * Which of their tools may this caller use? each spec's `privilege`, asked of `access`\n *\n * **Nothing asked means nothing offered.** Omitting the category list used to mean \"every\n * registered category\"; that default is the one that opened board-import's Board-creating tool to\n * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).\n *\n * **No access check means privileged tools stay closed.** A caller that cannot say who is asking\n * gets only the tools that need no privilege.\n *\n * The permission check itself belongs to auth, which this package does not depend on, so the host\n * hands it in (`ai-assistant` builds it from the request state).\n */\nimport { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'\nimport type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js'\n\n/** Answers \"may this caller use a door guarded by this privilege\". */\nexport type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>\n\nexport interface ScopedCategory {\n readonly name: string\n readonly guidance: string\n /** The tools of this category this caller may use. Never empty — a category with none is left out. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport interface ToolScope {\n /** Categories with at least one usable tool, in registration order. */\n readonly categories: ReadonlyArray<ScopedCategory>\n /** Every usable tool, flat. Offering and dispatch both read this. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })\n\nconst privilegeKey = (p: ToolPrivilege) => [p.category, p.privilege, !!p.owner, !!p.super].join('|')\n\nexport async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {\n if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE\n\n /* One check per distinct privilege — a category of ten read tools asks once. */\n const answers = new Map<string, Promise<boolean>>()\n const mayUse = (spec: ToolSpec): Promise<boolean> => {\n const privilege = spec.privilege\n if (!privilege) return Promise.resolve(true)\n if (!access) return Promise.resolve(false)\n const key = privilegeKey(privilege)\n if (!answers.has(key)) answers.set(key, access(privilege).catch(() => false))\n return answers.get(key)!\n }\n\n const categories: ScopedCategory[] = []\n for (const category of getToolCategories()) {\n if (!requested.includes(category.name)) continue\n const allowed = await Promise.all(category.specs.map(mayUse))\n const specs = category.specs.filter((_, i) => allowed[i])\n if (specs.length > 0) categories.push({ name: category.name, guidance: String(category.guidance ?? '').trim(), specs })\n }\n return fromCategories(categories)\n}\n\n/**\n * Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category\n * left with nothing drops out, so its guidance is not read to a model that cannot call it.\n */\nexport function narrowToolScope(scope: ToolScope, keep: (spec: ToolSpec) => boolean): ToolScope {\n return fromCategories(\n scope.categories\n .map(category => ({ ...category, specs: category.specs.filter(keep) }))\n .filter(category => category.specs.length > 0)\n )\n}\n\n/** The rules of the categories in scope, for the system prompt. */\nexport function toolScopeGuidance(scope: ToolScope): string[] {\n return scope.categories.map(category => category.guidance).filter(Boolean)\n}\n\nfunction fromCategories(categories: ScopedCategory[]): ToolScope {\n return { categories, specs: categories.flatMap(category => category.specs) }\n}\n"]}
1
+ {"version":3,"file":"tool-scope.js","sourceRoot":"","sources":["../../../server/service/assistant/tool-scope.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Which registered tools one conversation may use — decided once per request, before anything is\n * offered, so offering and dispatch read the same list.\n *\n * ── Two questions, one list ───────────────────────────────────────────────────────\n * Which categories did this surface ask for? `toolCategories`\n * Which of their tools may this caller use? each spec's `doors`, asked of `access`\n *\n * **Nothing asked means nothing offered.** Omitting the category list used to mean \"every\n * registered category\"; that default is the one that opened board-import's Board-creating tool to\n * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).\n *\n * **The doors are the gate** (ADR-0071 decision 2). A tool used to declare its own copy of what its\n * door asks, and this asked that copy. Two declarations of one fact drift, and the looser one wins;\n * plant's hand-written read check and its `doors` list were that pair. Now a tool names the doors it\n * reaches and the host resolves each one against the door's own declaration, so what passes here is\n * what passes at the door.\n *\n * **All the doors, not one of them.** A tool that answers from two doors hands back what is behind\n * both, so the caller holds both (AND, the same reading as AUTH's `requiresAll`).\n *\n * **No access check means a tool that reaches a door stays closed.** A caller that cannot say who is\n * asking gets only the tools that reach nothing.\n *\n * The permission check itself belongs to auth, which this package does not depend on, so the host\n * hands it in (`ai-assistant` builds it from the request state and AUTH's field table).\n */\nimport { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'\nimport type { ToolSpec } from '../tool-registry/types.js'\n\n/** Answers \"may this caller pass this door\" — `'Query.jobOrders'`. */\nexport type ToolAccessCheck = (door: string) => Promise<boolean>\n\nexport interface ScopedCategory {\n readonly name: string\n readonly guidance: string\n /** The tools of this category this caller may use. Never empty — a category with none is left out. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport interface ToolScope {\n /** Categories with at least one usable tool, in registration order. */\n readonly categories: ReadonlyArray<ScopedCategory>\n /** Every usable tool, flat. Offering and dispatch both read this. */\n readonly specs: ReadonlyArray<ToolSpec>\n}\n\nexport const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })\n\nexport async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {\n if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE\n\n /* One check per distinct door — five tools behind `Query.jobOrders` ask once. */\n const answers = new Map<string, Promise<boolean>>()\n const passes = (door: string): Promise<boolean> => {\n if (!answers.has(door)) answers.set(door, access!(door).catch(() => false))\n return answers.get(door)!\n }\n\n const mayUse = async (spec: ToolSpec): Promise<boolean> => {\n /*\n * Said nothing. Not open — left out, and the boot report names it. An answer this tool built\n * would be readable by no one (ADR-0073), so offering it would only produce dead answers.\n */\n if (!Array.isArray(spec.doors)) return false\n /* Reaches no door: it hands back what the surface itself sent. There is nothing to gate. */\n if (spec.doors.length === 0) return true\n if (!access) return false\n\n const held = await Promise.all(spec.doors.map(passes))\n return held.every(Boolean)\n }\n\n const categories: ScopedCategory[] = []\n for (const category of getToolCategories()) {\n if (!requested.includes(category.name)) continue\n const allowed = await Promise.all(category.specs.map(mayUse))\n const specs = category.specs.filter((_, i) => allowed[i])\n if (specs.length > 0) categories.push({ name: category.name, guidance: String(category.guidance ?? '').trim(), specs })\n }\n return fromCategories(categories)\n}\n\n/**\n * Keep only the tools a surface can run — e.g. a surface with no document keeps `read`. A category\n * left with nothing drops out, so its guidance is not read to a model that cannot call it.\n */\nexport function narrowToolScope(scope: ToolScope, keep: (spec: ToolSpec) => boolean): ToolScope {\n return fromCategories(\n scope.categories\n .map(category => ({ ...category, specs: category.specs.filter(keep) }))\n .filter(category => category.specs.length > 0)\n )\n}\n\n/** The rules of the categories in scope, for the system prompt. */\nexport function toolScopeGuidance(scope: ToolScope): string[] {\n return scope.categories.map(category => category.guidance).filter(Boolean)\n}\n\nfunction fromCategories(categories: ScopedCategory[]): ToolScope {\n return { categories, specs: categories.flatMap(category => category.specs) }\n}\n"]}
@@ -5,4 +5,4 @@
5
5
  */
6
6
  export { registerToolCategory, getToolCategories, getAllToolSpecs, getToolSpecsFor, getCategoryGuidance, findToolSpec, getToolKind, hasTool, getCategorySpecs, clearToolRegistry, getToolRegistryWarnings } from './registry';
7
7
  export type { ToolCategoryFilter, ToolRegistryWarning } from './registry';
8
- export type { ToolSpec, ToolSpecBase, ToolPrivilege, ReadToolSpec, WriteToolSpec, ActionToolSpec, ExternalToolSpec, ToolCategory, ReadToolBuilder, WriteToolBuilder, ActionToolBuilder, ExternalToolBuilder, ReadToolContext, WriteToolContext, ExternalToolContext, ToolCallContext, ToolCallMode } from './types';
8
+ export type { ToolSpec, ToolSpecBase, ReadToolSpec, WriteToolSpec, ActionToolSpec, ExternalToolSpec, ToolCategory, ReadToolBuilder, WriteToolBuilder, ActionToolBuilder, ExternalToolBuilder, ReadToolContext, WriteToolContext, ExternalToolContext, ToolCallContext, ToolCallMode } from './types';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,uCAYmB;AAXjB,gHAAA,oBAAoB,OAAA;AACpB,6GAAA,iBAAiB,OAAA;AACjB,2GAAA,eAAe,OAAA;AACf,2GAAA,eAAe,OAAA;AACf,+GAAA,mBAAmB,OAAA;AACnB,wGAAA,YAAY,OAAA;AACZ,uGAAA,WAAW,OAAA;AACX,mGAAA,OAAO,OAAA;AACP,4GAAA,gBAAgB,OAAA;AAChB,6GAAA,iBAAiB,OAAA;AACjB,mHAAA,uBAAuB,OAAA","sourcesContent":["/**\n * Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.\n *\n * 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.\n */\nexport {\n registerToolCategory,\n getToolCategories,\n getAllToolSpecs,\n getToolSpecsFor,\n getCategoryGuidance,\n findToolSpec,\n getToolKind,\n hasTool,\n getCategorySpecs,\n clearToolRegistry,\n getToolRegistryWarnings\n} from './registry'\n\n/* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */\nexport type { ToolCategoryFilter, ToolRegistryWarning } from './registry'\n\nexport type {\n ToolSpec,\n ToolSpecBase,\n ToolPrivilege,\n ReadToolSpec,\n WriteToolSpec,\n ActionToolSpec,\n ExternalToolSpec,\n ToolCategory,\n ReadToolBuilder,\n WriteToolBuilder,\n ActionToolBuilder,\n ExternalToolBuilder,\n ReadToolContext,\n WriteToolContext,\n ExternalToolContext,\n ToolCallContext,\n ToolCallMode\n} from './types'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,uCAYmB;AAXjB,gHAAA,oBAAoB,OAAA;AACpB,6GAAA,iBAAiB,OAAA;AACjB,2GAAA,eAAe,OAAA;AACf,2GAAA,eAAe,OAAA;AACf,+GAAA,mBAAmB,OAAA;AACnB,wGAAA,YAAY,OAAA;AACZ,uGAAA,WAAW,OAAA;AACX,mGAAA,OAAO,OAAA;AACP,4GAAA,gBAAgB,OAAA;AAChB,6GAAA,iBAAiB,OAAA;AACjB,mHAAA,uBAAuB,OAAA","sourcesContent":["/**\n * Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.\n *\n * 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.\n */\nexport {\n registerToolCategory,\n getToolCategories,\n getAllToolSpecs,\n getToolSpecsFor,\n getCategoryGuidance,\n findToolSpec,\n getToolKind,\n hasTool,\n getCategorySpecs,\n clearToolRegistry,\n getToolRegistryWarnings\n} from './registry'\n\n/* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */\nexport type { ToolCategoryFilter, ToolRegistryWarning } from './registry'\n\nexport type {\n ToolSpec,\n ToolSpecBase,\n ReadToolSpec,\n WriteToolSpec,\n ActionToolSpec,\n ExternalToolSpec,\n ToolCategory,\n ReadToolBuilder,\n WriteToolBuilder,\n ActionToolBuilder,\n ExternalToolBuilder,\n ReadToolContext,\n WriteToolContext,\n ExternalToolContext,\n ToolCallContext,\n ToolCallMode\n} from './types'\n"]}
@@ -29,24 +29,25 @@ import type { ToolCategory, ToolSpec } from './types';
29
29
  /** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */
30
30
  export interface ToolRegistryWarning {
31
31
  /**
32
- * `tool-privilege-undeclared` — nothing said which door this tool reaches.
33
- * `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet
34
- * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so
35
- * this is a different fact from saying nothing, and the report keeps the two apart.
32
+ * `tool-doors-undeclared` — nothing said which doors this tool reaches, so the gate leaves it out
33
+ * of every conversation (`resolveToolScope`) and this names it.
34
+ *
35
+ * What those doors require is **not** asked here. Registration runs while modules load, before the
36
+ * schema stands, so AUTH's field table is still empty; `ai-assistant`'s boot report asks the
37
+ * schema after it is built (`tool-door-not-in-schema` · `tool-door-has-no-privilege`).
36
38
  */
37
- code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege';
39
+ code: 'tool-doors-undeclared';
38
40
  params: {
39
41
  category: string;
40
42
  tool: string;
41
43
  kind: ToolSpec['kind'];
42
- doors?: string[];
43
44
  };
44
45
  /** For a person reading it; the code and params are what callers act on. */
45
46
  message: string;
46
47
  }
47
48
  /**
48
- * What registration accepted but the contract does not yet allow. Stage one of A1 records these
49
- * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
49
+ * What registered without saying which doors it reaches. A host prints this at boot; the gate has
50
+ * already left each of these tools out of every conversation.
50
51
  */
51
52
  export declare function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning>;
52
53
  /**
@@ -14,8 +14,8 @@ exports.clearToolRegistry = clearToolRegistry;
14
14
  const categories = new Map();
15
15
  const warnings = [];
16
16
  /**
17
- * What registration accepted but the contract does not yet allow. Stage one of A1 records these
18
- * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
17
+ * What registered without saying which doors it reaches. A host prints this at boot; the gate has
18
+ * already left each of these tools out of every conversation.
19
19
  */
20
20
  function getToolRegistryWarnings() {
21
21
  return warnings.slice();
@@ -44,50 +44,38 @@ function registerToolCategory(category) {
44
44
  'Write the rules for these tools in `guidance` — the side that registers them owns them.');
45
45
  }
46
46
  /*
47
- * A tool that changes something must say which privilege it goes through (A1, 2026-09-17).
47
+ * A tool says which doors it reaches (ADR-0071 decision 2).
48
48
  *
49
- * The conversation door checks one privilege — may this person talk to the assistant. Without a
50
- * privilege on the tool, that single check opened every tool behind it: a user holding only
51
- * `ai-assistant:mutation` could reach board-import's `materializeImportSession` and create a
52
- * Board.
49
+ * The conversation door checks one privilege — may this person talk to the assistant. On its own
50
+ * that single check opened every tool behind it: a user holding only `ai-assistant:mutation` could
51
+ * reach board-import's `materializeImportSession` and create a Board.
53
52
  *
54
- * **This is stage one of two: surface, then refuse** (ADR-0037, "먼저 드러내고, 목록이 비면 막는다").
55
- * Refusing now would stop every app whose tools predate the `privilege` field from booting, and
56
- * those apps compile against an installed copy that does not have the field yet. So an undeclared
57
- * tool still registers, and the omission is recorded as a code (`getToolRegistryWarnings`), not
58
- * a log line — a warning only a log carries is not distinguishable from no warning. When the list
59
- * is empty across the apps, this becomes a refusal.
53
+ * Registration does not refuse a tool that says nothing; it records the omission as a code
54
+ * (`getToolRegistryWarnings`), not a log line — a warning only a log carries is not
55
+ * distinguishable from no warning. The refusal happens where it costs nothing to be late: the gate
56
+ * leaves an undeclared tool out of every conversation, so booting stays possible while the list is
57
+ * worked down, and no undeclared tool is reachable in the meantime.
60
58
  */
61
59
  warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name));
60
+ /*
61
+ * ── 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** ─────────────────
62
+ *
63
+ * ADR-0073 뒤로 어시스턴트의 답 행에는 그 답을 만든 도구들이 지난 문이 적힌다. 선언이 없는 도구는
64
+ * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면
65
+ * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.
66
+ *
67
+ * 2026-09-18 부터 이 선언은 관문이기도 하다(ADR-0071 결정 2 보탬). 문을 안 적은 도구는 어느 대화에도
68
+ * 나가지 않으므로, 이 목록이 「무엇이 빠져 있나」를 말하는 유일한 자리다.
69
+ */
62
70
  for (const spec of category.specs) {
63
- const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege);
64
- if (declared)
71
+ if (Array.isArray(spec.doors))
65
72
  continue;
66
- /*
67
- * An empty list says this tool reaches no door — the ones that hand back what the surface sent
68
- * are that. Nothing to gate, nothing to report.
69
- */
70
- if (Array.isArray(spec.doors) && spec.doors.length === 0)
71
- continue;
72
- if (spec.doors?.length) {
73
- /*
74
- * The door is named and carries no privilege yet. The tool cannot be stricter than the door
75
- * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn
76
- * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.
77
- */
78
- warnings.push({
79
- code: 'tool-door-has-no-privilege',
80
- 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'
83
- });
84
- continue;
85
- }
86
73
  warnings.push({
87
- code: 'tool-privilege-undeclared',
74
+ code: 'tool-doors-undeclared',
88
75
  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"
76
+ message: `tool '${spec.name}' in category '${category.name}' declares no doors, so it is offered to no ` +
77
+ 'conversation and answers built with it would be hidden from every reader including their ' +
78
+ 'author — declare `doors`, or `doors: []` when it reaches none'
91
79
  });
92
80
  }
93
81
  if (categories.has(category.name)) {