@things-factory/board-ai 10.0.18 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-ai",
3
- "version": "10.0.18",
3
+ "version": "10.1.0",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/client/index.js",
6
6
  "things-factory": true,
@@ -31,11 +31,11 @@
31
31
  "@operato/graphql": "^10.0.0",
32
32
  "@operato/i18n": "^10.0.0",
33
33
  "@operato/styles": "^10.0.0",
34
- "@things-factory/ai-client-base": "^10.0.8",
35
- "@things-factory/auth-base": "^10.0.18",
36
- "@things-factory/board-import": "^10.0.18",
34
+ "@things-factory/ai-client-base": "^10.1.0",
35
+ "@things-factory/auth-base": "^10.1.0",
36
+ "@things-factory/board-import": "^10.1.0",
37
37
  "@things-factory/env": "^10.0.8",
38
- "@things-factory/shell": "^10.0.18",
38
+ "@things-factory/shell": "^10.1.0",
39
39
  "dompurify": "^3.0.0",
40
40
  "graphql-tag": "^2.12.6",
41
41
  "lit": "^3.1.2",
@@ -45,5 +45,5 @@
45
45
  "copyfiles": "^2.4.1",
46
46
  "rimraf": "^5.0.0"
47
47
  },
48
- "gitHead": "806c7af400517177f1323645df03558451587533"
48
+ "gitHead": "8407b1e9d1f47f6676af144d4a33c8ac01cb49c4"
49
49
  }
@@ -33,7 +33,18 @@ import { resolveCatalogMentions } from './catalog-resolver'
33
33
  // styling write tool builder 들은 registry (./styling/registry.ts) 에서 등록 + dispatch.
34
34
  // 직접 import 안 함 — 신규 styling tool 추가 시 registry 에만 spec 추가하면 됨.
35
35
  import { buildComponentStyleSummary } from './styling/read-tools'
36
- import { runAgenticLoop, type AgenticLoopResult } from './agentic-loop'
36
+ /*
37
+ * 도구 루프는 **계약 층이 소유한다**(2026-09-05). 전에는 이 패키지 안에 있었고, twin-ai 에 같은
38
+ * 모양이 한 벌 더 있었다 — 그래서 안전 장치가 서로 반대로 갈렸다(여기는 중단·조기종료, 저쪽은
39
+ * 접지 가드). 합친 것이 `@things-factory/ai-client-base` 에 있다.
40
+ *
41
+ * board 의 것(문서·선택 refid·편집 op)은 타입 매개변수로 넘긴다 — 루프는 그 모양을 모른다.
42
+ */
43
+ import { runAgenticLoop, type AgenticLoopResult } from '@things-factory/ai-client-base'
44
+ import type { BoardActionOp } from './types.js'
45
+
46
+ /** 이 대화면의 도구가 알아야 하는 것 — 루프는 나르기만 한다. */
47
+ type BoardToolContext = { board: BoardModel | undefined; selectedRefids: number[] }
37
48
  import { checkReplyGrounding } from './grounding'
38
49
  import { collectAllComponents, findComponentByRefid } from './component-tree'
39
50
  import {
@@ -227,7 +238,7 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
227
238
  return undefined
228
239
  }
229
240
 
