@pikku/inspector 0.12.69 → 0.12.72

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.
@@ -0,0 +1,134 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import type { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger } from '../types.js'
9
+ import {
10
+ serializeInspectorState,
11
+ deserializeInspectorState,
12
+ } from '../utils/serialize-inspector-state.js'
13
+
14
+ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
15
+ ({
16
+ debug: () => {},
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ diagnostic: ({ severity, code, message }) => {
21
+ if (severity !== 'warn') {
22
+ criticals.push({ code, message })
23
+ }
24
+ },
25
+ critical: (code: ErrorCode, message: string) => {
26
+ criticals.push({ code, message })
27
+ },
28
+ hasCriticalErrors: () => criticals.length > 0,
29
+ }) satisfies InspectorLogger
30
+
31
+ const inspectSource = async (prefix: string, source: string) => {
32
+ const rootDir = await mkdtemp(join(tmpdir(), prefix))
33
+ const file = join(rootDir, 'services.ts')
34
+ await writeFile(file, source)
35
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
36
+ try {
37
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
38
+ return { state, criticals }
39
+ } finally {
40
+ await rm(rootDir, { recursive: true, force: true })
41
+ }
42
+ }
43
+
44
+ describe('pikkuAddonServices — what an addon takes from its parent', () => {
45
+ test('a name destructured in the body, not the parameter list, is still forwarded', async () => {
46
+ const { state } = await inspectSource(
47
+ 'pikku-addon-body-destructure-',
48
+ [
49
+ "import { pikkuAddonServices } from '#pikku/addon/setup'",
50
+ 'export const createSingletonServices = pikkuAddonServices(',
51
+ ' async (_config, existingServices) => {',
52
+ ' const { kysely, secrets } = existingServices',
53
+ ' return { ...existingServices, stripeApi: makeStripe(secrets) }',
54
+ ' }',
55
+ ')',
56
+ ].join('\n')
57
+ )
58
+
59
+ assert.ok(
60
+ state.addonRequiredParentServices.includes('kysely'),
61
+ 'kysely is read off the parent bag and must be declared as required'
62
+ )
63
+ assert.deepEqual(state.addonCreatedServices, ['stripeApi'])
64
+ assert.equal(state.addonServicesFactorySeen, true)
65
+ })
66
+
67
+ test('an object rest is a binding name, not a service', async () => {
68
+ const { state } = await inspectSource(
69
+ 'pikku-addon-object-rest-',
70
+ [
71
+ "import { pikkuAddonServices } from '#pikku/addon/setup'",
72
+ 'export const createSingletonServices = pikkuAddonServices(',
73
+ ' async (_config, { kysely, ...parentServices }) => ({',
74
+ ' ...parentServices,',
75
+ ' kysely,',
76
+ ' stripeApi: makeStripe(),',
77
+ ' })',
78
+ ')',
79
+ ].join('\n')
80
+ )
81
+
82
+ assert.deepEqual(state.addonRequiredParentServices, ['kysely'])
83
+ assert.ok(
84
+ !state.addonRequiredParentServices.includes('parentServices'),
85
+ 'a rest binding names the leftover bag, not a service the parent provides'
86
+ )
87
+ })
88
+
89
+ test('a parenthesised return still yields its created services', async () => {
90
+ const { state } = await inspectSource(
91
+ 'pikku-addon-parenthesised-return-',
92
+ [
93
+ "import { pikkuAddonServices } from '#pikku/addon/setup'",
94
+ 'export const createSingletonServices = pikkuAddonServices(',
95
+ ' async (_config, existingServices) => {',
96
+ ' return ({ ...existingServices, stripeApi: makeStripe() })',
97
+ ' }',
98
+ ')',
99
+ ].join('\n')
100
+ )
101
+
102
+ assert.deepEqual(state.addonCreatedServices, ['stripeApi'])
103
+ assert.ok(
104
+ !state.addonRequiredParentServices.includes('stripeApi'),
105
+ 'a service the factory builds is not one the parent has to supply'
106
+ )
107
+ })
108
+
109
+ test('the addon contract survives a serialize/deserialize round trip', async () => {
110
+ const { state } = await inspectSource(
111
+ 'pikku-addon-round-trip-',
112
+ [
113
+ "import { pikkuAddonServices } from '#pikku/addon/setup'",
114
+ 'export const createSingletonServices = pikkuAddonServices(',
115
+ ' async (_config, existingServices) => {',
116
+ ' const { kysely } = existingServices',
117
+ ' return { ...existingServices, stripeApi: makeStripe(kysely) }',
118
+ ' }',
119
+ ')',
120
+ ].join('\n')
121
+ )
122
+
123
+ const restored = deserializeInspectorState(
124
+ JSON.parse(JSON.stringify(serializeInspectorState(state)))
125
+ )
126
+
127
+ assert.deepEqual(
128
+ restored.addonRequiredParentServices,
129
+ state.addonRequiredParentServices
130
+ )
131
+ assert.deepEqual(restored.addonCreatedServices, ['stripeApi'])
132
+ assert.equal(restored.addonServicesFactorySeen, true)
133
+ })
134
+ })
@@ -13,6 +13,106 @@ const wrapperFunctionMap: Record<string, string> = {
13
13
  pikkuServerLifecycle: 'ServerLifecycle',
14
14
  }
