@things-factory/board-ai 10.0.1 → 10.0.3

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 (90) hide show
  1. package/client/components/board-ai-chat.ts +565 -21
  2. package/client/components/chat-echo-dedup.test.ts +59 -3
  3. package/client/components/chat-echo-dedup.ts +32 -0
  4. package/client/components/chat-input-builder.ts +6 -0
  5. package/dist-client/client/components/board-ai-chat.d.ts +80 -0
  6. package/dist-client/client/components/board-ai-chat.js +540 -18
  7. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  8. package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
  9. package/dist-client/client/components/chat-echo-dedup.js +29 -0
  10. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  11. package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
  12. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
  13. package/dist-client/client/components/chat-input-builder.d.ts +5 -0
  14. package/dist-client/client/components/chat-input-builder.js +1 -0
  15. package/dist-client/client/components/chat-input-builder.js.map +1 -1
  16. package/dist-client/server/service/agentic-loop.d.ts +33 -0
  17. package/dist-client/server/service/agentic-loop.js +80 -10
  18. package/dist-client/server/service/agentic-loop.js.map +1 -1
  19. package/dist-client/server/service/assistant.js +32 -5
  20. package/dist-client/server/service/assistant.js.map +1 -1
  21. package/dist-client/server/service/grounding.d.ts +17 -0
  22. package/dist-client/server/service/grounding.js +42 -0
  23. package/dist-client/server/service/grounding.js.map +1 -0
  24. package/dist-client/server/service/types.d.ts +39 -0
  25. package/dist-client/server/service/types.js.map +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-server/service/agentic-loop.d.ts +33 -0
  28. package/dist-server/service/agentic-loop.js +81 -10
  29. package/dist-server/service/agentic-loop.js.map +1 -1
  30. package/dist-server/service/assistant.js +31 -4
  31. package/dist-server/service/assistant.js.map +1 -1
  32. package/dist-server/service/board-ai-resolver.d.ts +15 -0
  33. package/dist-server/service/board-ai-resolver.js +121 -2
  34. package/dist-server/service/board-ai-resolver.js.map +1 -1
  35. package/dist-server/service/chat-message/chat-message.d.ts +12 -0
  36. package/dist-server/service/chat-message/chat-message.js +23 -0
  37. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  38. package/dist-server/service/chat-message/fold-history.d.ts +30 -0
  39. package/dist-server/service/chat-message/fold-history.js +29 -0
  40. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  41. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  42. package/dist-server/service/chat-message/history-summary.js +77 -0
  43. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  44. package/dist-server/service/chat-message/llm-history.d.ts +19 -0
  45. package/dist-server/service/chat-message/llm-history.js +31 -1
  46. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  47. package/dist-server/service/chat-session/chat-session.d.ts +8 -0
  48. package/dist-server/service/chat-session/chat-session.js +5 -0
  49. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  50. package/dist-server/service/chat-session/session-inbox.d.ts +26 -0
  51. package/dist-server/service/chat-session/session-inbox.js +41 -0
  52. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  53. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
  54. package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
  55. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
  56. package/dist-server/service/chat-session-resolver.d.ts +44 -1
  57. package/dist-server/service/chat-session-resolver.js +306 -6
  58. package/dist-server/service/chat-session-resolver.js.map +1 -1
  59. package/dist-server/service/grounding.d.ts +17 -0
  60. package/dist-server/service/grounding.js +46 -0
  61. package/dist-server/service/grounding.js.map +1 -0
  62. package/dist-server/service/types.d.ts +39 -0
  63. package/dist-server/service/types.js.map +1 -1
  64. package/dist-server/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +6 -6
  66. package/server/service/agentic-loop.test.ts +154 -0
  67. package/server/service/agentic-loop.ts +108 -10
  68. package/server/service/assistant.ts +36 -5
  69. package/server/service/board-ai-resolver.ts +131 -2
  70. package/server/service/chat-message/chat-message.ts +26 -0
  71. package/server/service/chat-message/fold-history.test.ts +98 -0
  72. package/server/service/chat-message/fold-history.ts +60 -0
  73. package/server/service/chat-message/history-summary.test.ts +127 -0
  74. package/server/service/chat-message/history-summary.ts +100 -0
  75. package/server/service/chat-message/llm-history.test.ts +65 -0
  76. package/server/service/chat-message/llm-history.ts +48 -1
  77. package/server/service/chat-session/chat-session.ts +11 -0
  78. package/server/service/chat-session/session-inbox.test.ts +69 -1
  79. package/server/service/chat-session/session-inbox.ts +45 -0
  80. package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
  81. package/server/service/chat-session-resolver.ts +297 -5
  82. package/server/service/dock-contract.test.ts +305 -0
  83. package/server/service/grounding.test.ts +55 -0
  84. package/server/service/grounding.ts +53 -0
  85. package/server/service/types.ts +39 -0
  86. package/translations/en.json +16 -1
  87. package/translations/ja.json +16 -1
  88. package/translations/ko.json +15 -0
  89. package/translations/ms.json +16 -1
  90. package/translations/zh.json +16 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-ai",