230
- const loopResult = await runAgenticLoop({
241
+ const loopResult = await runAgenticLoop<BoardToolContext, BoardActionOp>({
231
242
  initialConversation: conversation,
232
243
  tools,
233
244
  options: {
@@ -244,13 +255,13 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
244
255
  isReadTool: gate.isRead,
245
256
  isWriteTool: gate.isWrite,
246
257
  isActionTool: gate.isAction,
247
- executeReadTool: wrappedExecuteReadTool,
248
- validateWriteToolCall,
249
- toolCallToBoardActionOp,
258
+ executeReadTool: (tc, ctx) => wrappedExecuteReadTool(tc, ctx.board, ctx.selectedRefids),
259
+ /* 문서에서 부품을 꺼내는 일은 **여기서** 한다 — 루프는 board 의 모양을 모른다. */
260
+ validateWriteToolCall: (tc, ctx) => validateWriteToolCall(tc, (ctx.board?.components ?? []) as any[]),
261
+ toolCallToOp: toolCallToBoardActionOp,
250
262
  summarizeToolResult
251
263
  },
252
- currentBoard,
253
- selectedRefids
264
+ context: { board: currentBoard, selectedRefids }
254
265
  })
255
266
  const accumulatedWriteCalls = loopResult.accumulatedWriteCalls
256
267
  const accumulatedActions = loopResult.accumulatedActions
@@ -112,37 +112,14 @@ export interface ComponentSchema {
112
112
  }
113
113
 
114
114
  /**
115
- * AI 가 응답을 만드는 동안 호출한 도구의 시간순 trace.
115
+ * 도구 사용 기록 — **`@things-factory/ai-client-base` 가 소유한다.**
116
116
  *
117
- * UX 목적 — 사용자가 "왜 이렇게 답했지" 의문 가질 때 fold-able 박스에서 확인.
118
- * 디버그용 + 신뢰도 향상.
117
+ * 도메인 중립이라 루프와 함께 그리로 옮겼다(2026-09-05). 여기서 다시 내보내는 것은 부르는 쪽의
118
+ * import 를 그대로 두기 위해서다 — 뜻의 정본은 저쪽 한 곳이다.
119
119
  */
120
- export interface ToolUsage {
121
- /** 도구 이름 (예: 'getSelection', 'modifyBoard', 'addComponent') */
122
- name: string
123
- /** 호출 인자 JSON */
124
- arguments: any
125
- /** 도구 결과 — read tool 은 실제 값 (truncated 가능), write tool 은 queued 마커 */
126
- result: any
127
- /** read tool / write tool 분류 */
128
- kind: 'read' | 'write' | 'unknown'
129
- /**
130
- * 몇 번째 LLM 턴에서 불렀는가(0부터). 판단 과정을 단계로 읽을 수 있게 한다 —
131
- * "조회 → 거절 → 정정 → 제안" 이 한 턴 안의 일인지 여러 턴에 걸친 일인지가 진단의 절반이다.
132
- */
133
- iter?: number
134
- /**
135
- * 이 호출이 **어떻게 끝났는가** — 화면이 배지로 보여줄 판정.
136
- * ok 조회·실행이 정상 완료
137
- * rejected 도구가 거절(인자 누락·대상 없음 등). 무엇이 빠졌는지는 result 에 있다.
138
- * queued 보드 편집처럼 클라이언트에서 적용될 예정
139
- * proposed 조치 제안이 만들어졌다(실행 아님 — 사용자가 버튼을 누른다)
140
- * folded 같은 효과의 제안이 이미 있어 접혔다. **조용히 버리지 않는다** — 접힌 사실이 보여야
141
- * "왜 카드가 하나뿐인가" 를 추적할 수 있다.
142
- * error 알 수 없는 도구·검증 실패
143
- */
144
- outcome?: 'ok' | 'rejected' | 'queued' | 'proposed' | 'folded' | 'error'
145
- }
120
+ import type { ToolUsage } from '@things-factory/ai-client-base'
121
+ export type { ToolUsage }
122
+
146
123
 
147
124
  export interface ChatResponse {
148
125
  /** 사용자에게 보여줄 텍스트 응답 */
@@ -1,121 +0,0 @@
1
- import type { AIMessage, AIToolCall, AIToolChatResult } from '@things-factory/ai-client-base';
2
- import type { BoardModel } from '@things-factory/board-import';
3
- import type { BoardActionOp, ToolUsage } from './types';
4
- /**
5
- * Loop 가 호출하는 dispatch 함수들. 모두 의존성 주입.
6
- *
7
- * assistant.ts 가 자기 자신의 함수 (isReadTool, executeReadTool 등) 를 그대로
8
- * 넘겨주는 구조. 본 모듈은 이 함수들의 *시그니처* 만 알면 됨.
9
- */
10
- export interface ToolDispatchHelpers {
11
- isReadTool: (name: string) => boolean;
12
- isWriteTool: (name: string) => boolean;
13
- isActionTool: (name: string) => boolean;
14
- /** read/external 도구 실행. **Promise 를 반환할 수 있다** — 호출부는 반드시 await 한다. */
15
- executeReadTool: (tc: AIToolCall, board: BoardModel | undefined, selectedRefids: number[]) => any | Promise<any>;
16
- validateWriteToolCall: (tc: AIToolCall, components: any[]) => {
17
- valid: boolean;
18
- errors?: any[];
19
- suggestion?: string;
20
- };
21
- toolCallToBoardActionOp: (tc: AIToolCall) => BoardActionOp | null;
22
- summarizeToolResult: (value: any) => any;
23
- }
24
- export interface AgenticLoopOptions {
25
- systemPrompt: string;
26
- model?: string;
27
- temperature?: number;
28
- maxTokens?: number;
29
- /** 최대 iteration. 기본 8. */
30
- maxIterations?: number;
31
- /**
32
- * 사용자 abort 신호. iteration 시작 직전 / chat() 호출 후 체크.
33
- * 이미 abort 된 신호로 들어오면 첫 iteration 도 안 돔.
34
- */
35
- signal?: AbortSignal;
36
- /**
37
- * 같은 tool 의 validation 실패가 N회 연속이면 조기 종료. 기본 3.
38
- * LLM 이 같은 잘못을 무한 반복하는 경우 방어.
39
- */
40
- repeatedFailureLimit?: number;
41
- /**
42
- * **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).
43
- *
44
- * 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다
45
- * ("지게차들이 바쁘게 움직이고 있다" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한
46
- * 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할
47
- * 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.
48
- *
49
- * 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.
50
- */
51
- requireToolOnFirstTurn?: boolean;
52
- }
53
- export interface AgenticLoopInput {
54
- initialConversation: AIMessage[];
55
- tools: any[];
56
- options: AgenticLoopOptions;
57
- /** LLM provider 의 chatWithTools 위임. provider-specific 구현. */
58
- chat: (messages: AIMessage[], tools: any[], options: any) => Promise<AIToolChatResult>;
59
- dispatch: ToolDispatchHelpers;
60
- currentBoard: BoardModel | undefined;
61
- selectedRefids: number[];
62
- }
63
- export interface AgenticLoopResult {
64
- lastText: string | undefined;
65
- accumulatedWriteCalls: AIToolCall[];
66
- accumulatedActions: BoardActionOp[];
67
- toolUsages: ToolUsage[];
68
- /**
69
- * LLM 에게 실제로 회신된 도구 결과 문자열(시간순) — **접지 판정의 근거집합**.
70
- *
71
- * `toolUsages[].result` 를 쓰면 안 된다: 그건 UI 표시용으로 압축된(잘린) 값이라 근거로 삼으면
72
- * 잘려 나간 식별자를 "근거 없음" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.
73
- */
74
- groundedTexts: string[];
75
- /**
76
- * **제안** — 도구가 "실행하지 않고 제안만 했다" 고 알린 결과들(시간순).
77
- *
78
- * 규약: read/external 도구의 결과에 `proposed === true` 가 있으면 그것은 조치 제안이다. 그 도구가
79
- * 어느 도메인인지 이 루프는 모른다 — 결과를 그대로 실어 올리고, 해석·실행 확인은 대화면이 한다.
80
- * 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다(사람이 버튼을 누른다).
81
- */
82
- proposals: any[];
83
- /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */
84
- stopReason: string;
85
- /**
86
- * 비정상 종료 사유 — 정상 종료 시 undefined.
87
- * 호출자 (assistant.ts) 가 사용자 메시지에 반영 (예: '시도 중 오류가 발생했어요').
88
- */
89
- abortReason?: {
90
- type: 'provider_error';
91
- message: string;
92
- iter: number;
93
- } | {
94
- type: 'aborted';
95
- iter: number;
96
- } | {
97
- type: 'max_iterations';
98
- iter: number;
99
- } | {
100
- type: 'repeated_validation_failure';
101
- toolName: string;
102
- consecutive: number;
103
- iter: number;
104
- };
105
- }
106
- /**
107
- * multi-turn agentic loop.
108
- *
109
- * LLM 이 read tool 을 호출 → 서버에서 즉시 실행 → 결과 회신 → LLM 추론 반복.
110
- * write tool 은 누적해 client 에 patch 로 전달. 무한 루프 방지를 위해 iteration cap.
111
- *
112
- * 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.
113
- */
114
- export declare function runAgenticLoop(input: AgenticLoopInput): Promise<AgenticLoopResult>;
115
- /**
116
- * 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).
117
- *
118
- * 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로
119
- * 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.
120
- */
121
- export declare function proposalEffectKey(p: any): string;
@@ -1,349 +0,0 @@
1
- /**
2
- * agentic-loop — multi-turn LLM ↔ tool 루프.
3
- *
4
- * assistant.ts 에서 추출 — loop 자체를 *순수 함수* 로 만들어:
5
- * 1. 단위 테스트 가능 (assistant.ts 의 board-import → @aws-sdk transitive 경로 회피)
6
- * 2. 관측성 (error boundary / iter log / AbortSignal / repeated-failure 조기 종료)
7
- * 을 한 곳에 응집
8
- * 3. 향후 split (Phase 3) 의 자연 경계
9
- *
10
- * 입력 — `chat` 함수와 dispatch helper 들을 의존성 주입. board-ai 본체는 본 모듈에
11
- * 의존하지만 본 모듈은 board-ai 의 어떤 무거운 import 도 모름.
12
- */
13
- import debug from 'debug';
14
- import { retryTransient } from '@things-factory/ai-client-base';
15
- const log = debug('things-factory:board-ai:loop');
16
- // ── Loop ─────────────────────────────────────────────────────────
17
- const DEFAULT_MAX_ITERATIONS = 8;
18
- /**
19
- * multi-turn agentic loop.
20
- *
21
- * LLM 이 read tool 을 호출 → 서버에서 즉시 실행 → 결과 회신 → LLM 추론 반복.
22
- * write tool 은 누적해 client 에 patch 로 전달. 무한 루프 방지를 위해 iteration cap.
23
- *
24
- * 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.
25
- */
26
- export async function runAgenticLoop(input) {
27
- const { initialConversation, tools, options, chat, dispatch, currentBoard, selectedRefids } = input;
28
- const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
29
- const failureLimit = options.repeatedFailureLimit ?? 3;
30
- const signal = options.signal;
31
- let conversation = initialConversation;
32
- const accumulatedWriteCalls = [];
33
- const accumulatedActions = [];
34
- const toolUsages = [];
35
- /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */
36
- const groundedTexts = [];
37
- /* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */
38
- const proposals = [];
39
- let lastText;
40
- let stopReason = 'end_turn';
41
- let abortReason;
42
- // 같은 tool 의 validation 실패 연속 카운터 — 다른 tool 호출 시 reset
43
- let lastFailedToolName;
44
- let consecutiveFailures = 0;
45
- let iter = 0;
46
- for (; iter < maxIterations; iter++) {
47
- // (1) AbortSignal — iteration 시작 시 체크
48
- if (signal?.aborted) {
49
- log('iter %d: aborted by signal', iter);
50
- abortReason = { type: 'aborted', iter };
51
- break;
52
- }
53
- log('iter %d: chat() 호출', iter);
54
- let result;
55
- try {
56
- /* (2) Error boundary — provider 예외 시 graceful 종료.
57
- *
58
- * 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini
59
- * 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는
60
- * 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */
61
- result = await retryTransient(() => chat(conversation, tools, {
62
- systemPrompt: options.systemPrompt,
63
- model: options.model,
64
- temperature: options.temperature,
65
- maxTokens: options.maxTokens,
66
- /* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */
67
- toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',
68
- allowParallelToolCalls: true,
69
- signal
70
- }), {
71
- onRetry: ({ attempt, delayMs, error }) => log('iter %d: 일시 장애 재시도 %d회 (%dms 후): %s', iter, attempt, delayMs, error?.message)
72
- });
73
- }
74
- catch (e) {
75
- // AbortError — 별도 분류
76
- if (e?.name === 'AbortError' || signal?.aborted) {
77
- log('iter %d: provider AbortError', iter);
78
- abortReason = { type: 'aborted', iter };
79
- }
80
- else {
81
- log('iter %d: provider error: %s', iter, e?.message);
82
- abortReason = {
83
- type: 'provider_error',
84
- message: e?.message ?? String(e),
85
- iter
86
- };
87
- }
88
- break;
89
- }
90
- lastText = result.text || lastText;
91
- stopReason = result.stopReason;
92
- log('iter %d: text=%s, toolCalls=%d, stopReason=%s', iter, result.text ? `"${result.text.slice(0, 60)}..."` : '(none)', result.toolCalls.length, result.stopReason);
93
- if (result.toolCalls.length === 0)
94
- break;
95
- // (1b) 삭제: chat() 직후 abort 는 다음 iter 시작 시 (1) 에서 감지.
96
- // 현재 iter tool 실행을 중단하면 read tool 결과도 버려져 테스트/동작 불일치.
97
- // tool 결과 빌드 — read 는 즉시 실행, write 는 누적, action 은 별도 채널
98
- const beforeUsages = toolUsages.length;
99
- const toolResultParts = await processToolCalls(result.toolCalls, currentBoard, selectedRefids, dispatch, accumulatedWriteCalls, accumulatedActions, toolUsages, proposals, iter);
100
- /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */
101
- for (const part of toolResultParts) {
102
- if (typeof part?.content === 'string')
103
- groundedTexts.push(part.content);
104
- }
105
- // (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort
106
- const newUsages = toolUsages.slice(beforeUsages);
107
- const repeatedFailureAbort = detectRepeatedValidationFailure(newUsages, lastFailedToolName, consecutiveFailures, failureLimit);
108
- if (repeatedFailureAbort.shouldAbort) {
109
- log('iter %d: repeated validation failure on %s (%d consecutive) — abort', iter, repeatedFailureAbort.toolName, repeatedFailureAbort.consecutive);
110
- abortReason = {
111
- type: 'repeated_validation_failure',
112
- toolName: repeatedFailureAbort.toolName,
113
- consecutive: repeatedFailureAbort.consecutive,
114
- iter
115
- };
116
- break;
117
- }
118
- lastFailedToolName = repeatedFailureAbort.lastFailedToolName;
119
- consecutiveFailures = repeatedFailureAbort.consecutive;
120
- // assistant 의 tool_use turn + user 의 tool_result turn 을 conversation 에 누적
121
- const assistantContent = [];
122
- if (result.text)
123
- assistantContent.push({ type: 'text', text: result.text });
124
- for (const tc of result.toolCalls) {
125
- // providerMeta (Gemini thoughtSignature 등) 를 그대로 round-trip — 안 보내면 400.
126
- assistantContent.push({
127
- type: 'tool_use',
128
- id: tc.id,
129
- name: tc.name,
130
- arguments: tc.arguments,
131
- ...(tc.providerMeta && { providerMeta: tc.providerMeta })
132
- });
133
- }
134
- conversation = [
135
- ...conversation,
136
- { role: 'assistant', content: assistantContent },
137
- { role: 'user', content: toolResultParts }
138
- ];
139
- }
140
- // (3) max iterations 도달 시 사용자에게 표면화
141
- if (iter >= maxIterations && !abortReason) {
142
- log('iter %d: max iterations reached', iter);
143
- abortReason = { type: 'max_iterations', iter };
144
- }
145
- return {
146
- lastText,
147
- accumulatedWriteCalls,
148
- accumulatedActions,
149
- toolUsages,
150
- groundedTexts,
151
- proposals,
152
- stopReason,
153
- abortReason
154
- };
155
- }
156
- /**
157
- * 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).
158
- *
159
- * 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로
160
- * 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.
161
- */
162
- export function proposalEffectKey(p) {
163
- return [p?.tool ?? '', p?.command ?? '', p?.instanceId ?? '', stableStringify(p?.args)].join('|');
164
- }
165
- /** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */
166
- function stableStringify(value) {
167
- if (value === null || typeof value !== 'object')
168
- return JSON.stringify(value ?? null);
169
- if (Array.isArray(value))
170
- return `[${value.map(stableStringify).join(',')}]`;
171
- const keys = Object.keys(value).sort();
172
- return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
173
- }
174
- /**
175
- * 같은 tool 의 validation 실패 연속 감지 — pure helper.
176
- *
177
- * 같은 tool 이 반복 fail 하면 LLM 이 self-correct 못 하는 상태.
178
- * 다른 tool 이 fail 하면 시퀀스 새로 시작 (전혀 다른 패턴이라).
179
- * tool 이 succeed 하면 카운터 reset.
180
- */
181
- function detectRepeatedValidationFailure(newUsages, lastFailedToolName, consecutiveFailures, limit) {
182
- // 이번 iteration 에서 실패한 write tool 들 (validation 거절)
183
- const failed = newUsages.filter(u => u.kind === 'write' && u.result?.error === 'Tool args failed validation');
184
- if (failed.length === 0) {
185
- // 실패 없음 — reset
186
- return {
187
- shouldAbort: false,
188
- toolName: '',
189
- consecutive: 0,
190
- lastFailedToolName: undefined
191
- };
192
- }
193
- // 모든 실패가 같은 tool 인지 확인
194
- const firstName = failed[0].name;
195
- const allSame = failed.every(u => u.name === firstName);
196
- if (allSame && firstName === lastFailedToolName) {
197
- const consecutive = consecutiveFailures + 1;
198
- return {
199
- shouldAbort: consecutive >= limit,
200
- toolName: firstName,
201
- consecutive,
202
- lastFailedToolName: firstName
203
- };
204
- }
205
- // 다른 tool 또는 첫 실패
206
- return {
207
- shouldAbort: false,
208
- toolName: firstName,
209
- consecutive: 1,
210
- lastFailedToolName: firstName
211
- };
212
- }
213
- /**
214
- * 한 iteration 의 tool 결과를 분류·실행·누적. (loop 에서 분리해 읽기 쉽게.)
215
- */
216
- async function processToolCalls(toolCalls, currentBoard, selectedRefids, dispatch, accumulatedWriteCalls, accumulatedActions, toolUsages, proposals,
217
- /** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */
218
- iter) {
219
- const toolResultParts = [];
220
- for (const tc of toolCalls) {
221
- if (dispatch.isReadTool(tc.name)) {
222
- /* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).
223
- * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다
224
- * (board-import 의 getImportSession/importBoardAsync, operato-twin 의 getTwinStructure 등). */
225
- const value = await dispatch.executeReadTool(tc, currentBoard, selectedRefids);
226
- /* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).
227
- * 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */
228
- /* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */
229
- let outcome = 'ok';
230
- if (value && typeof value === 'object' && value.proposed === true) {
231
- /* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가
232
- * 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.
233
- * 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */
234
- const candidate = { tool: tc.name, ...value };
235
- const key = proposalEffectKey(candidate);
236
- if (proposals.some(p => proposalEffectKey(p) === key)) {
237
- outcome = 'folded'; // 접힌 사실을 남긴다 — "왜 카드가 하나뿐인가" 가 추적 가능해야 한다
238
- }
239
- else {
240
- proposals.push(candidate);
241
- outcome = 'proposed';
242
- }
243
- }
244
- else if (value && typeof value === 'object' && (value.rejected === true || value.error)) {
245
- outcome = 'rejected';
246
- }
247
- toolResultParts.push({
248
- type: 'tool_result',
249
- toolUseId: tc.id,
250
- content: JSON.stringify(value)
251
- });
252
- toolUsages.push({
253
- name: tc.name,
254
- arguments: tc.arguments ?? {},
255
- result: dispatch.summarizeToolResult(value),
256
- kind: 'read',
257
- iter,
258
- outcome
259
- });
260
- }
261
- else if (dispatch.isWriteTool(tc.name)) {
262
- // 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.
263
- // 실패 시 LLM 한테 detailed error 반환 → 다음 turn 에서 자기 회복 (retry).
264
- const components = (currentBoard?.components ?? []);
265
- const validation = dispatch.validateWriteToolCall(tc, components);
266
- if (!validation.valid) {
267
- const errorResult = {
268
- error: 'Tool args failed validation',
269
- issues: validation.errors,
270
- suggestion: validation.suggestion,
271
- note: 'Fix the args and call the tool again.'
272
- };
273
- toolResultParts.push({
274
- type: 'tool_result',
275
- toolUseId: tc.id,
276
- content: JSON.stringify(errorResult)
277
- });
278
- toolUsages.push({
279
- name: tc.name,
280
- arguments: tc.arguments ?? {},
281
- result: errorResult,
282
- kind: 'write',
283
- iter,
284
- outcome: 'rejected'
285
- });
286
- // continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.
287
- }
288
- else {
289
- accumulatedWriteCalls.push(tc);
290
- const queuedResult = { queued: true, note: 'Will be applied on the client.' };
291
- toolResultParts.push({
292
- type: 'tool_result',
293
- toolUseId: tc.id,
294
- content: JSON.stringify(queuedResult)
295
- });
296
- toolUsages.push({
297
- name: tc.name,
298
- arguments: tc.arguments ?? {},
299
- result: queuedResult,
300
- kind: 'write',
301
- iter,
302
- outcome: 'queued'
303
- });
304
- }
305
- }
306
- else if (dispatch.isActionTool(tc.name)) {
307
- // ephemeral scene 조작 — accumulatedActions 로 누적, ChatResponse.actions 로 client 전달
308
- const action = dispatch.toolCallToBoardActionOp(tc);
309
- if (action)
310
- accumulatedActions.push(action);
311
- const queuedResult = {
312
- queued: true,
313
- note: 'Will be executed on the modeller (ephemeral).'
314
- };
315
- toolResultParts.push({
316
- type: 'tool_result',
317
- toolUseId: tc.id,
318
- content: JSON.stringify(queuedResult)
319
- });
320
- toolUsages.push({
321
- name: tc.name,
322
- arguments: tc.arguments ?? {},
323
- result: queuedResult,
324
- kind: 'write',
325
- iter,
326
- outcome: 'queued'
327
- });
328
- }
329
- else {
330
- const errResult = { error: `Unknown tool: ${tc.name}` };
331
- toolResultParts.push({
332
- type: 'tool_result',
333
- toolUseId: tc.id,
334
- content: JSON.stringify(errResult),
335
- isError: true
336
- });
337
- toolUsages.push({
338
- name: tc.name,
339
- arguments: tc.arguments ?? {},
340
- result: errResult,
341
- kind: 'unknown',
342
- iter,
343
- outcome: 'error'
344
- });
345
- }
346
- }
347
- return toolResultParts;
348
- }
349
- //# sourceMappingURL=agentic-loop.js.map