@things-factory/ai-client-base 10.1.149 → 10.1.157

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 (76) hide show
  1. package/config/config.development.js +2 -68
  2. package/config/config.production.js +2 -20
  3. package/dist-server/service/agent/agent-directory.d.ts +71 -0
  4. package/dist-server/service/agent/agent-directory.js +129 -0
  5. package/dist-server/service/agent/agent-directory.js.map +1 -0
  6. package/dist-server/service/agent/agent-settings-save.d.ts +38 -0
  7. package/dist-server/service/agent/agent-settings-save.js +153 -0
  8. package/dist-server/service/agent/agent-settings-save.js.map +1 -0
  9. package/dist-server/service/agent/agent-spec.d.ts +43 -0
  10. package/dist-server/service/agent/agent-spec.js +83 -0
  11. package/dist-server/service/agent/agent-spec.js.map +1 -0
  12. package/dist-server/service/agent/ai-client-for.d.ts +53 -0
  13. package/dist-server/service/agent/ai-client-for.js +166 -0
  14. package/dist-server/service/agent/ai-client-for.js.map +1 -0
  15. package/dist-server/service/agent/ai-usage-summary.d.ts +37 -0
  16. package/dist-server/service/agent/ai-usage-summary.js +108 -0
  17. package/dist-server/service/agent/ai-usage-summary.js.map +1 -0
  18. package/dist-server/service/agent/ai-usage.d.ts +35 -0
  19. package/dist-server/service/agent/ai-usage.js +118 -0
  20. package/dist-server/service/agent/ai-usage.js.map +1 -0
  21. package/dist-server/service/agent/index.d.ts +7 -0
  22. package/dist-server/service/agent/index.js +13 -0
  23. package/dist-server/service/agent/index.js.map +1 -0
  24. package/dist-server/service/agent/provider-models.d.ts +12 -0
  25. package/dist-server/service/agent/provider-models.js +37 -0
  26. package/dist-server/service/agent/provider-models.js.map +1 -0
  27. package/dist-server/service/capability.d.ts +21 -10
  28. package/dist-server/service/capability.js +20 -49
  29. package/dist-server/service/capability.js.map +1 -1
  30. package/dist-server/service/factory.d.ts +7 -12
  31. package/dist-server/service/factory.js +0 -45
  32. package/dist-server/service/factory.js.map +1 -1
  33. package/dist-server/service/index.d.ts +3 -0
  34. package/dist-server/service/index.js +6 -1
  35. package/dist-server/service/index.js.map +1 -1
  36. package/dist-server/service/providers/anthropic.d.ts +2 -0
  37. package/dist-server/service/providers/anthropic.js +16 -0
  38. package/dist-server/service/providers/anthropic.js.map +1 -1
  39. package/dist-server/service/providers/gemini.d.ts +13 -0
  40. package/dist-server/service/providers/gemini.js +52 -12
  41. package/dist-server/service/providers/gemini.js.map +1 -1
  42. package/dist-server/service/providers/openai.d.ts +6 -0
  43. package/dist-server/service/providers/openai.js +34 -8
  44. package/dist-server/service/providers/openai.js.map +1 -1
  45. package/dist-server/service/types.d.ts +16 -2
  46. package/dist-server/service/types.js.map +1 -1
  47. package/dist-server/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +5 -2
  49. package/server/service/agent/agent-directory.test.ts +206 -0
  50. package/server/service/agent/agent-directory.ts +198 -0
  51. package/server/service/agent/agent-settings-save.test.ts +151 -0
  52. package/server/service/agent/agent-settings-save.ts +205 -0
  53. package/server/service/agent/agent-spec.ts +103 -0
  54. package/server/service/agent/ai-client-for.test.ts +141 -0
  55. package/server/service/agent/ai-client-for.ts +201 -0
  56. package/server/service/agent/ai-usage-summary.test.ts +65 -0
  57. package/server/service/agent/ai-usage-summary.ts +146 -0
  58. package/server/service/agent/ai-usage.ts +97 -0
  59. package/server/service/agent/index.ts +7 -0
  60. package/server/service/agent/one-door.test.ts +57 -0
  61. package/server/service/agent/provider-models.ts +43 -0
  62. package/server/service/capability.test.ts +26 -55
  63. package/server/service/capability.ts +25 -42
  64. package/server/service/factory.ts +4 -44
  65. package/server/service/index.ts +6 -1
  66. package/server/service/providers/anthropic.ts +17 -0
  67. package/server/service/providers/gemini.ts +54 -12
  68. package/server/service/providers/openai-models.test.ts +30 -0
  69. package/server/service/providers/openai.ts +34 -8
  70. package/server/service/types.ts +18 -2
  71. package/translations/en.json +13 -0
  72. package/translations/es.json +13 -0
  73. package/translations/ja.json +13 -0
  74. package/translations/ko.json +13 -0
  75. package/translations/ms.json +13 -0
  76. package/translations/zh.json +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-client-base",