3
- "version": "10.0.1",
3
+ "version": "10.0.3",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/client/index.js",
6
6
  "things-factory": true,
@@ -30,11 +30,11 @@
30
30
  "@operato/graphql": "^10.0.0",
31
31
  "@operato/i18n": "^10.0.0",
32
32
  "@operato/styles": "^10.0.0",
33
- "@things-factory/ai-client-base": "^10.0.0",
34
- "@things-factory/auth-base": "^10.0.0",
35
- "@things-factory/board-import": "^10.0.1",
33
+ "@things-factory/ai-client-base": "^10.0.3",
34
+ "@things-factory/auth-base": "^10.0.3",
35
+ "@things-factory/board-import": "^10.0.3",
36
36
  "@things-factory/env": "^10.0.0",
37
- "@things-factory/shell": "^10.0.0",
37
+ "@things-factory/shell": "^10.0.3",
38
38
  "dompurify": "^3.0.0",
39
39
  "graphql-tag": "^2.12.6",
40
40
  "lit": "^3.1.2",
@@ -44,5 +44,5 @@
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
46
  },
47
- "gitHead": "6dd5b25ed1fe863415793bd156c3411ffe1ce47d"
47
+ "gitHead": "c7d253b722b6917e8911f6ef6e78e5fc2b295e82"
48
48
  }
@@ -703,3 +703,157 @@ describe('runAgenticLoop — currentBoard / selectedRefids 전달', () => {
703
703
  expect(receivedComponents).toEqual([{ refid: 7 }])
704
704
  })
705
705
  })
