@things-factory/ai-client-base 10.1.149 → 10.1.155
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/config/config.development.js +2 -68
- package/config/config.production.js +2 -20
- package/dist-server/service/agent/agent-directory.d.ts +71 -0
- package/dist-server/service/agent/agent-directory.js +129 -0
- package/dist-server/service/agent/agent-directory.js.map +1 -0
- package/dist-server/service/agent/agent-settings-save.d.ts +38 -0
- package/dist-server/service/agent/agent-settings-save.js +153 -0
- package/dist-server/service/agent/agent-settings-save.js.map +1 -0
- package/dist-server/service/agent/agent-spec.d.ts +43 -0
- package/dist-server/service/agent/agent-spec.js +83 -0
- package/dist-server/service/agent/agent-spec.js.map +1 -0
- package/dist-server/service/agent/ai-client-for.d.ts +53 -0
- package/dist-server/service/agent/ai-client-for.js +166 -0
- package/dist-server/service/agent/ai-client-for.js.map +1 -0
- package/dist-server/service/agent/ai-usage-summary.d.ts +37 -0
- package/dist-server/service/agent/ai-usage-summary.js +108 -0
- package/dist-server/service/agent/ai-usage-summary.js.map +1 -0
- package/dist-server/service/agent/ai-usage.d.ts +35 -0
- package/dist-server/service/agent/ai-usage.js +118 -0
- package/dist-server/service/agent/ai-usage.js.map +1 -0
- package/dist-server/service/agent/index.d.ts +7 -0
- package/dist-server/service/agent/index.js +13 -0
- package/dist-server/service/agent/index.js.map +1 -0
- package/dist-server/service/agent/provider-models.d.ts +12 -0
- package/dist-server/service/agent/provider-models.js +37 -0
- package/dist-server/service/agent/provider-models.js.map +1 -0
- package/dist-server/service/capability.d.ts +21 -10
- package/dist-server/service/capability.js +20 -49
- package/dist-server/service/capability.js.map +1 -1
- package/dist-server/service/factory.d.ts +7 -12
- package/dist-server/service/factory.js +0 -45
- package/dist-server/service/factory.js.map +1 -1
- package/dist-server/service/index.d.ts +3 -0
- package/dist-server/service/index.js +6 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/providers/anthropic.d.ts +2 -0
- package/dist-server/service/providers/anthropic.js +16 -0
- package/dist-server/service/providers/anthropic.js.map +1 -1
- package/dist-server/service/providers/gemini.d.ts +13 -0
- package/dist-server/service/providers/gemini.js +52 -12
- package/dist-server/service/providers/gemini.js.map +1 -1
- package/dist-server/service/providers/openai.d.ts +6 -0
- package/dist-server/service/providers/openai.js +34 -8
- package/dist-server/service/providers/openai.js.map +1 -1
- package/dist-server/service/types.d.ts +16 -2
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -2
- package/server/service/agent/agent-directory.test.ts +206 -0
- package/server/service/agent/agent-directory.ts +198 -0
- package/server/service/agent/agent-settings-save.test.ts +151 -0
- package/server/service/agent/agent-settings-save.ts +205 -0
- package/server/service/agent/agent-spec.ts +103 -0
- package/server/service/agent/ai-client-for.test.ts +141 -0
- package/server/service/agent/ai-client-for.ts +201 -0
- package/server/service/agent/ai-usage-summary.test.ts +65 -0
- package/server/service/agent/ai-usage-summary.ts +146 -0
- package/server/service/agent/ai-usage.ts +97 -0
- package/server/service/agent/index.ts +7 -0
- package/server/service/agent/one-door.test.ts +57 -0
- package/server/service/agent/provider-models.ts +43 -0
- package/server/service/capability.test.ts +26 -55
- package/server/service/capability.ts +25 -42
- package/server/service/factory.ts +4 -44
- package/server/service/index.ts +6 -1
- package/server/service/providers/anthropic.ts +17 -0
- package/server/service/providers/gemini.ts +54 -12
- package/server/service/providers/openai-models.test.ts +30 -0
- package/server/service/providers/openai.ts +34 -8
- package/server/service/types.ts +18 -2
- package/translations/en.json +13 -0
- package/translations/es.json +13 -0
- package/translations/ja.json +13 -0
- package/translations/ko.json +13 -0
- package/translations/ms.json +13 -0
- 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.
|
|
3
|
+
"version": "10.1.155",
|
|
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.155",
|
|
27
28
|
"@things-factory/env": "^10.1.149",
|
|
29
|
+
"@things-factory/setting-base": "^10.1.155",
|
|
30
|
+
"@things-factory/shell": "^10.1.155",
|
|
28
31
|
"openai": "^4.0.0"
|
|
29
32
|
},
|
|
30
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "0211c9c0764a26c10151fdc7d60094fc334905ff"
|
|
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
|
+
})
|