@things-factory/board-ai 10.0.19 → 10.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-client/server/service/assistant.d.ts +1 -1
- package/dist-client/server/service/assistant.js +13 -6
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/types.d.ts +5 -29
- package/dist-client/server/service/types.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant.d.ts +1 -1
- package/dist-server/service/assistant.js +24 -17
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/types.d.ts +5 -29
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/service/assistant.ts +18 -7
- package/server/service/types.ts +6 -29
- package/dist-client/server/service/agentic-loop.d.ts +0 -121
- package/dist-client/server/service/agentic-loop.js +0 -349
- package/dist-client/server/service/agentic-loop.js.map +0 -1
- package/dist-server/service/agentic-loop.d.ts +0 -121
- package/dist-server/service/agentic-loop.js +0 -354
- package/dist-server/service/agentic-loop.js.map +0 -1
- package/server/service/agentic-loop.test.ts +0 -859
- package/server/service/agentic-loop.ts +0 -533
|
@@ -1,533 +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 type {
|
|
15
|
-
AIMessage,
|
|
16
|
-
AIToolCall,
|
|
17
|
-
AIToolChatResult
|
|
18
|
-
} from '@things-factory/ai-client-base'
|
|
19
|
-
import { retryTransient } from '@things-factory/ai-client-base'
|
|
20
|
-
import type { BoardModel } from '@things-factory/board-import'
|
|
21
|
-
import type { BoardActionOp, ToolUsage } from './types'
|
|
22
|
-
|
|
23
|
-
const log = debug('things-factory:board-ai:loop')
|
|
24
|
-
|
|
25
|
-
// ── Public types ─────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Loop 가 호출하는 dispatch 함수들. 모두 의존성 주입.
|
|
29
|
-
*
|
|
30
|
-
* assistant.ts 가 자기 자신의 함수 (isReadTool, executeReadTool 등) 를 그대로
|
|
31
|
-
* 넘겨주는 구조. 본 모듈은 이 함수들의 *시그니처* 만 알면 됨.
|
|
32
|
-
*/
|
|
33
|
-
export interface ToolDispatchHelpers {
|
|
34
|
-
isReadTool: (name: string) => boolean
|
|
35
|
-
isWriteTool: (name: string) => boolean
|
|
36
|
-
isActionTool: (name: string) => boolean
|
|
37
|
-
/** read/external 도구 실행. **Promise 를 반환할 수 있다** — 호출부는 반드시 await 한다. */
|
|
38
|
-
executeReadTool: (
|
|
39
|
-
tc: AIToolCall,
|
|
40
|
-
board: BoardModel | undefined,
|
|
41
|
-
selectedRefids: number[]
|
|
42
|
-
) => any | Promise<any>
|
|
43
|
-
validateWriteToolCall: (
|
|
44
|
-
tc: AIToolCall,
|
|
45
|
-
components: any[]
|
|
46
|
-
) => { valid: boolean; errors?: any[]; suggestion?: string }
|
|
47
|
-
toolCallToBoardActionOp: (tc: AIToolCall) => BoardActionOp | null
|
|
48
|
-
summarizeToolResult: (value: any) => any
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface AgenticLoopOptions {
|
|
52
|
-
systemPrompt: string
|
|
53
|
-
model?: string
|
|
54
|
-
temperature?: number
|
|
55
|
-
maxTokens?: number
|
|
56
|
-
/** 최대 iteration. 기본 8. */
|
|
57
|
-
maxIterations?: number
|
|
58
|
-
/**
|
|
59
|
-
* 사용자 abort 신호. iteration 시작 직전 / chat() 호출 후 체크.
|
|
60
|
-
* 이미 abort 된 신호로 들어오면 첫 iteration 도 안 돔.
|
|
61
|
-
*/
|
|
62
|
-
signal?: AbortSignal
|
|
63
|
-
/**
|
|
64
|
-
* 같은 tool 의 validation 실패가 N회 연속이면 조기 종료. 기본 3.
|
|
65
|
-
* LLM 이 같은 잘못을 무한 반복하는 경우 방어.
|
|
66
|
-
*/
|
|
67
|
-
repeatedFailureLimit?: number
|
|
68
|
-
/**
|
|
69
|
-
* **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).
|
|
70
|
-
*
|
|
71
|
-
* 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다
|
|
72
|
-
* ("지게차들이 바쁘게 움직이고 있다" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한
|
|
73
|
-
* 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할
|
|
74
|
-
* 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.
|
|
75
|
-
*
|
|
76
|
-
* 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.
|
|
77
|
-
*/
|
|
78
|
-
requireToolOnFirstTurn?: boolean
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface AgenticLoopInput {
|
|
82
|
-
initialConversation: AIMessage[]
|
|
83
|
-
tools: any[]
|
|
84
|
-
options: AgenticLoopOptions
|
|
85
|
-
/** LLM provider 의 chatWithTools 위임. provider-specific 구현. */
|
|
86
|
-
chat: (
|
|
87
|
-
messages: AIMessage[],
|
|
88
|
-
tools: any[],
|
|
89
|
-
options: any
|
|
90
|
-
) => Promise<AIToolChatResult>
|
|
91
|
-
dispatch: ToolDispatchHelpers
|
|
92
|
-
currentBoard: BoardModel | undefined
|
|
93
|
-
selectedRefids: number[]
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export interface AgenticLoopResult {
|
|
97
|
-
lastText: string | undefined
|
|
98
|
-
accumulatedWriteCalls: AIToolCall[]
|
|
99
|
-
accumulatedActions: BoardActionOp[]
|
|
100
|
-
toolUsages: ToolUsage[]
|
|
101
|
-
/**
|
|
102
|
-
* LLM 에게 실제로 회신된 도구 결과 문자열(시간순) — **접지 판정의 근거집합**.
|
|
103
|
-
*
|
|
104
|
-
* `toolUsages[].result` 를 쓰면 안 된다: 그건 UI 표시용으로 압축된(잘린) 값이라 근거로 삼으면
|
|
105
|
-
* 잘려 나간 식별자를 "근거 없음" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.
|
|
106
|
-
*/
|
|
107
|
-
groundedTexts: string[]
|
|
108
|
-
/**
|
|
109
|
-
* **제안** — 도구가 "실행하지 않고 제안만 했다" 고 알린 결과들(시간순).
|
|
110
|
-
*
|
|
111
|
-
* 규약: read/external 도구의 결과에 `proposed === true` 가 있으면 그것은 조치 제안이다. 그 도구가
|
|
112
|
-
* 어느 도메인인지 이 루프는 모른다 — 결과를 그대로 실어 올리고, 해석·실행 확인은 대화면이 한다.
|
|
113
|
-
* 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다(사람이 버튼을 누른다).
|
|
114
|
-
*/
|
|
115
|
-
proposals: any[]
|
|
116
|
-
/** LLM 마지막 응답의 stopReason. provider-specific 문자열. */
|
|
117
|
-
stopReason: string
|
|
118
|
-
/**
|
|
119
|
-
* 비정상 종료 사유 — 정상 종료 시 undefined.
|
|
120
|
-
* 호출자 (assistant.ts) 가 사용자 메시지에 반영 (예: '시도 중 오류가 발생했어요').
|
|
121
|
-
*/
|
|
122
|
-
abortReason?:
|
|
123
|
-
| { type: 'provider_error'; message: string; iter: number }
|
|
124
|
-
| { type: 'aborted'; iter: number }
|
|
125
|
-
| { type: 'max_iterations'; iter: number }
|
|
126
|
-
| { type: 'repeated_validation_failure'; toolName: string; consecutive: number; iter: number }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── Loop ─────────────────────────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
const DEFAULT_MAX_ITERATIONS = 8
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* multi-turn agentic loop.
|
|
135
|
-
*
|
|
136
|
-
* LLM 이 read tool 을 호출 → 서버에서 즉시 실행 → 결과 회신 → LLM 추론 반복.
|
|
137
|
-
* write tool 은 누적해 client 에 patch 로 전달. 무한 루프 방지를 위해 iteration cap.
|
|
138
|
-
*
|
|
139
|
-
* 본 함수는 *순수* — input 만 보고 결정. 외부 mutable state 없음.
|
|
140
|
-
*/
|
|
141
|
-
export async function runAgenticLoop(
|
|
142
|
-
input: AgenticLoopInput
|
|
143
|
-
): Promise<AgenticLoopResult> {
|
|
144
|
-
const {
|
|
145
|
-
initialConversation,
|
|
146
|
-
tools,
|
|
147
|
-
options,
|
|
148
|
-
chat,
|
|
149
|
-
dispatch,
|
|
150
|
-
currentBoard,
|
|
151
|
-
selectedRefids
|
|
152
|
-
} = input
|
|
153
|
-
|
|
154
|
-
const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS
|
|
155
|
-
const failureLimit = options.repeatedFailureLimit ?? 3
|
|
156
|
-
const signal = options.signal
|
|
157
|
-
|
|
158
|
-
let conversation = initialConversation
|
|
159
|
-
const accumulatedWriteCalls: AIToolCall[] = []
|
|
160
|
-
const accumulatedActions: BoardActionOp[] = []
|
|
161
|
-
const toolUsages: ToolUsage[] = []
|
|
162
|
-
/* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */
|
|
163
|
-
const groundedTexts: string[] = []
|
|
164
|
-
/* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */
|
|
165
|
-
const proposals: any[] = []
|
|
166
|
-
let lastText: string | undefined
|
|
167
|
-
let stopReason: string = 'end_turn'
|
|
168
|
-
let abortReason: AgenticLoopResult['abortReason']
|
|
169
|
-
|
|
170
|
-
// 같은 tool 의 validation 실패 연속 카운터 — 다른 tool 호출 시 reset
|
|
171
|
-
let lastFailedToolName: string | undefined
|
|
172
|
-
let consecutiveFailures = 0
|
|
173
|
-
|
|
174
|
-
let iter = 0
|
|
175
|
-
for (; iter < maxIterations; iter++) {
|
|
176
|
-
// (1) AbortSignal — iteration 시작 시 체크
|
|
177
|
-
if (signal?.aborted) {
|
|
178
|
-
log('iter %d: aborted by signal', iter)
|
|
179
|
-
abortReason = { type: 'aborted', iter }
|
|
180
|
-
break
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
log('iter %d: chat() 호출', iter)
|
|
184
|
-
let result: AIToolChatResult
|
|
185
|
-
try {
|
|
186
|
-
/* (2) Error boundary — provider 예외 시 graceful 종료.
|
|
187
|
-
*
|
|
188
|
-
* 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini
|
|
189
|
-
* 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는
|
|
190
|
-
* 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */
|
|
191
|
-
result = await retryTransient(() => chat(conversation, tools, {
|
|
192
|
-
systemPrompt: options.systemPrompt,
|
|
193
|
-
model: options.model,
|
|
194
|
-
temperature: options.temperature,
|
|
195
|
-
maxTokens: options.maxTokens,
|
|
196
|
-
/* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */
|
|
197
|
-
toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',
|
|
198
|
-
allowParallelToolCalls: true,
|
|
199
|
-
signal
|
|
200
|
-
}), {
|
|
201
|
-
onRetry: ({ attempt, delayMs, error }) =>
|
|
202
|
-
log('iter %d: 일시 장애 재시도 %d회 (%dms 후): %s', iter, attempt, delayMs, error?.message)
|
|
203
|
-
})
|
|
204
|
-
} catch (e: any) {
|
|
205
|
-
// AbortError — 별도 분류
|
|
206
|
-
if (e?.name === 'AbortError' || signal?.aborted) {
|
|
207
|
-
log('iter %d: provider AbortError', iter)
|
|
208
|
-
abortReason = { type: 'aborted', iter }
|
|
209
|
-
} else {
|
|
210
|
-
log('iter %d: provider error: %s', iter, e?.message)
|
|
211
|
-
abortReason = {
|
|
212
|
-
type: 'provider_error',
|
|
213
|
-
message: e?.message ?? String(e),
|
|
214
|
-
iter
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
break
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
lastText = result.text || lastText
|
|
221
|
-
stopReason = result.stopReason
|
|
222
|
-
|
|
223
|
-
log(
|
|
224
|
-
'iter %d: text=%s, toolCalls=%d, stopReason=%s',
|
|
225
|
-
iter,
|
|
226
|
-
result.text ? `"${result.text.slice(0, 60)}..."` : '(none)',
|
|
227
|
-
result.toolCalls.length,
|
|
228
|
-
result.stopReason
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
if (result.toolCalls.length === 0) break
|
|
232
|
-
|
|
233
|
-
// (1b) 삭제: chat() 직후 abort 는 다음 iter 시작 시 (1) 에서 감지.
|
|
234
|
-
// 현재 iter tool 실행을 중단하면 read tool 결과도 버려져 테스트/동작 불일치.
|
|
235
|
-
|
|
236
|
-
// tool 결과 빌드 — read 는 즉시 실행, write 는 누적, action 은 별도 채널
|
|
237
|
-
const beforeUsages = toolUsages.length
|
|
238
|
-
const toolResultParts = await processToolCalls(
|
|
239
|
-
result.toolCalls,
|
|
240
|
-
currentBoard,
|
|
241
|
-
selectedRefids,
|
|
242
|
-
dispatch,
|
|
243
|
-
accumulatedWriteCalls,
|
|
244
|
-
accumulatedActions,
|
|
245
|
-
toolUsages,
|
|
246
|
-
proposals,
|
|
247
|
-
iter
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
/* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */
|
|
251
|
-
for (const part of toolResultParts) {
|
|
252
|
-
if (typeof part?.content === 'string') groundedTexts.push(part.content)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort
|
|
256
|
-
const newUsages = toolUsages.slice(beforeUsages)
|
|
257
|
-
const repeatedFailureAbort = detectRepeatedValidationFailure(
|
|
258
|
-
newUsages,
|
|
259
|
-
lastFailedToolName,
|
|
260
|
-
consecutiveFailures,
|
|
261
|
-
failureLimit
|
|
262
|
-
)
|
|
263
|
-
if (repeatedFailureAbort.shouldAbort) {
|
|
264
|
-
log(
|
|
265
|
-
'iter %d: repeated validation failure on %s (%d consecutive) — abort',
|
|
266
|
-
iter,
|
|
267
|
-
repeatedFailureAbort.toolName,
|
|
268
|
-
repeatedFailureAbort.consecutive
|
|
269
|
-
)
|
|
270
|
-
abortReason = {
|
|
271
|
-
type: 'repeated_validation_failure',
|
|
272
|
-
toolName: repeatedFailureAbort.toolName,
|
|
273
|
-
consecutive: repeatedFailureAbort.consecutive,
|
|
274
|
-
iter
|
|
275
|
-
}
|
|
276
|
-
break
|
|
277
|
-
}
|
|
278
|
-
lastFailedToolName = repeatedFailureAbort.lastFailedToolName
|
|
279
|
-
consecutiveFailures = repeatedFailureAbort.consecutive
|
|
280
|
-
|
|
281
|
-
// assistant 의 tool_use turn + user 의 tool_result turn 을 conversation 에 누적
|
|
282
|
-
const assistantContent: any[] = []
|
|
283
|
-
if (result.text) assistantContent.push({ type: 'text', text: result.text })
|
|
284
|
-
for (const tc of result.toolCalls) {
|
|
285
|
-
// providerMeta (Gemini thoughtSignature 등) 를 그대로 round-trip — 안 보내면 400.
|
|
286
|
-
assistantContent.push({
|
|
287
|
-
type: 'tool_use',
|
|
288
|
-
id: tc.id,
|
|
289
|
-
name: tc.name,
|
|
290
|
-
arguments: tc.arguments,
|
|
291
|
-
...(tc.providerMeta && { providerMeta: tc.providerMeta })
|
|
292
|
-
})
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
conversation = [
|
|
296
|
-
...conversation,
|
|
297
|
-
{ role: 'assistant', content: assistantContent },
|
|
298
|
-
{ role: 'user', content: toolResultParts }
|
|
299
|
-
]
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// (3) max iterations 도달 시 사용자에게 표면화
|
|
303
|
-
if (iter >= maxIterations && !abortReason) {
|
|
304
|
-
log('iter %d: max iterations reached', iter)
|
|
305
|
-
abortReason = { type: 'max_iterations', iter }
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return {
|
|
309
|
-
lastText,
|
|
310
|
-
accumulatedWriteCalls,
|
|
311
|
-
accumulatedActions,
|
|
312
|
-
toolUsages,
|
|
313
|
-
groundedTexts,
|
|
314
|
-
proposals,
|
|
315
|
-
stopReason,
|
|
316
|
-
abortReason
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).
|
|
322
|
-
*
|
|
323
|
-
* 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로
|
|
324
|
-
* 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.
|
|
325
|
-
*/
|
|
326
|
-
export function proposalEffectKey(p: any): string {
|
|
327
|
-
return [p?.tool ?? '', p?.command ?? '', p?.instanceId ?? '', stableStringify(p?.args)].join('|')
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */
|
|
331
|
-
function stableStringify(value: any): string {
|
|
332
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
|
|
333
|
-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
334
|
-
const keys = Object.keys(value).sort()
|
|
335
|
-
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* 같은 tool 의 validation 실패 연속 감지 — pure helper.
|
|
340
|
-
*
|
|
341
|
-
* 같은 tool 이 반복 fail 하면 LLM 이 self-correct 못 하는 상태.
|
|
342
|
-
* 다른 tool 이 fail 하면 시퀀스 새로 시작 (전혀 다른 패턴이라).
|
|
343
|
-
* tool 이 succeed 하면 카운터 reset.
|
|
344
|
-
*/
|
|
345
|
-
function detectRepeatedValidationFailure(
|
|
346
|
-
newUsages: ToolUsage[],
|
|
347
|
-
lastFailedToolName: string | undefined,
|
|
348
|
-
consecutiveFailures: number,
|
|
349
|
-
limit: number
|
|
350
|
-
): {
|
|
351
|
-
shouldAbort: boolean
|
|
352
|
-
toolName: string
|
|
353
|
-
consecutive: number
|
|
354
|
-
lastFailedToolName: string | undefined
|
|
355
|
-
} {
|
|
356
|
-
// 이번 iteration 에서 실패한 write tool 들 (validation 거절)
|
|
357
|
-
const failed = newUsages.filter(
|
|
358
|
-
u => u.kind === 'write' && (u.result as any)?.error === 'Tool args failed validation'
|
|
359
|
-
)
|
|
360
|
-
|
|
361
|
-
if (failed.length === 0) {
|
|
362
|
-
// 실패 없음 — reset
|
|
363
|
-
return {
|
|
364
|
-
shouldAbort: false,
|
|
365
|
-
toolName: '',
|
|
366
|
-
consecutive: 0,
|
|
367
|
-
lastFailedToolName: undefined
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// 모든 실패가 같은 tool 인지 확인
|
|
372
|
-
const firstName = failed[0].name
|
|
373
|
-
const allSame = failed.every(u => u.name === firstName)
|
|
374
|
-
|
|
375
|
-
if (allSame && firstName === lastFailedToolName) {
|
|
376
|
-
const consecutive = consecutiveFailures + 1
|
|
377
|
-
return {
|
|
378
|
-
shouldAbort: consecutive >= limit,
|
|
379
|
-
toolName: firstName,
|
|
380
|
-
consecutive,
|
|
381
|
-
lastFailedToolName: firstName
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// 다른 tool 또는 첫 실패
|
|
386
|
-
return {
|
|
387
|
-
shouldAbort: false,
|
|
388
|
-
toolName: firstName,
|
|
389
|
-
consecutive: 1,
|
|
390
|
-
lastFailedToolName: firstName
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* 한 iteration 의 tool 결과를 분류·실행·누적. (loop 에서 분리해 읽기 쉽게.)
|
|
396
|
-
*/
|
|
397
|
-
async function processToolCalls(
|
|
398
|
-
toolCalls: AIToolCall[],
|
|
399
|
-
currentBoard: BoardModel | undefined,
|
|
400
|
-
selectedRefids: number[],
|
|
401
|
-
dispatch: ToolDispatchHelpers,
|
|
402
|
-
accumulatedWriteCalls: AIToolCall[],
|
|
403
|
-
accumulatedActions: BoardActionOp[],
|
|
404
|
-
toolUsages: ToolUsage[],
|
|
405
|
-
proposals: any[],
|
|
406
|
-
/** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */
|
|
407
|
-
iter: number
|
|
408
|
-
): Promise<any[]> {
|
|
409
|
-
const toolResultParts: any[] = []
|
|
410
|
-
|
|
411
|
-
for (const tc of toolCalls) {
|
|
412
|
-
if (dispatch.isReadTool(tc.name)) {
|
|
413
|
-
/* await 필수 — registry 에 등록된 read/external builder 는 async 일 수 있다(DB·외부 호출).
|
|
414
|
-
* await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다
|
|
415
|
-
* (board-import 의 getImportSession/importBoardAsync, operato-twin 의 getTwinStructure 등). */
|
|
416
|
-
const value = await dispatch.executeReadTool(tc, currentBoard, selectedRefids)
|
|
417
|
-
/* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).
|
|
418
|
-
* 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */
|
|
419
|
-
/* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */
|
|
420
|
-
let outcome: ToolUsage['outcome'] = 'ok'
|
|
421
|
-
if (value && typeof value === 'object' && (value as any).proposed === true) {
|
|
422
|
-
/* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가
|
|
423
|
-
* 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.
|
|
424
|
-
* 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */
|
|
425
|
-
const candidate = { tool: tc.name, ...(value as any) }
|
|
426
|
-
const key = proposalEffectKey(candidate)
|
|
427
|
-
if (proposals.some(p => proposalEffectKey(p) === key)) {
|
|
428
|
-
outcome = 'folded' // 접힌 사실을 남긴다 — "왜 카드가 하나뿐인가" 가 추적 가능해야 한다
|
|
429
|
-
} else {
|
|
430
|
-
proposals.push(candidate)
|
|
431
|
-
outcome = 'proposed'
|
|
432
|
-
}
|
|
433
|
-
} else if (value && typeof value === 'object' && ((value as any).rejected === true || (value as any).error)) {
|
|
434
|
-
outcome = 'rejected'
|
|
435
|
-
}
|
|
436
|
-
toolResultParts.push({
|
|
437
|
-
type: 'tool_result',
|
|
438
|
-
toolUseId: tc.id,
|
|
439
|
-
content: JSON.stringify(value)
|
|
440
|
-
})
|
|
441
|
-
toolUsages.push({
|
|
442
|
-
name: tc.name,
|
|
443
|
-
arguments: tc.arguments ?? {},
|
|
444
|
-
result: dispatch.summarizeToolResult(value),
|
|
445
|
-
kind: 'read',
|
|
446
|
-
iter,
|
|
447
|
-
outcome
|
|
448
|
-
})
|
|
449
|
-
} else if (dispatch.isWriteTool(tc.name)) {
|
|
450
|
-
// 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.
|
|
451
|
-
// 실패 시 LLM 한테 detailed error 반환 → 다음 turn 에서 자기 회복 (retry).
|
|
452
|
-
const components = (currentBoard?.components ?? []) as any[]
|
|
453
|
-
const validation = dispatch.validateWriteToolCall(tc, components)
|
|
454
|
-
if (!validation.valid) {
|
|
455
|
-
const errorResult = {
|
|
456
|
-
error: 'Tool args failed validation',
|
|
457
|
-
issues: validation.errors,
|
|
458
|
-
suggestion: validation.suggestion,
|
|
459
|
-
note: 'Fix the args and call the tool again.'
|
|
460
|
-
}
|
|
461
|
-
toolResultParts.push({
|
|
462
|
-
type: 'tool_result',
|
|
463
|
-
toolUseId: tc.id,
|
|
464
|
-
content: JSON.stringify(errorResult)
|
|
465
|
-
})
|
|
466
|
-
toolUsages.push({
|
|
467
|
-
name: tc.name,
|
|
468
|
-
arguments: tc.arguments ?? {},
|
|
469
|
-
result: errorResult,
|
|
470
|
-
kind: 'write',
|
|
471
|
-
iter,
|
|
472
|
-
outcome: 'rejected'
|
|
473
|
-
})
|
|
474
|
-
// continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.
|
|
475
|
-
} else {
|
|
476
|
-
accumulatedWriteCalls.push(tc)
|
|
477
|
-
const queuedResult = { queued: true, note: 'Will be applied on the client.' }
|
|
478
|
-
toolResultParts.push({
|
|
479
|
-
type: 'tool_result',
|
|
480
|
-
toolUseId: tc.id,
|
|
481
|
-
content: JSON.stringify(queuedResult)
|
|
482
|
-
})
|
|
483
|
-
toolUsages.push({
|
|
484
|
-
name: tc.name,
|
|
485
|
-
arguments: tc.arguments ?? {},
|
|
486
|
-
result: queuedResult,
|
|
487
|
-
kind: 'write',
|
|
488
|
-
iter,
|
|
489
|
-
outcome: 'queued'
|
|
490
|
-
})
|
|
491
|
-
}
|
|
492
|
-
} else if (dispatch.isActionTool(tc.name)) {
|
|
493
|
-
// ephemeral scene 조작 — accumulatedActions 로 누적, ChatResponse.actions 로 client 전달
|
|
494
|
-
const action = dispatch.toolCallToBoardActionOp(tc)
|
|
495
|
-
if (action) accumulatedActions.push(action)
|
|
496
|
-
const queuedResult = {
|
|
497
|
-
queued: true,
|
|
498
|
-
note: 'Will be executed on the modeller (ephemeral).'
|
|
499
|
-
}
|
|
500
|
-
toolResultParts.push({
|
|
501
|
-
type: 'tool_result',
|
|
502
|
-
toolUseId: tc.id,
|
|
503
|
-
content: JSON.stringify(queuedResult)
|
|
504
|
-
})
|
|
505
|
-
toolUsages.push({
|
|
506
|
-
name: tc.name,
|
|
507
|
-
arguments: tc.arguments ?? {},
|
|
508
|
-
result: queuedResult,
|
|
509
|
-
kind: 'write',
|
|
510
|
-
iter,
|
|
511
|
-
outcome: 'queued'
|
|
512
|
-
})
|
|
513
|
-
} else {
|
|
514
|
-
const errResult = { error: `Unknown tool: ${tc.name}` }
|
|
515
|
-
toolResultParts.push({
|
|
516
|
-
type: 'tool_result',
|
|
517
|
-
toolUseId: tc.id,
|
|
518
|
-
content: JSON.stringify(errResult),
|
|
519
|
-
isError: true
|
|
520
|
-
})
|
|
521
|
-
toolUsages.push({
|
|
522
|
-
name: tc.name,
|
|
523
|
-
arguments: tc.arguments ?? {},
|
|
524
|
-
result: errResult,
|
|
525
|
-
kind: 'unknown',
|
|
526
|
-
iter,
|
|
527
|
-
outcome: 'error'
|
|
528
|
-
})
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
return toolResultParts
|
|
533
|
-
}
|