706
+
707
+ describe('runAgenticLoop — 접지 근거 수집', () => {
708
+ test('도구 결과 원문을 groundedTexts 에 담는다 — 압축본(toolUsages.result)이 아니라 원문이어야 한다', async () => {
709
+ let callCount = 0
710
+ const input = mkInput(
711
+ {
712
+ chat: async () => {
713
+ callCount++
714
+ return callCount === 1 ? okResult(undefined, [TC('getTwinStructure', {})]) : okResult('done')
715
+ }
716
+ },
717
+ {
718
+ isReadTool: () => true,
719
+ executeReadTool: () => ({ nodes: [{ id: 'dock-1' }, { id: 'dock-2' }] }),
720
+ /* 표시용 압축이 식별자를 잘라내도 근거는 온전해야 한다. */
721
+ summarizeToolResult: () => ({ nodes: '…(생략)' })
722
+ }
723
+ )
724
+ const r = await runAgenticLoop(input)
725
+ expect(r.groundedTexts).toEqual([JSON.stringify({ nodes: [{ id: 'dock-1' }, { id: 'dock-2' }] })])
726
+ expect(r.toolUsages[0].result).toEqual({ nodes: '…(생략)' })
727
+ })
728
+
729
+ test('도구를 부르지 않으면 근거가 비어 있다 — 그 답은 접지될 수 없다', async () => {
730
+ const r = await runAgenticLoop(mkInput({ chat: async () => okResult('바쁘게 움직이고 있습니다') }))
731
+ expect(r.groundedTexts).toEqual([])
732
+ })
733
+ })
734
+
735
+ describe('runAgenticLoop — 첫 턴 도구 강제(근거 없는 단언 차단)', () => {
736
+ /** 각 iteration 의 toolChoice 를 기록하는 mock. */
737
+ const recordChoices = (turns: number) => {
738
+ const choices: any[] = []
739
+ let i = 0
740
+ return {
741
+ choices,
742
+ chat: async (_m: any, _t: any, opts: any) => {
743
+ choices.push(opts.toolChoice)
744
+ i++
745
+ return i < turns ? okResult(undefined, [TC('getSelection', {})]) : okResult('done')
746
+ }
747
+ }
748
+ }
749
+
750
+ test('켜면 첫 턴만 required, 두 번째부터 auto — 조회 결과를 받고도 또 부르지 않게', async () => {
751
+ const rec = recordChoices(2)
752
+ await runAgenticLoop(
753
+ mkInput(
754
+ { chat: rec.chat, options: { systemPrompt: 'x', requireToolOnFirstTurn: true } },
755
+ { isReadTool: () => true, executeReadTool: () => ({ ok: 1 }) }
756
+ )
757
+ )
758
+ expect(rec.choices).toEqual(['required', 'auto'])
759
+ })
760
+
761
+ test('끄면 기존 그대로 auto — 라이브 상태를 다루지 않는 대화면은 강제하지 않는다', async () => {
762
+ const rec = recordChoices(2)
763
+ await runAgenticLoop(
764
+ mkInput({ chat: rec.chat }, { isReadTool: () => true, executeReadTool: () => ({ ok: 1 }) })
765
+ )
766
+ expect(rec.choices).toEqual(['auto', 'auto'])
767
+ })
768
+ })
769
+
770
+ describe('runAgenticLoop — 제안 채널(실행은 사용자)', () => {
771
+ test('도구 결과에 proposed:true 가 있으면 제안으로 올린다 — 대화면이 실행 버튼을 그릴 근거', async () => {
772
+ let i = 0
773
+ const input = mkInput(
774
+ {
775
+ chat: async () => {
776
+ i++
777
+ return i === 1 ? okResult(undefined, [TC('proposeAction', { command: 'order.hold' })]) : okResult('확인해 주세요')
778
+ }
779
+ },
780
+ {
781
+ isReadTool: () => true,
782
+ executeReadTool: () => ({
783
+ proposed: true,
784
+ command: 'order.hold',
785
+ args: { orderId: 'o7' },
786
+ label: 'o7 보류',
787
+ instanceId: 'busan-wms'
788
+ })
789
+ }
790
+ )
791
+ const r = await runAgenticLoop(input)
792
+ expect(r.proposals).toHaveLength(1)
793
+ expect(r.proposals[0]).toMatchObject({ tool: 'proposeAction', command: 'order.hold', instanceId: 'busan-wms' })
794
+ })
795
+
796
+ test('평범한 조회 결과는 제안이 아니다 — 규약은 proposed 플래그 하나뿐', async () => {
797
+ const r = await runAgenticLoop(
798
+ mkInput(
799
+ {
800
+ chat: async () => okResult('상태 정상')
801
+ },
802
+ { isReadTool: () => true, executeReadTool: () => ({ occupancy: 0 }) }
803
+ )
804
+ )
805
+ expect(r.proposals).toEqual([])
806
+ })
807
+ })
808
+
809
+ describe('runAgenticLoop — 같은 조치는 한 번만 제안된다', () => {
810
+ /* 실제 사고: 모델이 같은 조치를 두 번 제안했고(하나는 인자가 비어 있었다) 카드가 둘 떴다.
811
+ * 하나를 실행한 뒤에도 나머지가 살아 있어 **같은 명령을 두 번** 보낼 수 있었다. */
812
+ test('효과가 같은 제안은 하나로 접는다 — 설명 문구가 달라도 같은 조치다', async () => {
813
+ let i = 0
814
+ const input = mkInput(
815
+ {
816
+ chat: async () => {
817
+ i++
818
+ return i === 1
819
+ ? okResult(undefined, [TC('proposeAction', { n: 1 }, 'p1'), TC('proposeAction', { n: 2 }, 'p2')])
820
+ : okResult('확인해 주세요')
821
+ }
822
+ },
823
+ {
824
+ isReadTool: () => true,
825
+ /* 같은 명령·같은 인자(키 순서만 다름), 설명만 다르게 — 같은 조치다. */
826
+ executeReadTool: (tc: any) =>
827
+ tc.id === 'p1'
828
+ ? { proposed: true, command: 'resource.add', instanceId: 'w1', args: { kind: 'fk', count: 3 }, label: 'A', reason: '가' }
829
+ : { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 3, kind: 'fk' }, label: 'B', reason: '나' }
830
+ }
831
+ )
832
+ const r = await runAgenticLoop(input)
833
+ expect(r.proposals).toHaveLength(1)
834
+ expect(r.proposals[0].label).toBe('A') // 먼저 온 것을 남긴다
835
+ })
836
+
837
+ test('효과가 다르면 각각 남는다 — 서로 다른 결정이다', async () => {
838
+ let i = 0
839
+ const input = mkInput(
840
+ {
841
+ chat: async () => {
842
+ i++
843
+ return i === 1
844
+ ? okResult(undefined, [TC('proposeAction', {}, 'p1'), TC('proposeAction', {}, 'p2')])
845
+ : okResult('확인')
846
+ }
847
+ },
848
+ {
849
+ isReadTool: () => true,
850
+ executeReadTool: (tc: any) =>
851
+ tc.id === 'p1'
852
+ ? { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 3 } }
853
+ : { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 5 } }
854
+ }
855
+ )
856
+ const r = await runAgenticLoop(input)
857
+ expect(r.proposals).toHaveLength(2)
858
+ })
859
+ })
@@ -16,6 +16,7 @@ import type {
16
16
  AIToolCall,
17
17
  AIToolChatResult
18
18
  } from '@things-factory/ai-client-base'
