@pikku/core 0.12.63 → 0.12.64
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/CHANGELOG.md +147 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +32 -25
- package/dist/permissions.js +104 -185
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
- package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -0
- package/dist/wirings/ai-agent/index.js +1 -0
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +2 -38
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +0 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +1 -9
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/package.json +5 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +153 -372
- package/src/permissions.ts +139 -259
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
- package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
- package/src/wirings/ai-agent/index.ts +1 -0
- package/src/wirings/ai-agent/voice-input.ts +6 -35
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +0 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +1 -10
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -3,12 +3,16 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
|
|
4
4
|
import { resetPikkuState, pikkuState } from '../../pikku-state.js'
|
|
5
5
|
import {
|
|
6
|
+
agentSessionScope,
|
|
7
|
+
assertResourceOwner,
|
|
6
8
|
buildInstructions,
|
|
7
9
|
buildToolDefs,
|
|
8
10
|
createScopedChannel,
|
|
9
11
|
getAddonCredentialRequirements,
|
|
10
12
|
resolveAgent,
|
|
13
|
+
resolveOwnerResourceId,
|
|
11
14
|
} from './ai-agent-prepare.js'
|
|
15
|
+
import { ForbiddenError } from '../../errors/errors.js'
|
|
12
16
|
import type {
|
|
13
17
|
AIStreamChannel,
|
|
14
18
|
AIStreamEvent,
|
|
@@ -150,6 +154,7 @@ describe('ai-agent-prepare', () => {
|
|
|
150
154
|
toolCallId: 'tc-1',
|
|
151
155
|
toolName: 'tool-a',
|
|
152
156
|
args: { x: 1 },
|
|
157
|
+
reason: 'Delete todo "x"',
|
|
153
158
|
runId: 'run-1',
|
|
154
159
|
} as AIStreamEvent)
|
|
155
160
|
channel.send({ type: 'done' } as AIStreamEvent)
|
|
@@ -161,6 +166,7 @@ describe('ai-agent-prepare', () => {
|
|
|
161
166
|
toolCallId: 'tc-1',
|
|
162
167
|
toolName: 'tool-a',
|
|
163
168
|
args: { x: 1 },
|
|
169
|
+
reason: 'Delete todo "x"',
|
|
164
170
|
runId: 'run-1',
|
|
165
171
|
},
|
|
166
172
|
])
|
|
@@ -237,6 +243,106 @@ describe('ai-agent-prepare', () => {
|
|
|
237
243
|
assert.equal(afterCalls.length, 1)
|
|
238
244
|
})
|
|
239
245
|
|
|
246
|
+
test('buildToolDefs resolves addon-scoped services for approvalDescription on a cold services cache', async () => {
|
|
247
|
+
addAgent('todo-agent')
|
|
248
|
+
pikkuState(null, 'agent', 'agentsMeta')['todo-agent'] = {
|
|
249
|
+
...pikkuState(null, 'agent', 'agentsMeta')['todo-agent'],
|
|
250
|
+
tools: ['todos:deleteTodo'],
|
|
251
|
+
} as any
|
|
252
|
+
pikkuState(null, 'addons', 'packages').set('todos', {
|
|
253
|
+
package: '@test/todos-addon',
|
|
254
|
+
})
|
|
255
|
+
pikkuState('@test/todos-addon', 'function', 'meta').deleteTodo = {
|
|
256
|
+
description: 'Deletes a todo by ID',
|
|
257
|
+
approvalRequired: true,
|
|
258
|
+
inputSchemaName: 'DeleteTodoInput',
|
|
259
|
+
sessionless: true,
|
|
260
|
+
} as any
|
|
261
|
+
pikkuState('@test/todos-addon', 'misc', 'schemas').set('DeleteTodoInput', {
|
|
262
|
+
type: 'object',
|
|
263
|
+
})
|
|
264
|
+
pikkuState('@test/todos-addon', 'function', 'functions').set('deleteTodo', {
|
|
265
|
+
func: async ({ todoStore }: any, { id }: any) => todoStore.delete(id),
|
|
266
|
+
approvalDescription: async ({ todoStore }: any, { id }: any) =>
|
|
267
|
+
`Delete the todo called "${todoStore.get(id)?.title ?? id}"`,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// The addon builds `todoStore` in its own singleton services, which are NOT
|
|
271
|
+
// in the root services and are only cached the first time the addon runs.
|
|
272
|
+
// The approval is raised before the tool executes, so the cache is cold.
|
|
273
|
+
pikkuState('@test/todos-addon', 'package', 'factories', {
|
|
274
|
+
createSingletonServices: async () => ({
|
|
275
|
+
todoStore: {
|
|
276
|
+
get: (id: string) =>
|
|
277
|
+
id === '1' ? { id: '1', title: 'Buy groceries' } : undefined,
|
|
278
|
+
delete: () => true,
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
} as any)
|
|
282
|
+
|
|
283
|
+
const singletonServices = {
|
|
284
|
+
logger: { warn: () => {} },
|
|
285
|
+
} as any
|
|
286
|
+
pikkuState(null, 'package', 'singletonServices', singletonServices)
|
|
287
|
+
|
|
288
|
+
const { tools } = await buildToolDefs(
|
|
289
|
+
{},
|
|
290
|
+
new Map<string, string>(),
|
|
291
|
+
'resource-1',
|
|
292
|
+
'todo-agent',
|
|
293
|
+
null
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
assert.equal(tools.length, 1)
|
|
297
|
+
assert.equal(tools[0].needsApproval, true)
|
|
298
|
+
assert.equal(
|
|
299
|
+
await tools[0].approvalDescriptionFn?.({ id: '1' }),
|
|
300
|
+
'Delete the todo called "Buy groceries"'
|
|
301
|
+
)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('buildToolDefs logs a tool execute() failure even without aiMiddleware hooks, then rethrows', async () => {
|
|
305
|
+
addAgent('ops-agent')
|
|
306
|
+
pikkuState(null, 'agent', 'agentsMeta')['ops-agent'] = {
|
|
307
|
+
...pikkuState(null, 'agent', 'agentsMeta')['ops-agent'],
|
|
308
|
+
tools: ['searchInventory'],
|
|
309
|
+
} as any
|
|
310
|
+
pikkuState(null, 'rpc', 'meta').searchInventory = 'searchInventory'
|
|
311
|
+
pikkuState(null, 'function', 'meta').searchInventory = {
|
|
312
|
+
description: 'Search inventory',
|
|
313
|
+
inputSchemaName: 'SearchInput',
|
|
314
|
+
sessionless: true,
|
|
315
|
+
}
|
|
316
|
+
pikkuState(null, 'misc', 'schemas').set('SearchInput', { type: 'object' })
|
|
317
|
+
pikkuState(null, 'function', 'functions').set('searchInventory', {
|
|
318
|
+
func: async () => {
|
|
319
|
+
throw new Error('db unreachable')
|
|
320
|
+
},
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const errorCalls: unknown[][] = []
|
|
324
|
+
const singletonServices = {
|
|
325
|
+
logger: {
|
|
326
|
+
warn: () => {},
|
|
327
|
+
error: (...args: unknown[]) => errorCalls.push(args),
|
|
328
|
+
},
|
|
329
|
+
} as any
|
|
330
|
+
pikkuState(null, 'package', 'singletonServices', singletonServices)
|
|
331
|
+
|
|
332
|
+
const { tools } = await buildToolDefs(
|
|
333
|
+
{},
|
|
334
|
+
new Map<string, string>(),
|
|
335
|
+
'resource-1',
|
|
336
|
+
'ops-agent',
|
|
337
|
+
null
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
assert.equal(tools.length, 1)
|
|
341
|
+
await assert.rejects(() => tools[0].execute({}), /db unreachable/)
|
|
342
|
+
assert.equal(errorCalls.length, 1)
|
|
343
|
+
assert.match(String(errorCalls[0][0]), /searchInventory.*execute/)
|
|
344
|
+
})
|
|
345
|
+
|
|
240
346
|
test('buildToolDefs skips permissioned tools without a session and adds sub-agent tools', async () => {
|
|
241
347
|
addAgent('manager', {
|
|
242
348
|
agents: ['assistant'],
|
|
@@ -291,4 +397,206 @@ describe('ai-agent-prepare', () => {
|
|
|
291
397
|
required: ['message', 'session'],
|
|
292
398
|
})
|
|
293
399
|
})
|
|
400
|
+
|
|
401
|
+
test('buildToolDefs adds a workflow tool that runs the workflow and returns its output', async () => {
|
|
402
|
+
addAgent('planner', { workflows: ['buildReport'] } as any)
|
|
403
|
+
pikkuState(null, 'agent', 'agentsMeta').planner = {
|
|
404
|
+
...pikkuState(null, 'agent', 'agentsMeta').planner,
|
|
405
|
+
workflows: ['buildReport'],
|
|
406
|
+
} as any
|
|
407
|
+
|
|
408
|
+
pikkuState(null, 'workflows', 'meta').buildReport = {
|
|
409
|
+
name: 'buildReport',
|
|
410
|
+
pikkuFuncId: 'buildReport',
|
|
411
|
+
source: 'graph',
|
|
412
|
+
description: 'Build a report for a country',
|
|
413
|
+
} as any
|
|
414
|
+
pikkuState(null, 'function', 'meta').buildReport = {
|
|
415
|
+
inputSchemaName: 'BuildReportInput',
|
|
416
|
+
sessionless: true,
|
|
417
|
+
} as any
|
|
418
|
+
pikkuState(null, 'misc', 'schemas').set('BuildReportInput', {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: { country: { type: 'string' } },
|
|
421
|
+
required: ['country'],
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
const runCalls: Array<{ name: string; input: unknown }> = []
|
|
425
|
+
const workflowService = {
|
|
426
|
+
runToCompletion: async (name: string, input: unknown) => {
|
|
427
|
+
runCalls.push({ name, input })
|
|
428
|
+
return { capital: 'Paris' }
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
const singletonServices = {
|
|
432
|
+
logger: { warn: () => {} },
|
|
433
|
+
workflowService,
|
|
434
|
+
} as any
|
|
435
|
+
pikkuState(null, 'package', 'singletonServices', singletonServices)
|
|
436
|
+
|
|
437
|
+
const { tools, missingRpcs } = await buildToolDefs(
|
|
438
|
+
{},
|
|
439
|
+
new Map<string, string>(),
|
|
440
|
+
'resource-1',
|
|
441
|
+
'planner',
|
|
442
|
+
null
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
assert.deepEqual(missingRpcs, [])
|
|
446
|
+
assert.equal(tools.length, 1)
|
|
447
|
+
assert.equal(tools[0].name, 'buildReport')
|
|
448
|
+
assert.equal(tools[0].description, 'Build a report for a country')
|
|
449
|
+
assert.deepEqual(tools[0].inputSchema, {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: { country: { type: 'string' } },
|
|
452
|
+
required: ['country'],
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
const result = await tools[0].execute({ country: 'France' })
|
|
456
|
+
assert.deepEqual(result, { capital: 'Paris' })
|
|
457
|
+
assert.deepEqual(runCalls, [
|
|
458
|
+
{ name: 'buildReport', input: { country: 'France' } },
|
|
459
|
+
])
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('buildToolDefs reports an unknown workflow tool as missing', async () => {
|
|
463
|
+
addAgent('planner2', { workflows: ['ghost'] } as any)
|
|
464
|
+
pikkuState(null, 'agent', 'agentsMeta').planner2 = {
|
|
465
|
+
...pikkuState(null, 'agent', 'agentsMeta').planner2,
|
|
466
|
+
workflows: ['ghost'],
|
|
467
|
+
} as any
|
|
468
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
469
|
+
logger: { warn: () => {} },
|
|
470
|
+
workflowService: { runToCompletion: async () => ({}) },
|
|
471
|
+
} as any)
|
|
472
|
+
|
|
473
|
+
const { tools, missingRpcs } = await buildToolDefs(
|
|
474
|
+
{},
|
|
475
|
+
new Map<string, string>(),
|
|
476
|
+
'resource-1',
|
|
477
|
+
'planner2',
|
|
478
|
+
null
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
assert.equal(tools.length, 0)
|
|
482
|
+
assert.deepEqual(missingRpcs, ['ghost'])
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
describe('C2 thread/run ownership + sessionScope', () => {
|
|
487
|
+
const params = (session?: Record<string, unknown>) => ({
|
|
488
|
+
sessionService: { get: () => session } as never,
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
test('user scope composes the trusted userId with the requested resourceId', () => {
|
|
492
|
+
assert.equal(
|
|
493
|
+
resolveOwnerResourceId(params({ userId: 'user-a' }), 'user', 'default'),
|
|
494
|
+
'user-a:default'
|
|
495
|
+
)
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
test('user scope is the default when sessionScope is undefined', () => {
|
|
499
|
+
assert.equal(
|
|
500
|
+
resolveOwnerResourceId(params({ userId: 'user-a' }), undefined, 'p1'),
|
|
501
|
+
'user-a:p1'
|
|
502
|
+
)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test('user scope sub-partitions within the owner (client resourceId regains meaning)', () => {
|
|
506
|
+
assert.equal(
|
|
507
|
+
resolveOwnerResourceId(params({ userId: 'alice' }), 'user', 'project-1'),
|
|
508
|
+
'alice:project-1'
|
|
509
|
+
)
|
|
510
|
+
assert.equal(
|
|
511
|
+
resolveOwnerResourceId(params({ userId: 'alice' }), 'user', 'project-2'),
|
|
512
|
+
'alice:project-2'
|
|
513
|
+
)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('is idempotent: re-normalizing an already-owned key does not double-compose', () => {
|
|
517
|
+
assert.equal(
|
|
518
|
+
resolveOwnerResourceId(
|
|
519
|
+
params({ userId: 'alice' }),
|
|
520
|
+
'user',
|
|
521
|
+
'alice:project-1'
|
|
522
|
+
),
|
|
523
|
+
'alice:project-1'
|
|
524
|
+
)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
test('a client cannot forge another owner: a foreign prefix is re-scoped, not trusted', () => {
|
|
528
|
+
assert.equal(
|
|
529
|
+
resolveOwnerResourceId(params({ userId: 'bob' }), 'user', 'alice:secret'),
|
|
530
|
+
'bob:alice:secret'
|
|
531
|
+
)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('user scope falls back to the bare requested resourceId when sessionless', () => {
|
|
535
|
+
assert.equal(resolveOwnerResourceId({}, 'user', 'resource-1'), 'resource-1')
|
|
536
|
+
assert.equal(
|
|
537
|
+
resolveOwnerResourceId(params(undefined), 'user', 'resource-1'),
|
|
538
|
+
'resource-1'
|
|
539
|
+
)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
test('org scope composes the trusted orgId with the requested resourceId', () => {
|
|
543
|
+
assert.equal(
|
|
544
|
+
resolveOwnerResourceId(
|
|
545
|
+
params({ userId: 'user-a', orgId: 'org-x' }),
|
|
546
|
+
'org',
|
|
547
|
+
'default'
|
|
548
|
+
),
|
|
549
|
+
'org-x:default'
|
|
550
|
+
)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('org scope denies (ForbiddenError) when the session has no org', () => {
|
|
554
|
+
assert.throws(
|
|
555
|
+
() =>
|
|
556
|
+
resolveOwnerResourceId(params({ userId: 'user-a' }), 'org', 'default'),
|
|
557
|
+
(e: unknown) => e instanceof ForbiddenError
|
|
558
|
+
)
|
|
559
|
+
assert.throws(
|
|
560
|
+
() => resolveOwnerResourceId({}, 'org', 'default'),
|
|
561
|
+
(e: unknown) => e instanceof ForbiddenError
|
|
562
|
+
)
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
test('assertResourceOwner throws ForbiddenError on an owner mismatch', () => {
|
|
566
|
+
assert.throws(
|
|
567
|
+
() => assertResourceOwner('user-a:d', 'user-b:d', 'thread'),
|
|
568
|
+
(e: unknown) =>
|
|
569
|
+
e instanceof ForbiddenError && !/user-a|user-b/.test(e.message)
|
|
570
|
+
)
|
|
571
|
+
assert.doesNotThrow(() =>
|
|
572
|
+
assertResourceOwner('user-a:d', 'user-a:d', 'run')
|
|
573
|
+
)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test('agentSessionScope reads the declared scope and defaults to user', () => {
|
|
577
|
+
addAgent('scoped-user')
|
|
578
|
+
addAgent('scoped-org', { sessionScope: 'org' })
|
|
579
|
+
assert.equal(agentSessionScope('scoped-user'), 'user')
|
|
580
|
+
assert.equal(agentSessionScope('scoped-org'), 'org')
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
test('resume ownership: owner passes, a different user is rejected (via idempotent recompose)', () => {
|
|
584
|
+
const stored = 'alice:default'
|
|
585
|
+
assert.doesNotThrow(() =>
|
|
586
|
+
assertResourceOwner(
|
|
587
|
+
resolveOwnerResourceId(params({ userId: 'alice' }), 'user', stored),
|
|
588
|
+
stored,
|
|
589
|
+
'run'
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
assert.throws(
|
|
593
|
+
() =>
|
|
594
|
+
assertResourceOwner(
|
|
595
|
+
resolveOwnerResourceId(params({ userId: 'bob' }), 'user', stored),
|
|
596
|
+
stored,
|
|
597
|
+
'run'
|
|
598
|
+
),
|
|
599
|
+
(e: unknown) => e instanceof ForbiddenError
|
|
600
|
+
)
|
|
601
|
+
})
|
|
294
602
|
})
|
|
@@ -8,11 +8,13 @@ import type {
|
|
|
8
8
|
AIStreamChannel,
|
|
9
9
|
AIStreamEvent,
|
|
10
10
|
PikkuAIMiddlewareHooks,
|
|
11
|
+
SessionScope,
|
|
11
12
|
} from './ai-agent.types.js'
|
|
12
13
|
import type { AIAgentRunnerParams } from '../../services/ai-agent-runner-service.js'
|
|
13
14
|
import { PikkuError } from '../../errors/error-handler.js'
|
|
14
15
|
import { checkAuthPermissions } from '../../permissions.js'
|
|
15
16
|
import { AIProviderNotConfiguredError } from '../../errors/errors.js'
|
|
17
|
+
import { ForbiddenError } from '../../errors/errors.js'
|
|
16
18
|
import { pikkuState, getSingletonServices } from '../../pikku-state.js'
|
|
17
19
|
import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
|
|
18
20
|
import type { SessionService } from '../../services/user-session-service.js'
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
resolveNamespace,
|
|
24
26
|
ContextAwareRPCService,
|
|
25
27
|
} from '../../wirings/rpc/rpc-runner.js'
|
|
28
|
+
import { getOrCreatePackageSingletonServices } from '../../wirings/rpc/addon-runner.js'
|
|
26
29
|
import {
|
|
27
30
|
resolveMemoryServices,
|
|
28
31
|
loadContextMessages,
|
|
@@ -36,10 +39,91 @@ export type RunAIAgentParams = {
|
|
|
36
39
|
getCredential?: <T = unknown>(name: string) => T | null | Promise<T | null>
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The ownership key for a thread/run: the trusted principal (the authenticated
|
|
44
|
+
* `session.userId` for `'user'` scope, or `session.orgId` for `'org'` scope)
|
|
45
|
+
* composed with the client-supplied `resourceId` as a sub-partition —
|
|
46
|
+
* `principal:resourceId`. Because the principal is always the prefix, a client
|
|
47
|
+
* `resourceId` can sub-divide within the caller's own boundary but can never
|
|
48
|
+
* widen access to another user's or org's threads.
|
|
49
|
+
*
|
|
50
|
+
* Idempotent: a value that is already within the caller's namespace (top-level
|
|
51
|
+
* re-entry, sub-agent recursion, or resume, which all pass an already-composite
|
|
52
|
+
* `resourceId`) is returned unchanged rather than double-composed.
|
|
53
|
+
*
|
|
54
|
+
* `'org'` scope with no session org throws {@link ForbiddenError} — falling back
|
|
55
|
+
* to a bare/shared key would leak across organizations. Sessionless `'user'`
|
|
56
|
+
* wirings have no trusted principal and fall back to the requested `resourceId`
|
|
57
|
+
* as a best-effort partition key.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveOwnerResourceId(
|
|
60
|
+
params: RunAIAgentParams,
|
|
61
|
+
sessionScope: SessionScope | undefined,
|
|
62
|
+
requestedResourceId: string
|
|
63
|
+
): string {
|
|
64
|
+
const session = params.sessionService?.get()
|
|
65
|
+
let principal: string | undefined
|
|
66
|
+
if ((sessionScope ?? 'user') === 'org') {
|
|
67
|
+
if (!session?.orgId) {
|
|
68
|
+
throw new ForbiddenError(
|
|
69
|
+
'This agent is org-scoped but the session has no organization'
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
principal = session.orgId
|
|
73
|
+
} else {
|
|
74
|
+
principal = session?.userId
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!principal) return requestedResourceId
|
|
78
|
+
if (
|
|
79
|
+
requestedResourceId === principal ||
|
|
80
|
+
requestedResourceId.startsWith(`${principal}:`)
|
|
81
|
+
) {
|
|
82
|
+
return requestedResourceId
|
|
83
|
+
}
|
|
84
|
+
return `${principal}:${requestedResourceId}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The declared {@link SessionScope} for an agent, defaulting to `'user'`. */
|
|
88
|
+
export function agentSessionScope(agentName: string): SessionScope {
|
|
89
|
+
return resolveAgent(agentName).agent.sessionScope ?? 'user'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enforce that a stored thread/run belongs to the caller's owner resource,
|
|
94
|
+
* throwing {@link ForbiddenError} (without echoing the id, to avoid an
|
|
95
|
+
* existence oracle) on a mismatch.
|
|
96
|
+
*/
|
|
97
|
+
export function assertResourceOwner(
|
|
98
|
+
ownerResourceId: string,
|
|
99
|
+
storedResourceId: string,
|
|
100
|
+
kind: 'thread' | 'run'
|
|
101
|
+
): void {
|
|
102
|
+
if (storedResourceId !== ownerResourceId) {
|
|
103
|
+
throw new ForbiddenError(`Not authorized to access this ${kind}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
39
107
|
export type StreamAIAgentOptions = {
|
|
40
108
|
requiresToolApproval?: 'all' | 'explicit' | false
|
|
109
|
+
/**
|
|
110
|
+
* Invoked as soon as the run's AIRunStateService id exists, so transport
|
|
111
|
+
* wrappers (e.g. the AG-UI bridge) can stamp the real runId on RUN_STARTED.
|
|
112
|
+
*/
|
|
113
|
+
onRunCreated?: (runId: string) => void
|
|
41
114
|
}
|
|
42
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Non-forgeable brand for the sub-agent approval marker. Only framework code
|
|
118
|
+
* (the delegating sub-agent tools below) sets this Symbol on a tool result; a
|
|
119
|
+
* sub-agent's LLM-shaped `result.object` — plain JSON — cannot carry a Symbol,
|
|
120
|
+
* so `checkForApprovals` trusts the brand rather than the `__approvalRequired`
|
|
121
|
+
* string key to decide an approval must be surfaced.
|
|
122
|
+
*/
|
|
123
|
+
export const APPROVAL_REQUIRED: unique symbol = Symbol(
|
|
124
|
+
'pikku.ai.approvalRequired'
|
|
125
|
+
)
|
|
126
|
+
|
|
43
127
|
export class ToolApprovalRequired extends PikkuError {
|
|
44
128
|
public readonly toolCallId: string
|
|
45
129
|
public readonly toolName: string
|
|
@@ -193,7 +277,7 @@ export async function buildInstructions(
|
|
|
193
277
|
if (meta?.goal) parts.push(meta.goal)
|
|
194
278
|
let instructions = parts.join('\n\n')
|
|
195
279
|
|
|
196
|
-
if (meta?.tools?.length) {
|
|
280
|
+
if (meta?.tools?.length || meta?.workflows?.length) {
|
|
197
281
|
instructions +=
|
|
198
282
|
'\n\nTool usage rules:\n' +
|
|
199
283
|
'- Act immediately with the information given. Do NOT ask clarifying questions unless a required field is truly missing.\n' +
|
|
@@ -219,6 +303,7 @@ export type ScopedChannel = AIStreamChannel & {
|
|
|
219
303
|
toolCallId: string
|
|
220
304
|
toolName: string
|
|
221
305
|
args: unknown
|
|
306
|
+
reason?: string
|
|
222
307
|
runId: string
|
|
223
308
|
}>
|
|
224
309
|
}
|
|
@@ -248,6 +333,7 @@ export function createScopedChannel(
|
|
|
248
333
|
toolCallId: event.toolCallId,
|
|
249
334
|
toolName: event.toolName,
|
|
250
335
|
args: event.args,
|
|
336
|
+
...(event.reason !== undefined ? { reason: event.reason } : {}),
|
|
251
337
|
runId: (event as any).runId,
|
|
252
338
|
})
|
|
253
339
|
return
|
|
@@ -373,17 +459,27 @@ export async function buildToolDefs(
|
|
|
373
459
|
if (funcConfig?.approvalDescription) {
|
|
374
460
|
const descFn = funcConfig.approvalDescription
|
|
375
461
|
const capturedPkg = resolvedPkg
|
|
462
|
+
const capturedAddonConfig = resolved?.addonConfig
|
|
463
|
+
const capturedNamespace = toolName.includes(':')
|
|
464
|
+
? toolName.slice(0, toolName.indexOf(':'))
|
|
465
|
+
: null
|
|
376
466
|
approvalDescriptionFn = async (input: unknown) => {
|
|
377
467
|
let services = singletonServices
|
|
378
468
|
if (capturedPkg) {
|
|
379
|
-
const
|
|
469
|
+
const addonInstance = capturedNamespace
|
|
470
|
+
? {
|
|
471
|
+
namespace: capturedNamespace,
|
|
472
|
+
secretOverrides: capturedAddonConfig?.secretOverrides,
|
|
473
|
+
variableOverrides: capturedAddonConfig?.variableOverrides,
|
|
474
|
+
credentialOverrides:
|
|
475
|
+
capturedAddonConfig?.credentialOverrides,
|
|
476
|
+
}
|
|
477
|
+
: undefined
|
|
478
|
+
services = await getOrCreatePackageSingletonServices(
|
|
380
479
|
capturedPkg,
|
|
381
|
-
|
|
382
|
-
|
|
480
|
+
singletonServices,
|
|
481
|
+
addonInstance
|
|
383
482
|
)
|
|
384
|
-
if (pkgServices) {
|
|
385
|
-
services = pkgServices
|
|
386
|
-
}
|
|
387
483
|
}
|
|
388
484
|
return descFn(services, input)
|
|
389
485
|
}
|
|
@@ -437,6 +533,7 @@ export async function buildToolDefs(
|
|
|
437
533
|
tools.push({
|
|
438
534
|
name: subAgentName,
|
|
439
535
|
description: subMeta.description,
|
|
536
|
+
forwardsApproval: true,
|
|
440
537
|
inputSchema: {
|
|
441
538
|
type: 'object',
|
|
442
539
|
properties: {
|
|
@@ -502,6 +599,7 @@ export async function buildToolDefs(
|
|
|
502
599
|
)
|
|
503
600
|
if (subChannel.approvals.length > 0) {
|
|
504
601
|
return {
|
|
602
|
+
[APPROVAL_REQUIRED]: true,
|
|
505
603
|
__approvalRequired: true,
|
|
506
604
|
toolName: subAgentName,
|
|
507
605
|
args: toolInput,
|
|
@@ -530,6 +628,7 @@ export async function buildToolDefs(
|
|
|
530
628
|
result.pendingApprovals?.length
|
|
531
629
|
) {
|
|
532
630
|
return {
|
|
631
|
+
[APPROVAL_REQUIRED]: true,
|
|
533
632
|
__approvalRequired: true,
|
|
534
633
|
toolName: subAgentName,
|
|
535
634
|
args: toolInput,
|
|
@@ -538,6 +637,7 @@ export async function buildToolDefs(
|
|
|
538
637
|
toolCallId: a.toolCallId,
|
|
539
638
|
toolName: a.toolName,
|
|
540
639
|
args: a.args,
|
|
640
|
+
reason: a.reason,
|
|
541
641
|
runId: a.runId,
|
|
542
642
|
})),
|
|
543
643
|
}
|
|
@@ -548,6 +648,84 @@ export async function buildToolDefs(
|
|
|
548
648
|
}
|
|
549
649
|
}
|
|
550
650
|
|
|
651
|
+
const metaWorkflows = meta.workflows
|
|
652
|
+
if (metaWorkflows?.length) {
|
|
653
|
+
const workflowMetaMap = pikkuState(null, 'workflows', 'meta')
|
|
654
|
+
const functionMeta = pikkuState(null, 'function', 'meta')
|
|
655
|
+
const schemas = pikkuState(null, 'misc', 'schemas')
|
|
656
|
+
|
|
657
|
+
for (const workflowName of metaWorkflows) {
|
|
658
|
+
const wfMeta = workflowMetaMap[workflowName]
|
|
659
|
+
if (!wfMeta) {
|
|
660
|
+
missingRpcs.push(workflowName)
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const inputSchemaName = wfMeta.pikkuFuncId
|
|
665
|
+
? functionMeta[wfMeta.pikkuFuncId]?.inputSchemaName
|
|
666
|
+
: undefined
|
|
667
|
+
let inputSchema = inputSchemaName
|
|
668
|
+
? schemas.get(inputSchemaName)
|
|
669
|
+
: undefined
|
|
670
|
+
if (
|
|
671
|
+
!inputSchema ||
|
|
672
|
+
(typeof inputSchema === 'object' &&
|
|
673
|
+
inputSchema.type === 'object' &&
|
|
674
|
+
!inputSchema.properties)
|
|
675
|
+
) {
|
|
676
|
+
inputSchema = { type: 'object', properties: {} }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
tools.push({
|
|
680
|
+
name: workflowName,
|
|
681
|
+
description: wfMeta.description || workflowName,
|
|
682
|
+
inputSchema,
|
|
683
|
+
execute: async (toolInput: unknown) => {
|
|
684
|
+
const workflowService = singletonServices.workflowService
|
|
685
|
+
if (!workflowService) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`workflowService is not configured — cannot run workflow tool '${workflowName}'`
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
const wire: PikkuRawWire = params.sessionService
|
|
691
|
+
? { ...createMiddlewareSessionWireProps(params.sessionService) }
|
|
692
|
+
: {}
|
|
693
|
+
const rpcService = new ContextAwareRPCService(
|
|
694
|
+
singletonServices,
|
|
695
|
+
wire,
|
|
696
|
+
{ sessionService: params.sessionService }
|
|
697
|
+
)
|
|
698
|
+
return workflowService.runToCompletion(
|
|
699
|
+
workflowName,
|
|
700
|
+
toolInput,
|
|
701
|
+
rpcService,
|
|
702
|
+
{ wire: { type: 'internal' } }
|
|
703
|
+
)
|
|
704
|
+
},
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// A tool's execute() can throw (bad input, RPC failure, DB error, ...). The AI
|
|
710
|
+
// SDK catches that at the tool-call boundary and turns it into a conversational
|
|
711
|
+
// "tool error" reply — without this, the exception never reaches pikku's own
|
|
712
|
+
// logger, so a failing tool is undiagnosable server-side. Log unconditionally,
|
|
713
|
+
// even when no aiMiddleware afterToolCall hook is registered to see it.
|
|
714
|
+
for (const tool of tools) {
|
|
715
|
+
const originalExecute = tool.execute
|
|
716
|
+
tool.execute = async (toolInput: unknown) => {
|
|
717
|
+
try {
|
|
718
|
+
return await originalExecute(toolInput)
|
|
719
|
+
} catch (err) {
|
|
720
|
+
singletonServices.logger.error(
|
|
721
|
+
`AI agent tool '${tool.name}' threw during execute()`,
|
|
722
|
+
err
|
|
723
|
+
)
|
|
724
|
+
throw err
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
551
729
|
const hasToolHooks = aiMiddlewares?.some(
|
|
552
730
|
(mw) => mw.beforeToolCall || mw.afterToolCall
|
|
553
731
|
)
|
|
@@ -649,9 +827,15 @@ export async function prepareAgentRun(
|
|
|
649
827
|
: undefined
|
|
650
828
|
|
|
651
829
|
if (storage) {
|
|
830
|
+
let thread: Awaited<ReturnType<typeof storage.getThread>> | null = null
|
|
652
831
|
try {
|
|
653
|
-
await storage.getThread(threadId)
|
|
832
|
+
thread = await storage.getThread(threadId)
|
|
654
833
|
} catch {
|
|
834
|
+
thread = null
|
|
835
|
+
}
|
|
836
|
+
if (thread) {
|
|
837
|
+
assertResourceOwner(input.resourceId, thread.resourceId, 'thread')
|
|
838
|
+
} else {
|
|
655
839
|
await storage.createThread(input.resourceId, { threadId })
|
|
656
840
|
}
|
|
657
841
|
}
|