@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.
- package/client/components/board-ai-chat.ts +565 -21
- package/client/components/chat-echo-dedup.test.ts +59 -3
- package/client/components/chat-echo-dedup.ts +32 -0
- package/client/components/chat-input-builder.ts +6 -0
- package/dist-client/client/components/board-ai-chat.d.ts +80 -0
- package/dist-client/client/components/board-ai-chat.js +540 -18
- package/dist-client/client/components/board-ai-chat.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/client/components/chat-echo-dedup.js +29 -0
- package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
- package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
- package/dist-client/client/components/chat-input-builder.d.ts +5 -0
- package/dist-client/client/components/chat-input-builder.js +1 -0
- package/dist-client/client/components/chat-input-builder.js.map +1 -1
- package/dist-client/server/service/agentic-loop.d.ts +33 -0
- package/dist-client/server/service/agentic-loop.js +80 -10
- package/dist-client/server/service/agentic-loop.js.map +1 -1
- package/dist-client/server/service/assistant.js +32 -5
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/grounding.d.ts +17 -0
- package/dist-client/server/service/grounding.js +42 -0
- package/dist-client/server/service/grounding.js.map +1 -0
- package/dist-client/server/service/types.d.ts +39 -0
- package/dist-client/server/service/types.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/agentic-loop.d.ts +33 -0
- package/dist-server/service/agentic-loop.js +81 -10
- package/dist-server/service/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant.js +31 -4
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/board-ai-resolver.d.ts +15 -0
- package/dist-server/service/board-ai-resolver.js +121 -2
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +12 -0
- package/dist-server/service/chat-message/chat-message.js +23 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/fold-history.d.ts +30 -0
- package/dist-server/service/chat-message/fold-history.js +29 -0
- package/dist-server/service/chat-message/fold-history.js.map +1 -0
- package/dist-server/service/chat-message/history-summary.d.ts +43 -0
- package/dist-server/service/chat-message/history-summary.js +77 -0
- package/dist-server/service/chat-message/history-summary.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +19 -0
- package/dist-server/service/chat-message/llm-history.js +31 -1
- package/dist-server/service/chat-message/llm-history.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +8 -0
- package/dist-server/service/chat-session/chat-session.js +5 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +26 -0
- package/dist-server/service/chat-session/session-inbox.js +41 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +44 -1
- package/dist-server/service/chat-session-resolver.js +306 -6
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/grounding.d.ts +17 -0
- package/dist-server/service/grounding.js +46 -0
- package/dist-server/service/grounding.js.map +1 -0
- package/dist-server/service/types.d.ts +39 -0
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/agentic-loop.test.ts +154 -0
- package/server/service/agentic-loop.ts +108 -10
- package/server/service/assistant.ts +36 -5
- package/server/service/board-ai-resolver.ts +131 -2
- package/server/service/chat-message/chat-message.ts +26 -0
- package/server/service/chat-message/fold-history.test.ts +98 -0
- package/server/service/chat-message/fold-history.ts +60 -0
- package/server/service/chat-message/history-summary.test.ts +127 -0
- package/server/service/chat-message/history-summary.ts +100 -0
- package/server/service/chat-message/llm-history.test.ts +65 -0
- package/server/service/chat-message/llm-history.ts +48 -1
- package/server/service/chat-session/chat-session.ts +11 -0
- package/server/service/chat-session/session-inbox.test.ts +69 -1
- package/server/service/chat-session/session-inbox.ts +45 -0
- package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
- package/server/service/chat-session-resolver.ts +297 -5
- package/server/service/dock-contract.test.ts +305 -0
- package/server/service/grounding.test.ts +55 -0
- package/server/service/grounding.ts +53 -0
- package/server/service/types.ts +39 -0
- package/translations/en.json +16 -1
- package/translations/ja.json +16 -1
- package/translations/ko.json +15 -0
- package/translations/ms.json +16 -1
- 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.
|
|
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.
|
|
34
|
-
"@things-factory/auth-base": "^10.0.
|
|
35
|
-
"@things-factory/board-import": "^10.0.
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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':
|