19
+ import { retryTransient } from '@things-factory/ai-client-base'
19
20
  import type { BoardModel } from '@things-factory/board-import'
20
21
  import type { BoardActionOp, ToolUsage } from './types'
21
22
 
@@ -64,6 +65,17 @@ export interface AgenticLoopOptions {
64
65
  * LLM 이 같은 잘못을 무한 반복하는 경우 방어.
65
66
  */
66
67
  repeatedFailureLimit?: number
68
+ /**
69
+ * **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).
70
+ *
71
+ * 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다
72
+ * ("지게차들이 바쁘게 움직이고 있다" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한
73
+ * 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할
74
+ * 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.
75
+ *
76
+ * 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.
77
+ */
78
+ requireToolOnFirstTurn?: boolean
67
79
  }
68
80
 
69
81
  export interface AgenticLoopInput {
@@ -86,6 +98,21 @@ export interface AgenticLoopResult {
86
98
  accumulatedWriteCalls: AIToolCall[]
87
99
  accumulatedActions: BoardActionOp[]
88
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[]
89
116
  /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */
90
117
  stopReason: string
91
118
  /**
@@ -132,6 +159,10 @@ export async function runAgenticLoop(
132
159
  const accumulatedWriteCalls: AIToolCall[] = []
133
160
  const accumulatedActions: BoardActionOp[] = []
134
161
  const toolUsages: ToolUsage[] = []
162
+ /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */
163
+ const groundedTexts: string[] = []
164
+ /* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */
165
+ const proposals: any[] = []
135
166
  let lastText: string | undefined
136
167
  let stopReason: string = 'end_turn'
137
168
  let abortReason: AgenticLoopResult['abortReason']
@@ -152,15 +183,23 @@ export async function runAgenticLoop(
152
183
  log('iter %d: chat() 호출', iter)
153
184
  let result: AIToolChatResult
154
185
  try {
155
- // (2) Error boundary — provider 예외 시 graceful 종료
156
- result = await chat(conversation, tools, {
186
+ /* (2) Error boundary — provider 예외 시 graceful 종료.
187
+ *
188
+ * 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini
189
+ * 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는
190
+ * 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */
191
+ result = await retryTransient(() => chat(conversation, tools, {
157
192
  systemPrompt: options.systemPrompt,
158
193
  model: options.model,
159
194
  temperature: options.temperature,
160
195
  maxTokens: options.maxTokens,
161
- toolChoice: 'auto',
196
+ /* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */
197
+ toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',
162
198
  allowParallelToolCalls: true,
163
199
  signal
200
+ }), {
201
+ onRetry: ({ attempt, delayMs, error }) =>
202
+ log('iter %d: 일시 장애 재시도 %d회 (%dms 후): %s', iter, attempt, delayMs, error?.message)
164
203
  })
165
204
  } catch (e: any) {
166
205
  // AbortError — 별도 분류
@@ -203,9 +242,16 @@ export async function runAgenticLoop(
203
242
  dispatch,
204
243
  accumulatedWriteCalls,
205
244
  accumulatedActions,
206
- toolUsages
245
+ toolUsages,
246
+ proposals,
247
+ iter
207
248
  )
208
249
 
250
+ /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */
251
+ for (const part of toolResultParts) {
252
+ if (typeof part?.content === 'string') groundedTexts.push(part.content)
253
+ }
254
+
209
255
  // (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort
210
256
  const newUsages = toolUsages.slice(beforeUsages)
211
257
  const repeatedFailureAbort = detectRepeatedValidationFailure(
@@ -264,11 +310,31 @@ export async function runAgenticLoop(
264
310
  accumulatedWriteCalls,
265
311
  accumulatedActions,
266
312
  toolUsages,
313
+ groundedTexts,
314
+ proposals,
267
315
  stopReason,
268
316
  abortReason
269
317
  }
270
318
  }
271
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
+
272
338
  /**
273
339
  * 같은 tool 의 validation 실패 연속 감지 — pure helper.
274
340
  *
@@ -335,7 +401,10 @@ async function processToolCalls(
335
401
  dispatch: ToolDispatchHelpers,
336
402
  accumulatedWriteCalls: AIToolCall[],
337
403
  accumulatedActions: BoardActionOp[],
338
- toolUsages: ToolUsage[]
404
+ toolUsages: ToolUsage[],
405
+ proposals: any[],
406
+ /** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */
407
+ iter: number
339
408
  ): Promise<any[]> {
340
409
  const toolResultParts: any[] = []
341
410
 
@@ -345,6 +414,25 @@ async function processToolCalls(
345
414
  * await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다
346
415
  * (board-import 의 getImportSession/importBoardAsync, operato-twin 의 getTwinStructure 등). */
347
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
+ }
348
436
  toolResultParts.push({
349
437
  type: 'tool_result',
350
438
  toolUseId: tc.id,
@@ -354,7 +442,9 @@ async function processToolCalls(
354
442
  name: tc.name,
355
443
  arguments: tc.arguments ?? {},
356
444
  result: dispatch.summarizeToolResult(value),
357
- kind: 'read'
445
+ kind: 'read',
446
+ iter,
447
+ outcome
358
448
  })
359
449
  } else if (dispatch.isWriteTool(tc.name)) {
360
450
  // 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.
@@ -377,7 +467,9 @@ async function processToolCalls(
377
467
  name: tc.name,
378
468
  arguments: tc.arguments ?? {},
379
469
  result: errorResult,
380
- kind: 'write'
470
+ kind: 'write',
471
+ iter,
472
+ outcome: 'rejected'
381
473
  })
382
474
  // continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.
383
475
  } else {
@@ -392,7 +484,9 @@ async function processToolCalls(
392
484
  name: tc.name,
393
485
  arguments: tc.arguments ?? {},
394
486
  result: queuedResult,
395
- kind: 'write'
487
+ kind: 'write',
488
+ iter,
489
+ outcome: 'queued'
396
490
  })
397
491
  }
398
492
  } else if (dispatch.isActionTool(tc.name)) {
@@ -412,7 +506,9 @@ async function processToolCalls(
412
506
  name: tc.name,
413
507
  arguments: tc.arguments ?? {},
414
508
  result: queuedResult,
415
- kind: 'write'
509
+ kind: 'write',
510
+ iter,
511
+ outcome: 'queued'
416
512
  })
417
513
  } else {
418
514
  const errResult = { error: `Unknown tool: ${tc.name}` }
@@ -426,7 +522,9 @@ async function processToolCalls(
426
522
  name: tc.name,
427
523
  arguments: tc.arguments ?? {},
428
524
  result: errResult,
429
- kind: 'unknown'
525
+ kind: 'unknown',
526
+ iter,
527
+ outcome: 'error'
430
528
  })
431
529
  }
432
530
  }