15
15
 
16
+ /**
17
+ * What an addon's `pikkuAddonServices` factory takes from the parent: the names
18
+ * destructured off its second parameter, either in the parameter list or from a
19
+ * `const { … } = existingServices` in the body.
20
+ */
21
+ const extractForwardedServices = (
22
+ functionNode: ts.ArrowFunction | ts.FunctionExpression
23
+ ): string[] => {
24
+ const forwarded: string[] = []
25
+ const secondParam = functionNode.parameters[1]
26
+ if (!secondParam) return forwarded
27
+
28
+ const collectBinding = (pattern: ts.ObjectBindingPattern) => {
29
+ for (const elem of pattern.elements) {
30
+ if (elem.dotDotDotToken) continue
31
+ const name =
32
+ elem.propertyName && ts.isIdentifier(elem.propertyName)
33
+ ? elem.propertyName.text
34
+ : ts.isIdentifier(elem.name)
35
+ ? elem.name.text
36
+ : undefined
37
+ if (name) forwarded.push(name)
38
+ }
39
+ }
40
+
41
+ if (ts.isObjectBindingPattern(secondParam.name)) {
42
+ collectBinding(secondParam.name)
43
+ return forwarded
44
+ }
45
+
46
+ if (!ts.isIdentifier(secondParam.name)) return forwarded
47
+ const paramName = secondParam.name.text
48
+ const body = functionNode.body
49
+ if (!ts.isBlock(body)) return forwarded
50
+
51
+ const visit = (node: ts.Node) => {
52
+ if (
53
+ ts.isVariableDeclaration(node) &&
54
+ ts.isObjectBindingPattern(node.name) &&
55
+ node.initializer &&
56
+ ts.isIdentifier(node.initializer) &&
57
+ node.initializer.text === paramName
58
+ ) {
59
+ collectBinding(node.name)
60
+ }
61
+ ts.forEachChild(node, visit)
62
+ }
63
+ ts.forEachChild(body, visit)
64
+
65
+ return forwarded
66
+ }
67
+
68
+ /**
69
+ * The service names the factory's returned object literals name. A spread is
70
+ * skipped — it carries the parent's bag through rather than naming anything —
71
+ * and a name the factory took off the parent is a forward, not a creation.
72
+ */
73
+ const extractReturnedServices = (
74
+ functionNode: ts.ArrowFunction | ts.FunctionExpression
75
+ ): string[] => {
76
+ const returned: string[] = []
77
+
78
+ const collect = (expression: ts.Expression) => {
79
+ while (ts.isParenthesizedExpression(expression)) {
80
+ expression = expression.expression
81
+ }
82
+ if (!ts.isObjectLiteralExpression(expression)) return
83
+ for (const property of expression.properties) {
84
+ if (ts.isSpreadAssignment(property)) continue
85
+ const name = property.name
86
+ if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) {
87
+ returned.push(name.text)
88
+ }
89
+ }
90
+ }
91
+
92
+ const body = functionNode.body
93
+ if (!ts.isBlock(body)) {
94
+ collect(body)
95
+ return returned
96
+ }
97
+
98
+ const visit = (node: ts.Node) => {
99
+ if (
100
+ ts.isFunctionDeclaration(node) ||
101
+ ts.isFunctionExpression(node) ||
102
+ ts.isArrowFunction(node)
103
+ ) {
104
+ return
105
+ }
106
+ if (ts.isReturnStatement(node) && node.expression) {
107
+ collect(node.expression)
108
+ }
109
+ ts.forEachChild(node, visit)
110
+ }
111
+ ts.forEachChild(body, visit)
112
+
113
+ return returned
114
+ }
115
+
16
116
  export const addFileWithFactory = (
17
117
  node: ts.Node,
18
118
  checker: ts.TypeChecker,
@@ -77,23 +177,15 @@ export const addFileWithFactory = (
77
177
 
78
178
  // Extract existing services an addon needs from the parent
79
179
  // (second parameter of pikkuAddonServices callback)
80
- if (
81
- wrapperFunctionName === 'pikkuAddonServices' &&
82
- functionNode &&
83
- functionNode.parameters.length >= 2
84
- ) {
85
- const secondParam = functionNode.parameters[1]
86
- if (secondParam && ts.isObjectBindingPattern(secondParam.name)) {
87
- for (const elem of secondParam.name.elements) {
88
- const name =
89
- elem.propertyName && ts.isIdentifier(elem.propertyName)
90
- ? elem.propertyName.text
91
- : ts.isIdentifier(elem.name)
92
- ? elem.name.text
93
- : undefined
94
- if (name) {
95
- state.addonRequiredParentServices.push(name)
96
- }
180
+ if (wrapperFunctionName === 'pikkuAddonServices' && functionNode) {
181
+ state.addonServicesFactorySeen = true
182
+ const forwarded = new Set(extractForwardedServices(functionNode))
183
+ for (const name of forwarded) {
184
+ state.addonRequiredParentServices.push(name)
185
+ }
186
+ for (const name of extractReturnedServices(functionNode)) {
187
+ if (!forwarded.has(name)) {
188
+ state.addonCreatedServices.push(name)
97
189
  }
98
190
  }
99
191
  }
@@ -161,3 +161,127 @@ export async function doWork() {
161
161
  }
162
162
  })
163
163
  })
164
+
165
+ describe('add-rpc-invocations — invokedAgentsByFile', () => {
166
+ test('a body-level runAgent() is attributed to its source file', async () => {
167
+ const { state, dir } = await inspectFiles({
168
+ 'caller.ts': `
169
+ declare const runAgent: (name: string, input: unknown) => Promise<unknown>
170
+ export async function askTheHouse() {
171
+ return runAgent('houseAssistant', { message: 'hi' })
172
+ }
173
+ `,
174
+ 'other.ts': `
175
+ export const unrelated = () => 'no agents here'
176
+ `,
177
+ })
178
+ try {
179
+ const byFile = state.agents.invokedAgentsByFile
180
+ assert.strictEqual(byFile.size, 1)
181
+ const [file, agents] = [...byFile.entries()][0]!
182
+ assert.ok(file.endsWith('caller.ts'))
183
+ assert.deepStrictEqual([...agents], ['houseAssistant'])
184
+ } finally {
185
+ await rm(dir, { recursive: true, force: true })
186
+ }
187
+ })
188
+
189
+ test('rpc.agent.run() is an agent invocation too', async () => {
190
+ const { state, dir } = await inspectFiles({
191
+ 'caller.ts': `
192
+ declare const rpc: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
193
+ export async function askTheHouse() {
194
+ return rpc.agent.run('houseAssistant', { message: 'hi' })
195
+ }
196
+ `,
197
+ })
198
+ try {
199
+ const agents = [...state.agents.invokedAgentsByFile.values()][0]!
200
+ assert.deepStrictEqual([...agents], ['houseAssistant'])
201
+ } finally {
202
+ await rm(dir, { recursive: true, force: true })
203
+ }
204
+ })
205
+
206
+ test('a non-null assertion on the rpc receiver is still an invocation', async () => {
207
+ const { state, dir } = await inspectFiles({
208
+ 'caller.ts': `
209
+ declare const rpc: { agent: { stream: (name: string, input: unknown) => Promise<unknown> } } | undefined
210
+ export async function askTheHouse() {
211
+ return rpc!.agent.stream('houseAssistant', { message: 'hi' })
212
+ }
213
+ `,
214
+ })
215
+ try {
216
+ const agents = [...state.agents.invokedAgentsByFile.values()][0]!
217
+ assert.deepStrictEqual([...agents], ['houseAssistant'])
218
+ } finally {
219
+ await rm(dir, { recursive: true, force: true })
220
+ }
221
+ })
222
+
223
+ test('multiple agent calls in one file accumulate under that file', async () => {
224
+ const { state, dir } = await inspectFiles({
225
+ 'caller.ts': `
226
+ declare const runAgent: (name: string, input: unknown) => Promise<unknown>
227
+ declare const rpc: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
228
+ export async function askEveryone() {
229
+ await runAgent('houseAssistant', {})
230
+ return rpc.agent.run('gardenAssistant', {})
231
+ }
232
+ `,
233
+ })
234
+ try {
235
+ const agents = [...state.agents.invokedAgentsByFile.values()][0]!
236
+ assert.deepStrictEqual([...agents].sort(), [
237
+ 'gardenAssistant',
238
+ 'houseAssistant',
239
+ ])
240
+ } finally {
241
+ await rm(dir, { recursive: true, force: true })
242
+ }
243
+ })
244
+
245
+ test('a dynamic agent name is warned about, not recorded', async () => {
246
+ const warnings: string[] = []
247
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-agent-invocations-test-'))
248
+ const path = join(dir, 'caller.ts')
249
+ await writeFile(
250
+ path,
251
+ `
252
+ declare const runAgent: (name: string, input: unknown) => Promise<unknown>
253
+ declare const suffix: string
254
+ export async function askTheHouse() {
255
+ return runAgent(\`house-\${suffix}\`, {})
256
+ }
257
+ `
258
+ )
259
+ const logger = makeLogger()
260
+ logger.warn = (message: string) => {
261
+ warnings.push(message)
262
+ }
263
+ try {
264
+ const state = await inspect(logger, [path], { rootDir: dir })
265
+ assert.strictEqual(state.agents.invokedAgentsByFile.size, 0)
266
+ assert.ok(warnings.some((w) => w.includes('dynamic agent invocation')))
267
+ } finally {
268
+ await rm(dir, { recursive: true, force: true })
269
+ }
270
+ })
271
+
272
+ test('run() on some other receiver is left alone', async () => {
273
+ const { state, dir } = await inspectFiles({
274
+ 'caller.ts': `
275
+ declare const client: { agent: { run: (name: string, input: unknown) => Promise<unknown> } }
276
+ export async function doWork() {
277
+ return client.agent.run('notAnAgentCall', {})
278
+ }
279
+ `,
280
+ })
281
+ try {
282
+ assert.strictEqual(state.agents.invokedAgentsByFile.size, 0)
283
+ } finally {
284
+ await rm(dir, { recursive: true, force: true })
285
+ }
286
+ })
287
+ })
@@ -51,6 +51,33 @@ function extractNamespace(functionRef: string): string | null {
51
51
  return null
52
52
  }
53
53
 
54
+ function agentInvocationName(
55
+ expression: ts.Expression
56
+ ): 'runAgent' | 'streamAgent' | 'rpc.agent.run' | 'rpc.agent.stream' | null {
57
+ if (ts.isIdentifier(expression)) {
58
+ if (expression.text === 'runAgent') return 'runAgent'
59
+ if (expression.text === 'streamAgent') return 'streamAgent'
60
+ return null
61
+ }
62
+ if (!ts.isPropertyAccessExpression(expression)) return null
63
+ const method = expression.name.text
64
+ if (method !== 'run' && method !== 'stream') return null
65
+ const agentReceiver = invocationReceiver(expression.expression)
66
+ if (
67
+ !ts.isPropertyAccessExpression(agentReceiver) ||
68
+ agentReceiver.name.text !== 'agent'
69
+ ) {
70
+ return null
71
+ }
72
+ const rpcReceiver = invocationReceiver(agentReceiver.expression)
73
+ const isRpc = ts.isIdentifier(rpcReceiver)
74
+ ? rpcReceiver.text === 'rpc'
75
+ : ts.isPropertyAccessExpression(rpcReceiver) &&
76
+ rpcReceiver.name.text === 'rpc'
77
+ if (!isRpc) return null
78
+ return method === 'run' ? 'rpc.agent.run' : 'rpc.agent.stream'
79
+ }
80
+
54
81
  /**
55
82
  * Scan for rpc.invoke() calls to track which functions are actually being invoked
56
83
  * Also detects addon usage via:
@@ -99,6 +126,31 @@ export function addRPCInvocations(
99
126
  }
100
127
  }
101
128
 
129
+ const agentCall = agentInvocationName(expression)
130
+ if (agentCall) {
131
+ const [firstArg] = args
132
+ if (firstArg && ts.isStringLiteral(firstArg)) {
133
+ const agentName = firstArg.text
134
+ logger.debug(`• Found ${agentCall}() call: ${agentName}`)
135
+ const sourceFileName = node.getSourceFile().fileName
136
+ let byFile = state.agents.invokedAgentsByFile.get(sourceFileName)
137
+ if (!byFile) {
138
+ byFile = new Set()
139
+ state.agents.invokedAgentsByFile.set(sourceFileName, byFile)
140
+ }
141
+ byFile.add(agentName)
142
+ } else if (
143
+ firstArg &&
144
+ (ts.isTemplateExpression(firstArg) ||
145
+ ts.isNoSubstitutionTemplateLiteral(firstArg))
146
+ ) {
147
+ logger.warn(`• Found dynamic agent invocation: ${firstArg.getText()}`)
148
+ logger.warn(
149
+ `\tYou can only use string literals for agent names, with ' or " and not \``
150
+ )
151
+ }
152
+ }
153
+
102
154
  // Check for rpc.invoke('...') calls
103
155
  const invokeReceiver = ts.isPropertyAccessExpression(expression)
104
156
  ? invocationReceiver(expression.expression)
@@ -245,6 +245,67 @@ describe('pikkuScenarioStep', () => {
245
245
  }
246
246
  })
247
247
 
248
+ test('a step whose prose opens with its own actor is a PKU681 critical', async () => {
249
+ const { criticals, cleanup } = await run([
250
+ "await scenario.given(`'shopper' buys an apple`, 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
251
+ "await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
252
+ ])
253
+ try {
254
+ assert.ok(
255
+ criticals.find((c) => c.code === 'PKU681'),
256
+ `the reporter already renders the actor as the subject, so this reads "Given shopper 'shopper' buys an apple", got: ${JSON.stringify(criticals)}`
257
+ )
258
+ } finally {
259
+ await cleanup()
260
+ }
261
+ })
262
+
263
+ test('an unquoted, capitalised actor opening the prose is caught too', async () => {
264
+ const { criticals, cleanup } = await run([
265
+ "await scenario.given('Shopper buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
266
+ "await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
267
+ ])
268
+ try {
269
+ assert.ok(
270
+ criticals.find((c) => c.code === 'PKU681'),
271
+ `quotes and case are decoration — the subject is still doubled, got: ${JSON.stringify(criticals)}`
272
+ )
273
+ } finally {
274
+ await cleanup()
275
+ }
276
+ })
277
+
278
+ test('a call-site description naming the actor is caught as well as the step name', async () => {
279
+ const { criticals, cleanup } = await run([
280
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper, description: `shopper buys an apple` })",
281
+ "await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
282
+ ])
283
+ try {
284
+ assert.ok(
285
+ criticals.find((c) => c.code === 'PKU681'),
286
+ `the description is what actually renders when it is given, got: ${JSON.stringify(criticals)}`
287
+ )
288
+ } finally {
289
+ await cleanup()
290
+ }
291
+ })
292
+
293
+ test('an actor named mid-sentence is left alone', async () => {
294
+ const { criticals, cleanup } = await run([
295
+ "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
296
+ "await scenario.then('sends shopper a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
297
+ ])
298
+ try {
299
+ assert.equal(
300
+ criticals.filter((c) => c.code === 'PKU681').length,
301
+ 0,
302
+ `only the subject position doubles up; naming someone mid-sentence is ordinary prose, got: ${JSON.stringify(criticals)}`
303
+ )
304
+ } finally {
305
+ await cleanup()
306
+ }
307
+ })
308
+
248
309
  test('a scenario that never asserts is a PKU680 critical', async () => {
249
310
  const { criticals, cleanup } = await run([
250
311
  "await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
@@ -26,6 +26,7 @@ export enum ErrorCode {
26
26
  SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
27
27
  SCENARIO_NOT_EXTRACTABLE = 'PKU679',
28
28
  SCENARIO_HAS_NO_ASSERTION = 'PKU680',
29
+ SCENARIO_STEP_PROSE_NAMES_ACTOR = 'PKU681',
29
30
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
30
31
  INVALID_DSL_WORKFLOW = 'PKU641',
31
32
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
package/src/inspector.ts CHANGED
@@ -83,6 +83,8 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
83
83
  wireServicesFactories: new Map(),
84
84
  wireServicesMeta: new Map(),
85
85
  addonRequiredParentServices: [],
86
+ addonCreatedServices: [],
87
+ addonServicesFactorySeen: false,
86
88
  addonServerlessIncompatible: new Map(),
87
89
  configFactories: new Map(),
88
90
  serverLifecycleFactories: new Map(),
@@ -163,6 +165,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
163
165
  agents: {
164
166
  agentsMeta: {},
165
167
  files: new Map(),
168
+ invokedAgentsByFile: new Map(),
166
169
  },
167
170
  scorers: {
168
171
  scorersMeta: {},
package/src/types.ts CHANGED
@@ -526,6 +526,8 @@ export interface InspectorState {
526
526
  wireServicesFactories: PathToNameAndType
527
527
  wireServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
528
528
  addonRequiredParentServices: string[] // services an addon needs from the parent (extracted from pikkuAddonServices 2nd param)
529
+ addonCreatedServices: string[] // services an addon's own pikkuAddonServices factory builds itself
530
+ addonServicesFactorySeen: boolean // this project declares a pikkuAddonServices factory, i.e. it is an addon
529
531
  addonServerlessIncompatible: Map<string, string[]> // namespace → service names that are serverless-incompatible (scoped per addon)
530
532
  configFactories: PathToNameAndType
531
533
  serverLifecycleFactories: PathToNameAndType
@@ -634,6 +636,16 @@ export interface InspectorState {
634
636
  agents: {
635
637
  agentsMeta: AgentsMeta
636
638
  files: Map<string, { path: string; exportedName: string }>
639
+ /**
640
+ * Agent registry keys named by a `runAgent`/`rpc.agent.run` call in a
641
+ * function body, keyed by the file the call was written in.
642
+ *
643
+ * The lookup those calls perform is in-process, so the agent has to be
644
+ * registered in the same deployment unit as its caller. Without this the
645
+ * deploy analyzer only ever saw an agent as its own unit, and a caller in
646
+ * another unit failed at run time with "AI agent not found".
647
+ */
648
+ invokedAgentsByFile: Map<string, Set<string>>
637
649
  }
638
650
  scorers: {
639
651
  scorersMeta: ScorerMeta