3
- "version": "10.1.149",
3
+ "version": "10.1.157",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -24,8 +24,11 @@
24
24
  "dependencies": {
25
25
  "@anthropic-ai/sdk": "^0.40.0",
26
26
  "@google/generative-ai": "^0.24.0",
27
+ "@things-factory/auth-base": "^10.1.157",
27
28
  "@things-factory/env": "^10.1.149",
29
+ "@things-factory/setting-base": "^10.1.157",
30
+ "@things-factory/shell": "^10.1.157",
28
31
  "openai": "^4.0.0"
29
32
  },
30
- "gitHead": "816f88a9fa6110935a3f71740458db8f7469d2e3"
33
+ "gitHead": "bde810f804966549a7041457ad7ede40fcb78488"
31
34
  }
@@ -0,0 +1,206 @@
1
+ /*
2
+ * What this guards: **a domain sees its own agents and the agents of every domain above it, and the key that answers
3
+ * is the closest one** (design/ai/agent-settings.md §3-3).
4
+ *
5
+ * The screen and the door both read this one directory, so what the settings card shows as "usable" is exactly what a
6
+ * conversation can be answered by.
7
+ */
8
+ jest.mock('@things-factory/shell', () => ({
9
+ ...jest.requireActual('@things-factory/shell'),
10
+ getRepository: jest.fn(),
11
+ getDomainIdsWithAncestors: jest.fn()
12
+ }))
13
+
14
+ import { getDomainIdsWithAncestors, getRepository } from '@things-factory/shell'
15
+
16
+ import { readAgentUsersBelow, readAiAgentDirectory } from './agent-directory'
17
+ import { aiAgentKeyName } from './agent-spec'
18
+
19
+ type Row = Record<string, any>
20
+
21
+ const world = {
22
+ domains: [] as Row[],
23
+ settings: [] as Row[],
24
+ envVars: [] as Row[]
25
+ }
26
+
27
+ /* Answers the three finds the directory makes, by what they ask for. */
28
+ function idsOf(where: any): string[] | undefined {
29
+ const value = where?.domain?.id ?? where?.id ?? where?.parent?.id
30
+ return value?._value ?? (value === undefined ? undefined : [value])
31
+ }
32
+
33
+ ;(getRepository as jest.Mock).mockImplementation((target: any) => ({
34
+ findOne: async ({ where }: any) => world.domains.find(one => one.id === where.id) ?? null,
35
+ find: async ({ where }: any) => {
36
+ const name = target?.name
37
+ if (name === 'Domain') {
38
+ if (where.parent) return world.domains.filter(one => idsOf(where)!.includes(one.parentId))
39
+ return world.domains.filter(one => idsOf(where)!.includes(one.id))
40
+ }
41
+ if (name === 'Setting') return world.settings.filter(one => idsOf(where)!.includes(one.domainId) && one.name === where.name)
42
+ if (name === 'EnvVar') {
43
+ const names = where.name?._value ?? [where.name]
44
+ return world.envVars.filter(one => idsOf(where)!.includes(one.domainId) && names.includes(one.name) && one.active !== false)
45
+ }
46
+ return []
47
+ }
48
+ }))
49
+ ;(getDomainIdsWithAncestors as jest.Mock).mockImplementation(async (domain: Row) => {
50
+ const ids = [domain.id]
51
+ let parentId = domain.parentId
52
+ while (parentId) {
53
+ ids.push(parentId)
54
+ parentId = world.domains.find(one => one.id === parentId)?.parentId
55
+ }
56
+ return ids
57
+ })
58
+
59
+ const group = { id: 'group', name: '그룹', parentId: null }
60
+ const hq = { id: 'hq', name: '본사', parentId: 'group' }
61
+ const plant = { id: 'plant', name: '서울공장', parentId: 'hq' }
62
+
63
+ function agent(id: string, name: string, extra: Row = {}) {
64
+ return { id, name, provider: 'openai', model: 'gpt-4o', active: true, ...extra }
65
+ }
66
+
67
+ function agentsOf(domainId: string, value: Row) {
68
+ world.settings.push({ domainId, name: 'ai-agents', value: JSON.stringify(value) })
69
+ }
70
+
71
+ function keyOf(domainId: string, agentId: string, value = 'sk-0123456789abcdefWXYZ') {
72
+ world.envVars.push({ domainId, name: aiAgentKeyName(agentId), value, active: true, updatedAt: new Date(0) })
73
+ }
74
+
75
+ beforeEach(() => {
76
+ world.domains = [group, hq, plant]
77
+ world.settings = []
78
+ world.envVars = []
79
+ })
80
+
81
+ it('★ a domain sees its own agents first, then each ancestor\'s, closest first', async () => {
82
+ agentsOf('group', { agents: [agent('g1', 'Group GPT')] })
83
+ agentsOf('hq', { agents: [agent('h1', 'HQ GPT')] })
84
+ agentsOf('plant', { agents: [agent('p1', 'Plant GPT')] })
85
+
86
+ const directory = await readAiAgentDirectory(plant as any)
87
+
88
+ expect(directory.entries.map(one => [one.spec.id, one.ownerDomainId, one.inherited])).toEqual([
89
+ ['p1', 'plant', false],
90
+ ['h1', 'hq', true],
91
+ ['g1', 'group', true]
92
+ ])
93
+ expect(directory.chain).toEqual([
94
+ { id: 'plant', name: '서울공장' },
95
+ { id: 'hq', name: '본사' },
96
+ { id: 'group', name: '그룹' }
97
+ ])
98
+ })
99
+
100
+ it('★ a caller holding only the domain id still gets the whole chain - the row is read for its parent', async () => {
101
+ agentsOf('hq', { agents: [agent('h1', 'HQ GPT')] })
102
+ keyOf('hq', 'h1')
103
+
104
+ const directory = await readAiAgentDirectory({ id: 'plant' })
105
+ expect(directory.entries.map(one => [one.spec.id, one.usable])).toEqual([['h1', true]])
106
+ })
107
+
108
+ it('★ the key states: own, inherited, overridden and missing', async () => {
109
+ agentsOf('hq', { agents: [agent('h1', 'HQ GPT'), agent('h2', 'HQ Claude', { provider: 'anthropic', model: 'claude-x' })] })
110
+ agentsOf('plant', { agents: [agent('p1', 'Plant GPT'), agent('p2', 'Plant Gemini', { provider: 'gemini', model: 'g' })] })
111
+ keyOf('plant', 'p1')
112
+ keyOf('hq', 'h1')
113
+ keyOf('hq', 'h2')
114
+ keyOf('plant', 'h2', 'sk-plant-own-key-for-h2-0000')
115
+
116
+ const entries = (await readAiAgentDirectory(plant as any)).entries
117
+ const state = Object.fromEntries(entries.map(one => [one.spec.id, [one.keyState, one.keyDomainId, one.keyHint, one.usable]]))
118
+
119
+ expect(state).toEqual({
120
+ p1: ['own', 'plant', '••••WXYZ', true],
121
+ p2: ['missing', undefined, undefined, false],
122
+ h1: ['inherited', 'hq', undefined, true],
123
+ h2: ['overridden', 'plant', '••••0000', true]
124
+ })
125
+ })
126
+
127
+ it('★ a key from a domain above is never hinted below, and never leaves the entry\'s server-only field', async () => {
128
+ agentsOf('hq', { agents: [agent('h1', 'HQ GPT')] })
129
+ keyOf('hq', 'h1')
130
+
131
+ const [entry] = (await readAiAgentDirectory(plant as any)).entries
132
+
133
+ expect(entry.keyHint).toBeUndefined()
134
+ expect(entry.apiKey).toBe('sk-0123456789abcdefWXYZ')
135
+ })
136
+
137
+ it('★ an owner that turns its agent off takes it out of every domain below; its own list still shows it', async () => {
138
+ agentsOf('hq', { agents: [agent('h1', 'HQ GPT', { active: false })] })
139
+ keyOf('hq', 'h1')
140
+
141
+ expect((await readAiAgentDirectory(plant as any)).entries).toEqual([])
142
+ const own = (await readAiAgentDirectory(hq as any)).entries
143
+ expect(own.map(one => [one.spec.id, one.usable])).toEqual([['h1', false]])
144
+ })
145
+
146
+ it('★ a domain may turn an inherited agent off for itself only', async () => {
147
+ agentsOf('hq', { agents: [agent('h1', 'HQ GPT')] })
148
+ agentsOf('plant', { agents: [], turnedOff: ['h1'] })
149
+ keyOf('hq', 'h1')
150
+
151
+ const [below] = (await readAiAgentDirectory(plant as any)).entries
152
+ expect([below.turnedOff, below.usable]).toEqual([true, false])
153
+ const [atOwner] = (await readAiAgentDirectory(hq as any)).entries
154
+ expect([atOwner.turnedOff, atOwner.usable]).toEqual([false, true])
155
+ })
156
+
157
+ describe('the default agent', () => {
158
+ beforeEach(() => {
159
+ agentsOf('hq', { agents: [agent('h1', 'Beta'), agent('h2', 'Alpha')], defaultAgentId: 'h1' })
160
+ keyOf('hq', 'h1')
161
+ keyOf('hq', 'h2')
162
+ })
163
+
164
+ it('★ is this domain\'s own choice when it made one', async () => {
165
+ agentsOf('plant', { agents: [], defaultAgentId: 'h2' })
166
+ expect(await defaultOf(plant)).toEqual(['h2', 'own'])
167
+ })
168
+
169
+ it('★ is the closest ancestor\'s choice otherwise', async () => {
170
+ expect(await defaultOf(plant)).toEqual(['h1', 'inherited'])
171
+ })
172
+
173
+ it('★ skips a choice this domain cannot use, and then takes the first usable agent by name - never the order of declaration', async () => {
174
+ agentsOf('plant', { agents: [], turnedOff: ['h1'] })
175
+ expect(await defaultOf(plant)).toEqual(['h2', 'first'])
176
+ })
177
+
178
+ it('is none when nothing is usable', async () => {
179
+ world.envVars = []
180
+ expect(await defaultOf(plant)).toEqual([undefined, 'none'])
181
+ })
182
+
183
+ async function defaultOf(domain: Row) {
184
+ const directory = await readAiAgentDirectory(domain as any)
185
+ return [directory.defaultAgentId, directory.defaultFrom]
186
+ }
187
+ })
188
+
189
+ it('★ a stored value that does not parse reads as no agents - a broken row never becomes an agent', async () => {
190
+ world.settings.push({ domainId: 'plant', name: 'ai-agents', value: '{not json' })
191
+ agentsOf('hq', { agents: [{ id: 'h1', name: 'No provider', model: 'x' }, agent('h2', 'Good')] })
192
+
193
+ const directory = await readAiAgentDirectory(plant as any)
194
+ expect(directory.entries.map(one => one.spec.id)).toEqual(['h2'])
195
+ })
196
+
197
+ it('★ the owner sees which domains below use each agent - all of them, except one that turned it off', async () => {
198
+ const other = { id: 'busan', name: '부산공장', parentId: 'hq' }
199
+ const line = { id: 'line', name: '1라인', parentId: 'plant' }
200
+ world.domains.push(other, line)
201
+ agentsOf('hq', { agents: [agent('h1', 'HQ GPT')] })
202
+ agentsOf('busan', { agents: [], turnedOff: ['h1'] })
203
+
204
+ const users = await readAgentUsersBelow(hq as any, ['h1'])
205
+ expect(users.get('h1')!.map(one => one.name).sort()).toEqual(['1라인', '서울공장'])
206
+ })
@@ -0,0 +1,198 @@
1
+ import { In } from 'typeorm'
2
+
3
+ import {
4
+ dedupByClosestDomain,
5
+ Domain,
6
+ EnvVar,
7
+ getDomainIdsWithAncestors,
8
+ getRepository,
9
+ secretHint
10
+ } from '@things-factory/shell'
11
+ import { Setting } from '@things-factory/setting-base'
12
+
13
+ import {
14
+ AI_AGENTS_SETTING,
15
+ aiAgentKeyName,
16
+ type AiAgentSpec,
17
+ type AiAgentsSetting,
18
+ parseAiAgentsSetting
19
+ } from './agent-spec.js'
20
+
21
+ /**
22
+ * Which agents a domain can use, read up its chain of domains (design/ai/agent-settings.md §3-3).
23
+ *
24
+ * A domain sees its own agents and every agent of the domains above it. The definition belongs to the domain that
25
+ * declared it; the key is the closest `AiAgent::<id>::apiKey` EnvVar, so a domain below can answer an inherited agent
26
+ * with a key of its own.
27
+ */
28
+
29
+ /**
30
+ * Where the key an agent answers with comes from, seen from the domain asking:
31
+ * own - this domain's key for its own agent
32
+ * inherited - a domain above holds the key
33
+ * overridden - an inherited agent answered with this domain's key
34
+ * missing - no key anywhere on the chain; the agent cannot answer
35
+ */
36
+ export type AiAgentKeyState = 'own' | 'inherited' | 'overridden' | 'missing'
37
+
38
+ export interface AiAgentEntry {
39
+ spec: AiAgentSpec
40
+ /** The domain that declared the agent. */
41
+ ownerDomainId: string
42
+ inherited: boolean
43
+ /** An inherited agent this domain turned off for itself. */
44
+ turnedOff: boolean
45
+ keyState: AiAgentKeyState
46
+ /** The domain whose key answers. */
47
+ keyDomainId?: string
48
+ /** Shown only for a key of this domain (ADR-0112 decision 5). A key from above is never hinted below. */
49
+ keyHint?: string
50
+ /** Active, not turned off here, and has a key. */
51
+ usable: boolean
52
+ /** Server-only - never put on a GraphQL type. */
53
+ apiKey?: string
54
+ /** For a client cache: changes whenever the key does. */
55
+ keyVersion?: string
56
+ }
57
+
58
+ export interface AiAgentDirectory {
59
+ domainId: string
60
+ /** The domain and its ancestors, closest first, with names for the screen. */
61
+ chain: { id: string; name: string }[]
62
+ /** This domain's own agents first, then each ancestor's, closest first. */
63
+ entries: AiAgentEntry[]
64
+ /** This domain's stored setting, as parsed. */
65
+ own: AiAgentsSetting
66
+ /** The agent a request without a choice gets, and how it was decided. */
67
+ defaultAgentId?: string
68
+ defaultFrom: 'own' | 'inherited' | 'first' | 'none'
69
+ }
70
+
71
+ export async function readAiAgentDirectory(given: Domain | { id: string }): Promise<AiAgentDirectory> {
72
+ const domain = await fullDomain(given)
73
+ const chainIds = await getDomainIdsWithAncestors(domain)
74
+ const [settings, domains] = await Promise.all([
75
+ getRepository(Setting).find({ where: { domain: { id: In(chainIds) }, name: AI_AGENTS_SETTING } }),
76
+ getRepository(Domain).find({ where: { id: In(chainIds) } })
77
+ ])
78
+
79
+ const settingOf = new Map(settings.map(one => [one.domainId, parseAiAgentsSetting(one.value)]))
80
+ const own = settingOf.get(domain.id) ?? { agents: [] }
81
+ const turnedOff = new Set(own.turnedOff ?? [])
82
+
83
+ /* The domain's own agents whatever their state; an inherited one only while its owner keeps it on. */
84
+ const declared: { spec: AiAgentSpec; ownerDomainId: string }[] = []
85
+ for (const domainId of chainIds) {
86
+ const setting = settingOf.get(domainId)
87
+ if (!setting) continue
88
+ for (const spec of setting.agents) {
89
+ if (domainId !== domain.id && !spec.active) continue
90
+ declared.push({ spec, ownerDomainId: domainId })
91
+ }
92
+ }
93
+
94
+ const keyNames = declared.map(one => aiAgentKeyName(one.spec.id))
95
+ const keyRows = keyNames.length
96
+ ? await getRepository(EnvVar).find({ where: { domain: { id: In(chainIds) }, name: In(keyNames), active: true } })
97
+ : []
98
+ const keyOf = new Map(dedupByClosestDomain(keyRows, chainIds, row => row.name).map(row => [row.name, row]))
99
+
100
+ const entries = declared.map(({ spec, ownerDomainId }): AiAgentEntry => {
101
+ const inherited = ownerDomainId !== domain.id
102
+ const key = keyOf.get(aiAgentKeyName(spec.id))
103
+ const keyHere = key?.domainId === domain.id
104
+ const keyState: AiAgentKeyState = !key?.value ? 'missing' : !keyHere ? 'inherited' : inherited ? 'overridden' : 'own'
105
+ const off = inherited && turnedOff.has(spec.id)
106
+ return {
107
+ spec,
108
+ ownerDomainId,
109
+ inherited,
110
+ turnedOff: off,
111
+ keyState,
112
+ ...(key?.value && { keyDomainId: key.domainId, apiKey: key.value, keyVersion: `${key.domainId}:${key.updatedAt?.valueOf() ?? ''}` }),
113
+ ...(keyHere && key?.value && { keyHint: secretHint(key.value) }),
114
+ usable: spec.active && !off && keyState !== 'missing'
115
+ }
116
+ })
117
+
118
+ const nameOf = new Map(domains.map(one => [one.id, one.name]))
119
+ const { defaultAgentId, defaultFrom } = pickDefault(chainIds, settingOf, entries)
120
+
121
+ return {
122
+ domainId: domain.id,
123
+ chain: chainIds.map(id => ({ id, name: nameOf.get(id) ?? id })),
124
+ entries,
125
+ own,
126
+ ...(defaultAgentId && { defaultAgentId }),
127
+ defaultFrom
128
+ }
129
+ }
130
+
131
+ /**
132
+ * A caller that holds only the domain's id (a background job, a webhook) still gets the domain's whole chain: the walk
133
+ * needs `parentId`, which an id alone does not carry.
134
+ */
135
+ export async function fullDomain(given: Domain | { id: string }): Promise<Domain> {
136
+ if ((given as Domain).parentId !== undefined) return given as Domain
137
+ const found = await getRepository(Domain).findOne({ where: { id: given.id } })
138
+ if (!found) throw new Error(`no domain ${given.id}`)
139
+ return found
140
+ }
141
+
142
+ /**
143
+ * This domain's own choice, else the closest ancestor's choice that is usable here, else the first usable agent by
144
+ * name. Registration order never decides it.
145
+ */
146
+ function pickDefault(
147
+ chainIds: string[],
148
+ settingOf: Map<string, AiAgentsSetting>,
149
+ entries: AiAgentEntry[]
150
+ ): { defaultAgentId?: string; defaultFrom: AiAgentDirectory['defaultFrom'] } {
151
+ const usable = new Map(entries.filter(one => one.usable).map(one => [one.spec.id, one]))
152
+ for (const [index, domainId] of chainIds.entries()) {
153
+ const chosen = settingOf.get(domainId)?.defaultAgentId
154
+ if (chosen && usable.has(chosen)) return { defaultAgentId: chosen, defaultFrom: index === 0 ? 'own' : 'inherited' }
155
+ }
156
+ const [first] = [...usable.values()].sort((a, b) => a.spec.name.localeCompare(b.spec.name))
157
+ return first ? { defaultAgentId: first.spec.id, defaultFrom: 'first' } : { defaultFrom: 'none' }
158
+ }
159
+
160
+ /** Every domain below this one, any depth. */
161
+ export async function readDescendantDomains(domain: Domain): Promise<Domain[]> {
162
+ const found: Domain[] = []
163
+ const seen = new Set<string>([domain.id])
164
+ let frontier = [domain.id]
165
+ for (let depth = 0; frontier.length && depth < 10; depth++) {
166
+ const children = await getRepository(Domain).find({ where: { parent: { id: In(frontier) } } })
167
+ const fresh = children.filter(one => !seen.has(one.id))
168
+ fresh.forEach(one => seen.add(one.id))
169
+ found.push(...fresh)
170
+ frontier = fresh.map(one => one.id)
171
+ }
172
+ return found
173
+ }
174
+
175
+ /**
176
+ * For each of this domain's agents, the domains below that use it - every descendant that has not turned it off.
177
+ * The owner sees these before deleting, turning off or rekeying an agent (§3-3 point 8).
178
+ */
179
+ export async function readAgentUsersBelow(
180
+ domain: Domain,
181
+ agentIds: string[]
182
+ ): Promise<Map<string, { id: string; name: string }[]>> {
183
+ const result = new Map(agentIds.map(id => [id, [] as { id: string; name: string }[]]))
184
+ if (!agentIds.length) return result
185
+ const descendants = await readDescendantDomains(domain)
186
+ if (!descendants.length) return result
187
+
188
+ const settings = await getRepository(Setting).find({
189
+ where: { domain: { id: In(descendants.map(one => one.id)) }, name: AI_AGENTS_SETTING }
190
+ })
191
+ const offOf = new Map(settings.map(one => [one.domainId, new Set(parseAiAgentsSetting(one.value).turnedOff ?? [])]))
192
+ for (const below of descendants) {
193
+ for (const agentId of agentIds) {
194
+ if (!offOf.get(below.id)?.has(agentId)) result.get(agentId)!.push({ id: below.id, name: below.name })
195
+ }
196
+ }
197
+ return result
198
+ }
@@ -0,0 +1,151 @@
1
+ /*
2
+ * What this guards: **saving a domain's agents writes the definition and the keys together, keeps a key that was not
3
+ * sent, and refuses what would make a picker lie** (design/ai/agent-settings.md §3-2, §3-3, ADR-0112 decision 3).
4
+ */
5
+ jest.mock('@things-factory/shell', () => ({
6
+ ...jest.requireActual('@things-factory/shell'),
7
+ getRepository: jest.fn(() => ({ find: async () => mockBelowSettings }))
8
+ }))
9
+ jest.mock('./agent-directory', () => ({
10
+ readAiAgentDirectory: jest.fn(),
11
+ readDescendantDomains: jest.fn(async () => mockBelow)
12
+ }))
13
+ jest.mock('./provider-models', () => ({ listProviderModels: jest.fn(async () => ['gpt-4o', 'gpt-4o-mini']) }))
14
+
15
+ import { readAiAgentDirectory } from './agent-directory'
16
+ import { saveAiAgents } from './agent-settings-save'
17
+ import { aiAgentKeyName } from './agent-spec'
18
+ import { listProviderModels } from './provider-models'
19
+
20
+ let mockBelow: any[] = []
21
+ let mockBelowSettings: any[] = []
22
+
23
+ const stored = { id: 'a1', name: 'GPT', provider: 'openai', model: 'gpt-4o', active: true }
24
+
25
+ function directory(extra: any = {}) {
26
+ return {
27
+ own: { agents: [stored] },
28
+ entries: [
29
+ { spec: stored, inherited: false, keyState: 'own', apiKey: 'sk-stored', usable: true },
30
+ { spec: { id: 'h1', name: 'HQ Claude', provider: 'anthropic', model: 'c', active: true }, inherited: true, keyState: 'inherited', usable: true }
31
+ ],
32
+ ...extra
33
+ }
34
+ }
35
+
36
+ /* A transaction that records what was written. */
37
+ function fakeTx() {
38
+ const writes: any[] = []
39
+ const repository = (target: any) => ({
40
+ findOne: async () => undefined,
41
+ save: async (row: any) => writes.push({ entity: target.name, op: 'save', row }),
42
+ delete: async (where: any) => writes.push({ entity: target.name, op: 'delete', where })
43
+ })
44
+ return { writes, tx: { getRepository: repository } as any }
45
+ }
46
+
47
+ const context = (tx: any) => ({ tx, domain: { id: 'plant' } as any, user: { id: 'u1' } })
48
+
49
+ beforeEach(() => {
50
+ mockBelow = []
51
+ mockBelowSettings = []
52
+ ;(readAiAgentDirectory as jest.Mock).mockResolvedValue(directory())
53
+ ;(listProviderModels as jest.Mock).mockClear()
54
+ })
55
+
56
+ it('★ the definition goes to the ai-agents setting, and a new key to its own EnvVar - in the same transaction', async () => {
57
+ const { writes, tx } = fakeTx()
58
+ await saveAiAgents({ agents: [{ ...stored, apiKey: 'sk-new-key' }] }, context(tx))
59
+
60
+ const setting = writes.find(one => one.entity === 'Setting')
61
+ expect(JSON.parse(setting.row.value).agents).toEqual([stored])
62
+ expect(writes).toContainEqual(
63
+ expect.objectContaining({ entity: 'EnvVar', op: 'save', row: expect.objectContaining({ name: aiAgentKeyName('a1'), value: 'sk-new-key', domain: { id: 'plant' } }) })
64
+ )
65
+ })
66
+
67
+ it('★ a key left out keeps the stored one, and an unchanged agent does not ask the provider again', async () => {
68
+ const { writes, tx } = fakeTx()
69
+ await saveAiAgents({ agents: [{ ...stored, name: 'GPT renamed' }] }, context(tx))
70
+
71
+ expect(writes.filter(one => one.entity === 'EnvVar')).toEqual([])
72
+ expect(listProviderModels).not.toHaveBeenCalled()
73
+ })
74
+
75
+ it('★ a new agent without a key is refused', async () => {
76
+ const { tx } = fakeTx()
77
+ await expect(
78
+ saveAiAgents({ agents: [stored, { name: 'New', provider: 'gemini', model: 'g', active: true }] }, context(tx))
79
+ ).rejects.toMatchObject({ code: 'ai-agent-key-required' })
80
+ })
81
+
82
+ it('★ a model the provider does not offer to that key is refused', async () => {
83
+ const { tx } = fakeTx()
84
+ await expect(saveAiAgents({ agents: [{ ...stored, model: 'gpt-made-up' }] }, context(tx))).rejects.toMatchObject({
85
+ code: 'ai-agent-model-not-offered',
86
+ params: { agent: 'GPT', model: 'gpt-made-up' }
87
+ })
88
+ })
89
+
90
+ it('★ a name already inherited from above is refused', async () => {
91
+ const { tx } = fakeTx()
92
+ await expect(saveAiAgents({ agents: [{ ...stored, name: 'hq claude' }] }, context(tx))).rejects.toMatchObject({
93
+ code: 'ai-agent-name-taken'
94
+ })
95
+ })
96
+
97
+ it('★ a name a domain below already uses is refused, naming that domain', async () => {
98
+ mockBelow = [{ id: 'line', name: '1라인' }]
99
+ mockBelowSettings = [{ domainId: 'line', name: 'ai-agents', value: JSON.stringify({ agents: [{ ...stored, id: 'l1', name: 'Line GPT' }] }) }]
100
+ const { tx } = fakeTx()
101
+ await expect(saveAiAgents({ agents: [{ ...stored, name: 'Line GPT' }] }, context(tx))).rejects.toMatchObject({
102
+ code: 'ai-agent-name-taken',
103
+ params: { name: 'Line GPT', domain: '1라인' }
104
+ })
105
+ })
106
+
107
+ it('★ an id that is not this domain\'s own agent is refused - an inherited agent is not edited from below', async () => {
108
+ const { tx } = fakeTx()
109
+ await expect(
110
+ saveAiAgents({ agents: [stored, { id: 'h1', name: 'Hijack', provider: 'anthropic', model: 'c', active: true }] }, context(tx))
111
+ ).rejects.toMatchObject({ code: 'ai-agent-not-in-domain' })
112
+ })
113
+
114
+ it('★ deleting an agent removes its key here and the keys domains below set for it', async () => {
115
+ mockBelow = [{ id: 'line', name: '1라인' }]
116
+ const { writes, tx } = fakeTx()
117
+ await saveAiAgents({ agents: [] }, context(tx))
118
+
119
+ const removal = writes.find(one => one.entity === 'EnvVar' && one.op === 'delete')
120
+ expect(removal.where.name._value).toEqual([aiAgentKeyName('a1')])
121
+ expect(removal.where.domain.id._value).toEqual(['plant', 'line'])
122
+ })
123
+
124
+ it('★ an inherited agent can be answered with this domain\'s key, and given back to the key above', async () => {
125
+ const set = fakeTx()
126
+ await saveAiAgents({ agents: [stored], inheritedKeys: [{ agentId: 'h1', apiKey: 'sk-plant-for-h1' }] }, context(set.tx))
127
+ expect(set.writes).toContainEqual(
128
+ expect.objectContaining({ entity: 'EnvVar', op: 'save', row: expect.objectContaining({ name: aiAgentKeyName('h1'), value: 'sk-plant-for-h1' }) })
129
+ )
130
+
131
+ const clear = fakeTx()
132
+ await saveAiAgents({ agents: [stored], inheritedKeys: [{ agentId: 'h1', clear: true }] }, context(clear.tx))
133
+ expect(clear.writes).toContainEqual({ entity: 'EnvVar', op: 'delete', where: { domain: { id: 'plant' }, name: aiAgentKeyName('h1') } })
134
+ })
135
+
136
+ it('★ only inherited agents can be turned off here, and the default must be one this domain can use', async () => {
137
+ const { writes, tx } = fakeTx()
138
+ await saveAiAgents({ agents: [stored], turnedOff: ['h1', 'a1', 'nonesuch'], defaultAgentId: 'h1' }, context(tx))
139
+ const value = JSON.parse(writes.find(one => one.entity === 'Setting').row.value)
140
+ expect(value).toMatchObject({ turnedOff: ['h1'], defaultAgentId: 'h1' })
141
+
142
+ await expect(saveAiAgents({ agents: [stored], defaultAgentId: 'nonesuch' }, context(fakeTx().tx))).rejects.toMatchObject({
143
+ code: 'ai-agent-not-available'
144
+ })
145
+ })
146
+
147
+ it('only three providers', async () => {
148
+ await expect(
149
+ saveAiAgents({ agents: [{ ...stored, provider: 'mistral' }] }, context(fakeTx().tx))
150
+ ).rejects.toMatchObject({ code: 'ai-agent-provider-unknown' })
151
+ })