@@ -34,6 +34,7 @@ import { resolveCatalogMentions } from './catalog-resolver'
34
34
  // 직접 import 안 함 — 신규 styling tool 추가 시 registry 에만 spec 추가하면 됨.
35
35
  import { buildComponentStyleSummary } from './styling/read-tools'
36
36
  import { runAgenticLoop, type AgenticLoopResult } from './agentic-loop'
37
+ import { checkReplyGrounding } from './grounding'
37
38
  import { collectAllComponents, findComponentByRefid } from './component-tree'
38
39
  import {
39
40
  isStylingTool,
@@ -46,7 +47,9 @@ import {
46
47
  getToolKind,
47
48
  findToolSpec,
48
49
  type ToolSpec,
49
- type ToolCategoryFilter
50
+ type ToolCategoryFilter,
51
+ getCategoryGuidance,
52
+ isTransientAIError
50
53
  } from '@things-factory/ai-client-base'
51
54
  import { validateWriteToolCall } from './validation/tool-validation'
52
55
 
@@ -152,7 +155,16 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
152
155
  * 정의만 숨기면 모델이 이름을 추측하거나 옛 대화가 재생될 때 숨긴 도구가 실행된다. */
153
156
  const gate = buildToolGate({ boardTools: options.boardTools, toolCategories: options.toolCategories })
154
157
  const tools = buildBoardEditTools(knownTypes, { boardTools: options.boardTools, toolCategories: options.toolCategories })
155
- const systemPrompt = this.buildSystemPromptForTools(knownTypes, categories, schemas)
158
+ /* 노출된 도구 카테고리의 **사용 규율**을 프롬프트에 함께 싣는다.
159
+ *
160
+ * 도구 목록만 보내면 모델은 "무엇이 있는지" 는 알지만 "언제 반드시 불러야 하는지" 는 모른다.
161
+ * 실제로 그 공백에서 사고가 났다 — 조치 요청에 제안 도구를 부르지 않고 "제안해 두었습니다" 라고만
162
+ * 답해, 사용자에게 없는 버튼을 누르라고 안내했다. 규율은 도구를 등록한 쪽이 소유하고 여기서는
163
+ * 모아 붙이기만 한다(도메인 지식이 board-ai 프롬프트로 새지 않는다). */
164
+ const guidance = getCategoryGuidance(options.toolCategories)
165
+ const systemPrompt =
166
+ this.buildSystemPromptForTools(knownTypes, categories, schemas) +
167
+ (guidance.length ? `\n\n==== TOOL USE RULES (from the tools exposed to this conversation) ====\n${guidance.join('\n')}` : '')
156
168
 
157
169
  const selectedRefids = (options.selectedRefids ?? []).filter(
158
170
  (n): n is number => typeof n === 'number' && Number.isFinite(n)
@@ -222,7 +234,9 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
222
234
  systemPrompt,
223
235
  model: options.model,
224
236
  temperature: options.temperature,
225
- maxTokens: options.maxTokens
237
+ maxTokens: options.maxTokens,
238
+ /* 라이브 상태를 다루는 대화면은 첫 턴에 조회를 강제한다 — 근거 없이 상태를 단언할 길을 막는다. */
239
+ requireToolOnFirstTurn: options.requireGroundingTools
226
240
  },
227
241
  chat: (msgs, tools, opts) => this.base.chatWithTools!(msgs, tools, opts),
228
242
  dispatch: {
@@ -267,12 +281,24 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
267
281
  }
268
282
  }
