@things-factory/ai-assistant 10.1.206 → 10.1.207
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/assistant-chat.ts +2 -1
- package/client/components/chat-input-builder.test.ts +5 -0
- package/client/components/chat-input-builder.ts +4 -1
- package/dist-client/components/assistant-chat.js +2 -1
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +2 -0
- package/dist-client/components/chat-input-builder.js +2 -1
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/components/chat-input-builder.test.js +4 -0
- package/dist-client/components/chat-input-builder.test.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
- package/dist-server/service/assistant-chat-resolver.js +20 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -0
- package/dist-server/service/assistant-mode/mode-categories.js +37 -1
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/registry.js +20 -0
- package/dist-server/service/assistant-mode/registry.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +6 -1
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-message/used-tools.d.ts +11 -0
- package/dist-server/service/chat-message/used-tools.js +33 -0
- package/dist-server/service/chat-message/used-tools.js.map +1 -0
- package/dist-server/service/testing/mode-conformance.d.ts +4 -0
- package/dist-server/service/testing/mode-conformance.js +4 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/service/unattended-run/run-unattended.js +2 -0
- package/dist-server/service/unattended-run/run-unattended.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/assistant-chat-resolver.ts +21 -1
- package/server/service/assistant-mode/mode-categories.ts +47 -1
- package/server/service/assistant-mode/mode-front.test.ts +65 -0
- package/server/service/assistant-mode/open-subject-door.test.ts +16 -0
- package/server/service/assistant-mode/registry.test.ts +56 -0
- package/server/service/assistant-mode/registry.ts +20 -2
- package/server/service/assistant-mode/types.ts +14 -2
- package/server/service/chat-message/used-tools.test.ts +19 -0
- package/server/service/chat-message/used-tools.ts +25 -0
- package/server/service/testing/mode-conformance.ts +7 -1
- package/server/service/unattended-run/run-unattended.ts +2 -0
- package/test/assistant-dock.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.207",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@operato/shell": "^10.29.39",
|
|
36
36
|
"@operato/styles": "^10.0.0",
|
|
37
37
|
"@operato/utils": "^10.29.39",
|
|
38
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
38
|
+
"@things-factory/ai-client-base": "^10.1.207",
|
|
39
39
|
"@things-factory/apptool-base": "^10.1.206",
|
|
40
40
|
"@things-factory/attachment-base": "^10.1.206",
|
|
41
41
|
"@things-factory/auth-base": "^10.1.206",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"copyfiles": "^2.4.1",
|
|
56
56
|
"rimraf": "^5.0.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "e6e3fbf5c353629adbeb8850093150b14254e059"
|
|
59
59
|
}
|
|
@@ -27,7 +27,7 @@ import { GraphQLJSON } from 'graphql-scalars'
|
|
|
27
27
|
import '@things-factory/auth-base'
|
|
28
28
|
|
|
29
29
|
import { getRepository, rethrowSpoken } from '@things-factory/shell'
|
|
30
|
-
import { aiClientFor, findToolSpec, openSubjectLine, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
|
|
30
|
+
import { aiClientFor, findToolSpec, openSubjectLine, OPEN_TOOLS, registryAssistantChat, type AgentClient } from '@things-factory/ai-client-base'
|
|
31
31
|
|
|
32
32
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
33
33
|
import { assertStationSession } from './chat-session/station-session-guard.js'
|
|
@@ -54,6 +54,8 @@ import { attachmentForModel, describeAttachment, findChatAttachment } from './ch
|
|
|
54
54
|
import { readTurnAttachments } from './chat-session/turn-attachments.js'
|
|
55
55
|
import { getAssistantMode } from './assistant-mode/registry.js'
|
|
56
56
|
import { resolveModeSettings } from './assistant-mode/resolve.js'
|
|
57
|
+
import { modeFrontCategories } from './assistant-mode/mode-categories.js'
|
|
58
|
+
import { usedToolNames } from './chat-message/used-tools.js'
|
|
57
59
|
import { splitNextRequest } from './assistant-mode/screen-rules.js'
|
|
58
60
|
import { requestTranslator } from './request-translator.js'
|
|
59
61
|
|
|
@@ -134,6 +136,13 @@ class AssistantChatInput {
|
|
|
134
136
|
'The AI agent the person picked for this turn, from aiAgentChoices. Omitted takes the domain default. An agent this domain cannot use is refused - the provider, the model and the key are always the server\'s.'
|
|
135
137
|
})
|
|
136
138
|
agentId?: string
|
|
139
|
+
|
|
140
|
+
@Field({
|
|
141
|
+
nullable: true,
|
|
142
|
+
description:
|
|
143
|
+
'The route of the page the dock is on (cash-transaction-list). It decides which tools the model is offered first; what a person may use is decided as without it.'
|
|
144
|
+
})
|
|
145
|
+
page?: string
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
@ObjectType()
|
|
@@ -506,6 +515,17 @@ export class AssistantChatResolver {
|
|
|
506
515
|
messages: conversation as any,
|
|
507
516
|
systemPrompt,
|
|
508
517
|
toolCategories: settings.toolCategories,
|
|
518
|
+
/*
|
|
519
|
+
* What goes in front when the mode's tools do not fit in one call (ADR-0061 decision 9): the open page's and the
|
|
520
|
+
* anchored record's, then what this conversation used. The anchor is the stored one, never the client's.
|
|
521
|
+
*/
|
|
522
|
+
front: {
|
|
523
|
+
categories: modeFrontCategories(getAssistantMode(input.mode.kind) ?? {}, {
|
|
524
|
+
page: input.page,
|
|
525
|
+
subjectAxis: (session as any)?.anchorType === 'subject' ? String((session as any).anchorId ?? '').split(':')[0] || undefined : undefined
|
|
526
|
+
}),
|
|
527
|
+
tools: usedToolNames(storedRows as any, [OPEN_TOOLS])
|
|
528
|
+
},
|
|
509
529
|
/* 도구가 「어느 대화에서 불렸나」를 안다 — 서버의 값이다(ADR-0061 결정 3). */
|
|
510
530
|
mode: { kind: input.mode.kind, ...(input.mode.target ? { target: input.mode.target } : {}) },
|
|
511
531
|
toolAccess: toolAccessFor(context),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* Read by the resolver and by the boot report, so the report and the conversation open the same tools. */
|
|
2
|
+
import { readTargetOf } from '../data-read/read-target.js'
|
|
2
3
|
/**
|
|
3
4
|
* The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
|
|
4
5
|
* takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
|
|
@@ -23,3 +24,48 @@ export function modeToolCategories(mode: { toolCategories?: string[]; attachment
|
|
|
23
24
|
export function unattendedToolCategories(mode: { toolCategories?: string[]; readTargets?: string[] }): string[] {
|
|
24
25
|
return [...new Set([...(mode.toolCategories ?? []), ...(mode.readTargets ?? []).map(key => `data-read:${key}`)])]
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
/** The screen a turn is asked from - the page the dock is on, and the record its conversation is anchored to. */
|
|
29
|
+
export interface TurnScreen {
|
|
30
|
+
/** The application's route of the open page (`cash-transaction-list`), as the dock sends it. */
|
|
31
|
+
page?: string
|
|
32
|
+
/** The axis of the record the conversation is anchored to (`contract` of `subject` · `contract:<id>`), from the stored anchor. */
|
|
33
|
+
subjectAxis?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type FrontMode = { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The records the open screen is about, among those the mode reads and enters (ADR-0061 decision 9). Nothing new is
|
|
40
|
+
* declared: a read target's screen route is the page that lists it, and a key's last part is the axis of its record
|
|
41
|
+
* (`office.contract` is the record of the axis `contract`) - reading and entering one record share its key.
|
|
42
|
+
*/
|
|
43
|
+
export function screenTargetKeys(mode: FrontMode, screen: TurnScreen, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): string[] {
|
|
44
|
+
const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
|
|
45
|
+
return keys.filter(key => (!!screen.page && routeOf(key) === screen.page) || (!!screen.subjectAxis && key.split('.').pop() === screen.subjectAxis))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The categories a turn puts in front when the mode's tools do not fit in one call: the mode's own, the AI 작업판, the
|
|
50
|
+
* attachment reader, and the open screen's records. The rest the model opens with `openTools`.
|
|
51
|
+
*/
|
|
52
|
+
export function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): string[] {
|
|
53
|
+
const keys = screenTargetKeys(mode, screen)
|
|
54
|
+
return [
|
|
55
|
+
...new Set([
|
|
56
|
+
...(mode.toolCategories ?? []),
|
|
57
|
+
'assistant-workspace',
|
|
58
|
+
...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),
|
|
59
|
+
...keys.filter(key => (mode.readTargets ?? []).includes(key)).map(key => `data-read:${key}`),
|
|
60
|
+
...keys.filter(key => (mode.entryTargets ?? []).includes(key)).map(key => `data-entry:${key}`)
|
|
61
|
+
])
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Each screen the mode could be asked from - a page that lists one of its records, a record's axis - for the boot report. */
|
|
66
|
+
export function modeScreens(mode: FrontMode, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): TurnScreen[] {
|
|
67
|
+
const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]
|
|
68
|
+
const pages = [...new Set(keys.map(routeOf).filter(Boolean) as string[])]
|
|
69
|
+
const axes = [...new Set(keys.map(key => key.split('.').pop()!))]
|
|
70
|
+
return [{}, ...pages.map(page => ({ page })), ...axes.map(subjectAxis => ({ subjectAxis }))]
|
|
71
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What a turn puts in front when its mode's tools do not fit in one call (ADR-0061 decision 9) - the open screen's
|
|
3
|
+
* records, found from what is already declared: a list's screen route and a key's last part.
|
|
4
|
+
*/
|
|
5
|
+
import { clearReadTargets, declareReadTarget } from '../data-read/read-target'
|
|
6
|
+
import { listReadTarget } from '../data-read/list-read'
|
|
7
|
+
import { modeFrontCategories, modeScreens, screenTargetKeys } from './mode-categories'
|
|
8
|
+
|
|
9
|
+
const list = (key: string, route?: string) =>
|
|
10
|
+
listReadTarget({ key, title: `title.${key}`, name: key, listQuery: 'rows', selection: 'id name', columns: [{ name: 'name', type: 'string' }], ...(route ? { route } : {}) } as any)
|
|
11
|
+
|
|
12
|
+
const mode = {
|
|
13
|
+
toolCategories: ['office-ops'],
|
|
14
|
+
attachments: { kinds: ['image'] },
|
|
15
|
+
readTargets: ['office.cash-transaction', 'office.contract', 'office.contract-allocation', 'office.loan-summary'],
|
|
16
|
+
entryTargets: ['office.cash-transaction', 'office.contract', 'office.contract-round', 'office.expense-line']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
clearReadTargets()
|
|
21
|
+
declareReadTarget(list('office.cash-transaction', 'cash-transaction-list'))
|
|
22
|
+
declareReadTarget(list('office.contract', 'contract-list'))
|
|
23
|
+
declareReadTarget(list('office.contract-allocation', 'contract-allocation-list'))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('the open screen decides what goes in front', () => {
|
|
27
|
+
it('★ a list page: that list read and its record entered - one key, both ways', () => {
|
|
28
|
+
expect(modeFrontCategories(mode, { page: 'cash-transaction-list' })).toEqual([
|
|
29
|
+
'office-ops',
|
|
30
|
+
'assistant-workspace',
|
|
31
|
+
'chat-attachments',
|
|
32
|
+
'data-read:office.cash-transaction',
|
|
33
|
+
'data-entry:office.cash-transaction'
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('★ a record\'s conversation: the keys whose last part is its axis - never a key that only starts with it', () => {
|
|
38
|
+
expect(screenTargetKeys(mode, { subjectAxis: 'contract' })).toEqual(['office.contract'])
|
|
39
|
+
expect(screenTargetKeys(mode, { subjectAxis: 'contract-round' })).toEqual(['office.contract-round'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('★ a record the mode does not read or enter is not put in front', () => {
|
|
43
|
+
expect(modeFrontCategories({ ...mode, readTargets: [], entryTargets: [] }, { page: 'cash-transaction-list' })).toEqual(['office-ops', 'assistant-workspace', 'chat-attachments'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('no screen: the mode\'s own, the AI 작업판 and the attachment reader', () => {
|
|
47
|
+
expect(modeFrontCategories(mode)).toEqual(['office-ops', 'assistant-workspace', 'chat-attachments'])
|
|
48
|
+
expect(modeFrontCategories(mode, { page: 'some-other-page' })).toEqual(['office-ops', 'assistant-workspace', 'chat-attachments'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('the screens the boot report walks: none, each list page, each axis', () => {
|
|
52
|
+
expect(modeScreens(mode)).toEqual([
|
|
53
|
+
{},
|
|
54
|
+
{ page: 'cash-transaction-list' },
|
|
55
|
+
{ page: 'contract-list' },
|
|
56
|
+
{ page: 'contract-allocation-list' },
|
|
57
|
+
{ subjectAxis: 'cash-transaction' },
|
|
58
|
+
{ subjectAxis: 'contract' },
|
|
59
|
+
{ subjectAxis: 'contract-allocation' },
|
|
60
|
+
{ subjectAxis: 'loan-summary' },
|
|
61
|
+
{ subjectAxis: 'contract-round' },
|
|
62
|
+
{ subjectAxis: 'expense-line' }
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -27,3 +27,19 @@ describe('the door', () => {
|
|
|
27
27
|
expect(door).not.toMatch(/content: result\.reply/)
|
|
28
28
|
})
|
|
29
29
|
})
|
|
30
|
+
|
|
31
|
+
describe('what the door puts in front (ADR-0061 decision 9)', () => {
|
|
32
|
+
const block = door.slice(door.indexOf(' front: {'), door.indexOf(' /* 도구가 「어느 대화에서 불렸나」를 안다'))
|
|
33
|
+
|
|
34
|
+
it('★ the open page, the stored anchor\'s axis and the tools this conversation used - never the anchor the screen sent', () => {
|
|
35
|
+
expect(block).toMatch(/modeFrontCategories\(getAssistantMode\(input\.mode\.kind\) \?\? \{\}, \{/)
|
|
36
|
+
expect(block).toMatch(/page: input\.page,/)
|
|
37
|
+
expect(block).toMatch(/\(session as any\)\?\.anchorType === 'subject' \? String\(\(session as any\)\.anchorId/)
|
|
38
|
+
expect(block).toMatch(/tools: usedToolNames\(storedRows as any, \[OPEN_TOOLS\]\)/)
|
|
39
|
+
expect(block).not.toMatch(/hostContext/)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('★ the page is a field of its own on the input, never read out of the host context', () => {
|
|
43
|
+
expect(door).toMatch(/\n page\?: string\n\}\n\n@ObjectType\(\)/)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -113,3 +113,59 @@ it('★ a slash sentence may need a read target\'s tool - the check opens what t
|
|
|
113
113
|
registerAssistantMode(spec({ readTargets: ['office.contract'], slashTemplates: [{ name: 'contracts', descriptionKey: 'k', templateKey: 't', tools: ['queryOfficeContract'] }] }))
|
|
114
114
|
expect(getAssistantModeWarnings()).toEqual([])
|
|
115
115
|
})
|
|
116
|
+
|
|
117
|
+
describe('what a turn puts in front fits in one call (ADR-0061 decision 9)', () => {
|
|
118
|
+
const many = (prefix: string, count: number) =>
|
|
119
|
+
Array.from({ length: count }, (_, i) => {
|
|
120
|
+
registerToolCategory({
|
|
121
|
+
name: `${prefix}-${i}`,
|
|
122
|
+
guidance: 'call it before answering',
|
|
123
|
+
specs: [{ kind: 'read', name: `${prefix}Tool${i}`, description: 'one tool', schema: {}, builder: () => ({}) } as any]
|
|
124
|
+
} as any)
|
|
125
|
+
return `${prefix}-${i}`
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('★ a mode whose own tools do not leave room for openTools is reported by code at boot', () => {
|
|
129
|
+
registerAssistantMode(spec({ kind: 'office.finance', toolCategories: many('own', 70) }))
|
|
130
|
+
|
|
131
|
+
expect(getAssistantModeWarnings()).toContainEqual(
|
|
132
|
+
expect.objectContaining({ code: 'assistant-mode-front-over-budget', params: expect.objectContaining({ kind: 'office.finance', front: 70, budget: 64 }) })
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('★ a large mode whose front fits is not reported - the rest is opened as needed', () => {
|
|
137
|
+
const lists = Array.from({ length: 100 }, (_, i) => `office.list-${i}`)
|
|
138
|
+
for (const key of lists) {
|
|
139
|
+
registerToolCategory({
|
|
140
|
+
name: `data-read:${key}`,
|
|
141
|
+
guidance: 'call it before answering',
|
|
142
|
+
specs: [{ kind: 'read', name: `query${key.replace(/\W/g, '')}`, description: 'one list', schema: {}, builder: () => ({}) } as any]
|
|
143
|
+
} as any)
|
|
144
|
+
}
|
|
145
|
+
registerAssistantMode(spec({ kind: 'office.finance', toolCategories: many('own', 10), readTargets: lists }))
|
|
146
|
+
|
|
147
|
+
expect(getAssistantModeWarnings().filter(warning => warning.code === 'assistant-mode-front-over-budget')).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('★ the front counts the screen\'s record and openTools - 62 of its own fit, 63 do not', () => {
|
|
151
|
+
const lists = Array.from({ length: 10 }, (_, i) => `office.list-${i}`)
|
|
152
|
+
for (const key of lists) {
|
|
153
|
+
registerToolCategory({
|
|
154
|
+
name: `data-read:${key}`,
|
|
155
|
+
guidance: 'call it before answering',
|
|
156
|
+
specs: [{ kind: 'read', name: `query${key.replace(/\W/g, '')}`, description: 'one list', schema: {}, builder: () => ({}) } as any]
|
|
157
|
+
} as any)
|
|
158
|
+
}
|
|
159
|
+
registerAssistantMode(spec({ kind: 'office.fits', toolCategories: many('fits', 62), readTargets: lists }))
|
|
160
|
+
registerAssistantMode(spec({ kind: 'office.full', toolCategories: many('full', 63), readTargets: lists }))
|
|
161
|
+
|
|
162
|
+
const over = getAssistantModeWarnings().filter(warning => warning.code === 'assistant-mode-front-over-budget')
|
|
163
|
+
expect(over.map(warning => [warning.params.kind, warning.params.front])).toEqual([['office.full', 64]])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('a mode that fits whole is offered whole and never reported', () => {
|
|
167
|
+
registerAssistantMode(spec({ toolCategories: many('small', 60) }))
|
|
168
|
+
|
|
169
|
+
expect(getAssistantModeWarnings().filter(warning => warning.code === 'assistant-mode-front-over-budget')).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* after every module is in.
|
|
13
13
|
*/
|
|
14
14
|
import { entryTargetOf } from '../data-entry/entry-target.js'
|
|
15
|
-
import { getToolSpecsFor } from '@things-factory/ai-client-base'
|
|
16
|
-
import { modeToolCategories } from './mode-categories'
|
|
15
|
+
import { getToolSpecsFor, MAX_OFFERED_TOOLS } from '@things-factory/ai-client-base'
|
|
16
|
+
import { modeFrontCategories, modeScreens, modeToolCategories } from './mode-categories'
|
|
17
17
|
|
|
18
18
|
import type { AssistantModeSpec, AssistantModeWarning } from './types'
|
|
19
19
|
|
|
@@ -107,6 +107,24 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
for (const mode of modes.values()) {
|
|
111
|
+
/*
|
|
112
|
+
* What a turn puts in front, on the screen that puts the most (ADR-0061 decision 9). The front is offered whole or
|
|
113
|
+
* the turn is refused, so a mode whose front does not fit with openTools fails every turn of that screen.
|
|
114
|
+
*/
|
|
115
|
+
const answering = (categories: string[]) => getToolSpecsFor(categories).filter(spec => spec.kind === 'read').length
|
|
116
|
+
if (answering(modeToolCategories(mode) ?? []) <= MAX_OFFERED_TOOLS) continue
|
|
117
|
+
for (const screen of modeScreens(mode)) {
|
|
118
|
+
const front = answering(modeFrontCategories(mode, screen))
|
|
119
|
+
if (front + 1 <= MAX_OFFERED_TOOLS) continue
|
|
120
|
+
out.push({
|
|
121
|
+
code: 'assistant-mode-front-over-budget',
|
|
122
|
+
params: { kind: mode.kind, front, budget: MAX_OFFERED_TOOLS, ...screen },
|
|
123
|
+
message: `mode '${mode.kind}' puts ${front} tools in front${screen.page ? ` on '${screen.page}'` : ''}${screen.subjectAxis ? ` for a '${screen.subjectAxis}'` : ''} - with openTools more than one call carries (${MAX_OFFERED_TOOLS}). Narrow its own tool categories.`
|
|
124
|
+
})
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
}
|
|
110
128
|
return out
|
|
111
129
|
}
|
|
112
130
|
|
|
@@ -118,9 +118,21 @@ export interface AssistantModeWarning {
|
|
|
118
118
|
* `assistant-mode-slash-outside-tools` — a `/` sentence needs a tool this mode does not open.
|
|
119
119
|
* `assistant-mode-subject-claimed-twice` — two conversations name the same subject type.
|
|
120
120
|
* `assistant-mode-entry-target-unknown` — a record the mode enters that no application registered.
|
|
121
|
+
* `assistant-mode-front-over-budget` — what a turn puts in front on some screen does not fit in one call (ADR-0061 decision 9).
|
|
121
122
|
*/
|
|
122
|
-
code: 'assistant-mode-slash-outside-tools' | 'assistant-mode-subject-claimed-twice' | 'assistant-mode-entry-target-unknown'
|
|
123
|
-
params: {
|
|
123
|
+
code: 'assistant-mode-slash-outside-tools' | 'assistant-mode-subject-claimed-twice' | 'assistant-mode-entry-target-unknown' | 'assistant-mode-front-over-budget'
|
|
124
|
+
params: {
|
|
125
|
+
kind: string
|
|
126
|
+
slash?: string
|
|
127
|
+
tool?: string
|
|
128
|
+
subject?: string
|
|
129
|
+
kinds?: string[]
|
|
130
|
+
target?: string
|
|
131
|
+
front?: number
|
|
132
|
+
budget?: number
|
|
133
|
+
page?: string
|
|
134
|
+
subjectAxis?: string
|
|
135
|
+
}
|
|
124
136
|
/** For a person reading the boot log; callers act on the code and params. */
|
|
125
137
|
message: string
|
|
126
138
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { usedToolNames } from './used-tools'
|
|
2
|
+
|
|
3
|
+
const answer = (...names: string[]) => ({ role: 'assistant', toolUsages: JSON.stringify(names.map(name => ({ name, arguments: {}, kind: 'read' }))) })
|
|
4
|
+
|
|
5
|
+
describe('the tools a conversation used, for the front of the next turn (ADR-0061 decision 9)', () => {
|
|
6
|
+
it('★ the latest answer first, the latest call of it first, each once', () => {
|
|
7
|
+
const rows = [answer('readA', 'readB'), { role: 'user', toolUsages: undefined }, answer('readC', 'readA')]
|
|
8
|
+
expect(usedToolNames(rows)).toEqual(['readA', 'readC', 'readB'])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('★ the opening tool is not a tool to put in front', () => {
|
|
12
|
+
expect(usedToolNames([answer('openTools', 'readA')], ['openTools'])).toEqual(['readA'])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('a person\'s line and a trace that does not read are skipped', () => {
|
|
16
|
+
const rows = [{ role: 'user', toolUsages: JSON.stringify([{ name: 'readX' }]) }, { role: 'assistant', toolUsages: '{broken' }, answer('readA')]
|
|
17
|
+
expect(usedToolNames(rows)).toEqual(['readA'])
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tools the earlier answers of a conversation called, the latest first (ADR-0061 decision 9).
|
|
3
|
+
*
|
|
4
|
+
* A turn puts them in front after the open screen's records, so a question that follows on from the last one finds the
|
|
5
|
+
* tool it needs without opening it again. Read from the stored trace (`ChatMessage.toolUsages`); a trace that does not
|
|
6
|
+
* read is skipped, as the dock's tool panel skips it.
|
|
7
|
+
*/
|
|
8
|
+
export function usedToolNames(rows: ReadonlyArray<{ role?: string; toolUsages?: string | null }>, skip: ReadonlyArray<string> = []): string[] {
|
|
9
|
+
const out: string[] = []
|
|
10
|
+
for (const row of [...rows].reverse()) {
|
|
11
|
+
if (row.role !== 'assistant' || !row.toolUsages) continue
|
|
12
|
+
let usages: unknown
|
|
13
|
+
try {
|
|
14
|
+
usages = JSON.parse(row.toolUsages)
|
|
15
|
+
} catch {
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(usages)) continue
|
|
19
|
+
for (const usage of [...usages].reverse()) {
|
|
20
|
+
const name = typeof usage?.name === 'string' ? usage.name : undefined
|
|
21
|
+
if (name && !skip.includes(name) && !out.includes(name)) out.push(name)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
@@ -16,10 +16,13 @@ import { openSubjectLine, registryAssistantChat } from '@things-factory/ai-clien
|
|
|
16
16
|
|
|
17
17
|
import { getAssistantMode, getAssistantModeWarnings } from '../assistant-mode/registry.js'
|
|
18
18
|
import { assistantModeView, resolveModeSettings, type Translate } from '../assistant-mode/resolve.js'
|
|
19
|
+
import { modeFrontCategories, type TurnScreen } from '../assistant-mode/mode-categories.js'
|
|
19
20
|
|
|
20
21
|
export interface ModeRunResult {
|
|
21
22
|
/** 이 모드가 모델에게 실제로 건넨 도구 이름. */
|
|
22
23
|
offeredTools: string[]
|
|
24
|
+
/** 건네지 않았지만 모델이 openTools 로 꺼낼 수 있던 도구 — 둘을 합치면 이 모드로 쓸 수 있는 전부(ADR-0061 결정 9). */
|
|
25
|
+
openableTools: string[]
|
|
23
26
|
/** 모델이 받은 시스템 프롬프트 — 서버의 것인지 확인할 수 있다. */
|
|
24
27
|
systemPrompt: string
|
|
25
28
|
/** 첫 턴에 도구를 강제했는가. */
|
|
@@ -37,7 +40,7 @@ export const keyEchoTranslate: Translate = key => key
|
|
|
37
40
|
*/
|
|
38
41
|
export async function runRegisteredMode(
|
|
39
42
|
kind: string,
|
|
40
|
-
options: { client: any; hostContext?: any; state?: any; toolAccess?: any; reply?: string } = {} as any
|
|
43
|
+
options: { client: any; hostContext?: any; state?: any; toolAccess?: any; reply?: string; screen?: TurnScreen } = {} as any
|
|
41
44
|
): Promise<ModeRunResult> {
|
|
42
45
|
const settings = resolveModeSettings({ kind }, keyEchoTranslate)
|
|
43
46
|
let requiredToolOnFirstTurn = false
|
|
@@ -47,6 +50,8 @@ export async function runRegisteredMode(
|
|
|
47
50
|
messages: [{ role: 'user', content: '한 턴' }] as any,
|
|
48
51
|
systemPrompt: settings.systemPrompt,
|
|
49
52
|
toolCategories: settings.toolCategories,
|
|
53
|
+
/* What the door puts in front on that screen (ADR-0061 decision 9) - a mode too large for one call is offered as a person meets it. */
|
|
54
|
+
front: { categories: modeFrontCategories(getAssistantMode(kind) ?? {}, options.screen ?? {}) },
|
|
50
55
|
requireToolOnFirstTurn: (requiredToolOnFirstTurn = Boolean(settings.requireToolOnFirstTurn)),
|
|
51
56
|
temperature: settings.temperature,
|
|
52
57
|
maxTokens: settings.maxTokens,
|
|
@@ -65,6 +70,7 @@ export async function runRegisteredMode(
|
|
|
65
70
|
|
|
66
71
|
return {
|
|
67
72
|
offeredTools: [...result.offeredTools],
|
|
73
|
+
openableTools: [...result.openableTools],
|
|
68
74
|
systemPrompt: settings.systemPrompt,
|
|
69
75
|
requiredToolOnFirstTurn,
|
|
70
76
|
reply: result.reply
|
|
@@ -187,6 +187,8 @@ export async function runAssistantUnattended(input: UnattendedRunInput, deps: Un
|
|
|
187
187
|
messages,
|
|
188
188
|
systemPrompt,
|
|
189
189
|
toolCategories: categories,
|
|
190
|
+
/* No screen and no conversation - the mode's own tools in front, the lists opened as the run needs (ADR-0061 decision 9). */
|
|
191
|
+
front: { categories: mode.toolCategories ?? [] },
|
|
190
192
|
mode: { kind: mode.kind, ...(input.mode.target ? { target: input.mode.target } : {}) },
|
|
191
193
|
toolAccess: deps.toolAccess(state),
|
|
192
194
|
hostContext: input.hostContext,
|
|
@@ -144,7 +144,7 @@ describe('attachments in the chat follow the mode', () => {
|
|
|
144
144
|
/* Without it the client sends the File as {} and the server refuses the upload (found on :3700, 2026-09-25). */
|
|
145
145
|
assert.match(chat, /context: \{ hasUpload: true \}/)
|
|
146
146
|
/* Sent with the turn's input - the agent the person picked follows it (design/ai/agent-settings.md §5). */
|
|
147
|
-
assert.match(chat, /\n\s*attachmentIds,\s*\n\s*agentId: this\.agentId\s*\n\s*\}\)/)
|
|
147
|
+
assert.match(chat, /\n\s*attachmentIds,\s*\n\s*agentId: this\.agentId,\s*\n\s*page: \(store\.getState\(\) as any\)\?\.route\?\.page \|\| undefined\s*\n\s*\}\)/)
|
|
148
148
|
})
|
|
149
149
|
|
|
150
150
|
it('a stored file opens through the conversation route', () => {
|