@things-factory/ai-assistant 10.1.205 → 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.
Files changed (43) hide show
  1. package/client/components/assistant-chat.ts +2 -1
  2. package/client/components/chat-input-builder.test.ts +5 -0
  3. package/client/components/chat-input-builder.ts +4 -1
  4. package/dist-client/components/assistant-chat.js +2 -1
  5. package/dist-client/components/assistant-chat.js.map +1 -1
  6. package/dist-client/components/chat-input-builder.d.ts +2 -0
  7. package/dist-client/components/chat-input-builder.js +2 -1
  8. package/dist-client/components/chat-input-builder.js.map +1 -1
  9. package/dist-client/components/chat-input-builder.test.js +4 -0
  10. package/dist-client/components/chat-input-builder.test.js.map +1 -1
  11. package/dist-client/tsconfig.tsbuildinfo +1 -1
  12. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  13. package/dist-server/service/assistant-chat-resolver.js +20 -0
  14. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  15. package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -0
  16. package/dist-server/service/assistant-mode/mode-categories.js +37 -1
  17. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  18. package/dist-server/service/assistant-mode/registry.js +20 -0
  19. package/dist-server/service/assistant-mode/registry.js.map +1 -1
  20. package/dist-server/service/assistant-mode/types.d.ts +6 -1
  21. package/dist-server/service/assistant-mode/types.js.map +1 -1
  22. package/dist-server/service/chat-message/used-tools.d.ts +11 -0
  23. package/dist-server/service/chat-message/used-tools.js +33 -0
  24. package/dist-server/service/chat-message/used-tools.js.map +1 -0
  25. package/dist-server/service/testing/mode-conformance.d.ts +4 -0
  26. package/dist-server/service/testing/mode-conformance.js +4 -0
  27. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  28. package/dist-server/service/unattended-run/run-unattended.js +2 -0
  29. package/dist-server/service/unattended-run/run-unattended.js.map +1 -1
  30. package/dist-server/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +8 -8
  32. package/server/service/assistant-chat-resolver.ts +21 -1
  33. package/server/service/assistant-mode/mode-categories.ts +47 -1
  34. package/server/service/assistant-mode/mode-front.test.ts +65 -0
  35. package/server/service/assistant-mode/open-subject-door.test.ts +16 -0
  36. package/server/service/assistant-mode/registry.test.ts +56 -0
  37. package/server/service/assistant-mode/registry.ts +20 -2
  38. package/server/service/assistant-mode/types.ts +14 -2
  39. package/server/service/chat-message/used-tools.test.ts +19 -0
  40. package/server/service/chat-message/used-tools.ts +25 -0
  41. package/server/service/testing/mode-conformance.ts +7 -1
  42. package/server/service/unattended-run/run-unattended.ts +2 -0
  43. 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.205",
3
+ "version": "10.1.207",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -35,13 +35,13 @@
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.205",
39
- "@things-factory/apptool-base": "^10.1.205",
40
- "@things-factory/attachment-base": "^10.1.205",
41
- "@things-factory/auth-base": "^10.1.205",
38
+ "@things-factory/ai-client-base": "^10.1.207",
39
+ "@things-factory/apptool-base": "^10.1.206",
40
+ "@things-factory/attachment-base": "^10.1.206",
41
+ "@things-factory/auth-base": "^10.1.206",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/setting-base": "^10.1.205",
44
- "@things-factory/shell": "^10.1.205",
43
+ "@things-factory/setting-base": "^10.1.206",
44
+ "@things-factory/shell": "^10.1.206",
45
45
  "dompurify": "^3.0.0",
46
46
  "exceljs": "4.2.0",
47
47
  "graphql-scalars": "^1.22.0",
@@ -55,5 +55,5 @@
55
55
  "copyfiles": "^2.4.1",
56
56
  "rimraf": "^5.0.0"
57
57
  },
58
- "gitHead": "7311492aa5eaa00993c0325053f5f7ae8b1fb024"
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
- /* Pure - read by the resolver and by the boot report, so the report and the conversation open the same tools. */
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: { kind: string; slash?: string; tool?: string; subject?: string; kinds?: string[]; target?: string }
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', () => {