269
283
 
284
+ /* 접지 검사 — 답이 **모델이 받은 것** 밖의 식별자를 지목했는지. 답은 고치지 않고 경고만 낸다.
285
+ * 근거집합의 정의와 사정거리는 ./grounding 머리말 참조(식별자 없는 창작 서술은 잡지 못한다). */
286
+ const warnings = checkReplyGrounding(baseReply, {
287
+ systemPrompt,
288
+ boardContext,
289
+ messages: messages.map(m => (typeof m.content === 'string' ? m.content : '')),
290
+ toolResults: loopResult.groundedTexts
291
+ })
292
+
270
293
  return {
271
294
  reply,
272
295
  patch,
273
296
  followUp: undefined,
274
297
  toolUsages: toolUsages.length > 0 ? toolUsages : undefined,
275
- actions: accumulatedActions.length > 0 ? accumulatedActions : undefined
298
+ actions: accumulatedActions.length > 0 ? accumulatedActions : undefined,
299
+ /* 제안은 실행 전 상태다 — 대화면이 확인 버튼을 그린다. 서버는 실행하지 않는다. */
300
+ proposals: loopResult.proposals.length > 0 ? loopResult.proposals : undefined,
301
+ groundingWarnings: warnings.length > 0 ? warnings : undefined
276
302
  }
277
303
  }
278
304
 
@@ -1967,7 +1993,12 @@ export function formatAbortNotice(
1967
1993
  case 'aborted':
1968
1994
  return '\n\n_(요청이 중단되어 일부만 처리됐어요.)_'
1969
1995
  case 'provider_error':
1970
- return `\n\n_(처리 중 오류가 발생했어요: ${abortReason.message})_`
1996
+ /* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다 — "Error fetching from undefined: [503 …]" 은
1997
+ * 사용자가 할 수 있는 일을 알려주지 않는다. 일시 장애는 이미 몇 번 다시 시도한 뒤이므로,
1998
+ * 지금 필요한 안내는 "잠시 후 다시" 하나다. 원문은 서버 로그에 남는다. */
1999
+ return isTransientAIError({ message: abortReason.message })
2000
+ ? '\n\n_(AI 서비스가 일시적으로 응답하지 않았어요. 잠시 후 다시 시도해 주세요.)_'
2001
+ : '\n\n_(처리 중 오류가 발생했어요. 같은 요청이 계속 실패하면 관리자에게 알려 주세요.)_'
1971
2002
  case 'max_iterations':
1972
2003
  return `\n\n_(작업이 너무 길어 ${abortReason.iter}회에서 중단했어요. 더 작게 나눠 요청해보세요.)_`
1973
2004
  case 'repeated_validation_failure':