@pikku/core 0.12.2 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/crypto-utils.d.ts +10 -0
  3. package/dist/crypto-utils.js +62 -0
  4. package/dist/function/function-runner.js +4 -4
  5. package/dist/function/functions.types.d.ts +26 -1
  6. package/dist/function/functions.types.js +16 -0
  7. package/dist/handle-error.d.ts +1 -1
  8. package/dist/handle-error.js +7 -3
  9. package/dist/index.d.ts +14 -2
  10. package/dist/index.js +1 -1
  11. package/dist/pikku-state.js +3 -1
  12. package/dist/schema.js +1 -1
  13. package/dist/services/in-memory-workflow-service.d.ts +8 -2
  14. package/dist/services/in-memory-workflow-service.js +28 -1
  15. package/dist/services/workflow-service.d.ts +8 -2
  16. package/dist/testing/index.d.ts +2 -0
  17. package/dist/testing/index.js +1 -0
  18. package/dist/testing/service-tests.d.ts +31 -0
  19. package/dist/testing/service-tests.js +598 -0
  20. package/dist/types/core.types.d.ts +9 -1
  21. package/dist/types/state.types.d.ts +4 -2
  22. package/dist/utils/hash.d.ts +2 -0
  23. package/dist/utils/hash.js +23 -0
  24. package/dist/wirings/ai-agent/agent-dynamic-workflow.d.ts +6 -0
  25. package/dist/wirings/ai-agent/agent-dynamic-workflow.js +348 -0
  26. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +1 -1
  27. package/dist/wirings/ai-agent/ai-agent-memory.js +6 -5
  28. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +10 -7
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +127 -47
  30. package/dist/wirings/ai-agent/ai-agent-runner.d.ts +4 -0
  31. package/dist/wirings/ai-agent/ai-agent-runner.js +381 -27
  32. package/dist/wirings/ai-agent/ai-agent-stream.d.ts +7 -2
  33. package/dist/wirings/ai-agent/ai-agent-stream.js +109 -46
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +22 -1
  35. package/dist/wirings/ai-agent/index.d.ts +1 -2
  36. package/dist/wirings/ai-agent/index.js +1 -2
  37. package/dist/wirings/channel/channel-handler.js +5 -1
  38. package/dist/wirings/channel/channel.types.d.ts +1 -7
  39. package/dist/wirings/channel/local/local-channel-handler.d.ts +1 -1
  40. package/dist/wirings/channel/local/local-channel-handler.js +5 -3
  41. package/dist/wirings/channel/local/local-channel-runner.js +14 -4
  42. package/dist/wirings/channel/serverless/serverless-channel-runner.js +4 -1
  43. package/dist/wirings/cli/cli-runner.js +10 -2
  44. package/dist/wirings/cli/cli.types.d.ts +1 -0
  45. package/dist/wirings/http/http-routes.js +0 -1
  46. package/dist/wirings/http/http-runner.d.ts +1 -1
  47. package/dist/wirings/http/http-runner.js +25 -3
  48. package/dist/wirings/http/http.types.d.ts +3 -11
  49. package/dist/wirings/http/index.d.ts +2 -1
  50. package/dist/wirings/http/index.js +1 -0
  51. package/dist/wirings/http/pikku-fetch-http-request.d.ts +1 -0
  52. package/dist/wirings/http/pikku-fetch-http-request.js +3 -0
  53. package/dist/wirings/http/pikku-fetch-http-response.d.ts +1 -0
  54. package/dist/wirings/http/pikku-fetch-http-response.js +4 -4
  55. package/dist/wirings/http/web-request.d.ts +12 -0
  56. package/dist/wirings/http/web-request.js +85 -0
  57. package/dist/wirings/mcp/mcp-endpoint-registry.js +1 -1
  58. package/dist/wirings/mcp/mcp-runner.js +13 -1
  59. package/dist/wirings/rpc/rpc-runner.d.ts +30 -6
  60. package/dist/wirings/rpc/rpc-runner.js +36 -14
  61. package/dist/wirings/rpc/rpc-types.d.ts +6 -1
  62. package/dist/wirings/rpc/wire-addon.d.ts +1 -0
  63. package/dist/wirings/rpc/wire-addon.js +1 -1
  64. package/dist/wirings/scheduler/scheduler-runner.js +1 -0
  65. package/dist/wirings/workflow/graph/graph-validation.d.ts +3 -0
  66. package/dist/wirings/workflow/graph/graph-validation.js +175 -0
  67. package/dist/wirings/workflow/graph/index.d.ts +1 -0
  68. package/dist/wirings/workflow/graph/index.js +1 -0
  69. package/dist/wirings/workflow/index.d.ts +1 -1
  70. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -2
  71. package/dist/wirings/workflow/pikku-workflow-service.js +1 -2
  72. package/dist/wirings/workflow/workflow.types.d.ts +10 -1
  73. package/package.json +4 -2
  74. package/src/crypto-utils.test.ts +99 -1
  75. package/src/crypto-utils.ts +114 -0
  76. package/src/function/function-runner.ts +5 -5
  77. package/src/function/functions.types.ts +38 -1
  78. package/src/handle-error.test.ts +65 -2
  79. package/src/handle-error.ts +8 -4
  80. package/src/index.ts +14 -0
  81. package/src/pikku-state.ts +3 -1
  82. package/src/schema.ts +1 -1
  83. package/src/services/in-memory-workflow-service.ts +45 -2
  84. package/src/services/workflow-service.ts +12 -1
  85. package/src/testing/index.ts +2 -0
  86. package/src/testing/service-tests.ts +873 -0
  87. package/src/types/core.types.ts +9 -1
  88. package/src/types/state.types.ts +4 -2
  89. package/src/utils/hash.test.ts +68 -0
  90. package/src/utils/hash.ts +26 -0
  91. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +469 -0
  92. package/src/wirings/ai-agent/ai-agent-memory.ts +7 -6
  93. package/src/wirings/ai-agent/ai-agent-prepare.ts +175 -61
  94. package/src/wirings/ai-agent/ai-agent-runner.test.ts +3 -1
  95. package/src/wirings/ai-agent/ai-agent-runner.ts +553 -31
  96. package/src/wirings/ai-agent/ai-agent-stream.test.ts +209 -2
  97. package/src/wirings/ai-agent/ai-agent-stream.ts +182 -89
  98. package/src/wirings/ai-agent/ai-agent.types.ts +23 -1
  99. package/src/wirings/ai-agent/index.ts +1 -5
  100. package/src/wirings/channel/channel-handler.ts +11 -2
  101. package/src/wirings/channel/channel.types.ts +1 -7
  102. package/src/wirings/channel/local/local-channel-handler.ts +5 -3
  103. package/src/wirings/channel/local/local-channel-runner.ts +17 -7
  104. package/src/wirings/channel/serverless/serverless-channel-runner.ts +4 -1
  105. package/src/wirings/cli/cli-runner.ts +12 -2
  106. package/src/wirings/cli/cli.types.ts +1 -0
  107. package/src/wirings/http/http-routes.ts +0 -1
  108. package/src/wirings/http/http-runner.ts +35 -10
  109. package/src/wirings/http/http.types.ts +3 -12
  110. package/src/wirings/http/index.ts +3 -0
  111. package/src/wirings/http/pikku-fetch-http-request.ts +4 -0
  112. package/src/wirings/http/pikku-fetch-http-response.ts +4 -4
  113. package/src/wirings/http/web-request.test.ts +236 -0
  114. package/src/wirings/http/web-request.ts +104 -0
  115. package/src/wirings/mcp/mcp-endpoint-registry.ts +1 -1
  116. package/src/wirings/mcp/mcp-runner.ts +30 -12
  117. package/src/wirings/rpc/rpc-runner.ts +45 -24
  118. package/src/wirings/rpc/rpc-types.ts +6 -1
  119. package/src/wirings/rpc/wire-addon.ts +2 -1
  120. package/src/wirings/scheduler/scheduler-runner.ts +1 -0
  121. package/src/wirings/workflow/graph/graph-validation.test.ts +170 -0
  122. package/src/wirings/workflow/graph/graph-validation.ts +237 -0
  123. package/src/wirings/workflow/graph/index.ts +5 -0
  124. package/src/wirings/workflow/index.ts +1 -0
  125. package/src/wirings/workflow/pikku-workflow-service.ts +14 -3
  126. package/src/wirings/workflow/workflow.types.ts +9 -1
  127. package/tsconfig.tsbuildinfo +1 -1
  128. package/dist/wirings/ai-agent/ai-agent-assistant-ui.d.ts +0 -5
  129. package/dist/wirings/ai-agent/ai-agent-assistant-ui.js +0 -168
  130. package/src/wirings/ai-agent/ai-agent-assistant-ui.test.ts +0 -363
  131. package/src/wirings/ai-agent/ai-agent-assistant-ui.ts +0 -238
@@ -94,7 +94,12 @@ describe('streamAIAgent', () => {
94
94
  {}
95
95
  )
96
96
 
97
- assert.deepEqual(updates, [{ runId: 'run-1', patch: { status: 'failed' } }])
97
+ assert.deepEqual(updates, [
98
+ {
99
+ runId: 'run-1',
100
+ patch: { status: 'failed', errorMessage: 'stream failed' },
101
+ },
102
+ ])
98
103
  assert.deepEqual(
99
104
  events.map((event) => event.type),
100
105
  ['step-start', 'error', 'done']
@@ -198,7 +203,7 @@ describe('streamAIAgent', () => {
198
203
  pikkuState(null, 'rpc', 'meta')['tool-x'] = 'tool-x'
199
204
  pikkuState(null, 'function', 'meta')['tool-x'] = {
200
205
  description: 'test tool',
201
- requiresApproval: true,
206
+ approvalRequired: true,
202
207
  }
203
208
  pikkuState(null, 'misc', 'schemas').set('ToolXInput', {
204
209
  type: 'object',
@@ -260,6 +265,208 @@ describe('streamAIAgent', () => {
260
265
  )
261
266
  })
262
267
 
268
+ test('suspends run when multiple tool calls need approval (explicit policy)', async () => {
269
+ addTestAgent('multi-approval-agent')
270
+
271
+ const updates: Array<{ runId: string; patch: unknown }> = []
272
+ const events: AIStreamEvent[] = []
273
+
274
+ const mockServices = {
275
+ logger: {
276
+ info: () => {},
277
+ warn: () => {},
278
+ error: () => {},
279
+ debug: () => {},
280
+ },
281
+ aiAgentRunner: {
282
+ stream: async (): Promise<AIAgentStepResult> => {
283
+ // Simulate LLM returning 3 addTodo calls in one step
284
+ return makeStepResult({
285
+ toolCalls: [
286
+ {
287
+ toolCallId: 'tc-1',
288
+ toolName: 'todos__addTodo',
289
+ args: { title: 'todo 1' },
290
+ },
291
+ {
292
+ toolCallId: 'tc-2',
293
+ toolName: 'todos__addTodo',
294
+ args: { title: 'todo 2' },
295
+ },
296
+ {
297
+ toolCallId: 'tc-3',
298
+ toolName: 'todos__addTodo',
299
+ args: { title: 'todo 3' },
300
+ },
301
+ ],
302
+ // No toolResults since tools have no execute (needsApproval)
303
+ })
304
+ },
305
+ },
306
+ aiRunState: {
307
+ createRun: async () => 'run-multi-approval',
308
+ updateRun: async (runId: string, patch: unknown) => {
309
+ updates.push({ runId, patch })
310
+ },
311
+ },
312
+ } as any
313
+
314
+ pikkuState(null, 'package', 'singletonServices', mockServices)
315
+
316
+ const agentName = 'multi-approval-agent'
317
+ pikkuState(null, 'agent', 'agentsMeta')[agentName].tools = [
318
+ 'todos__addTodo',
319
+ ]
320
+ pikkuState(null, 'rpc', 'meta')['todos__addTodo'] = 'addTodo'
321
+ pikkuState(null, 'function', 'meta')['addTodo'] = {
322
+ description: 'Add a todo',
323
+ approvalRequired: true,
324
+ inputSchemaName: 'AddTodoInput',
325
+ }
326
+ pikkuState(null, 'misc', 'schemas').set('AddTodoInput', {
327
+ type: 'object',
328
+ properties: { title: { type: 'string' } },
329
+ })
330
+
331
+ await streamAIAgent(
332
+ agentName,
333
+ {
334
+ message: 'create 3 todos',
335
+ threadId: 'thread-multi-approval',
336
+ resourceId: 'resource-multi-approval',
337
+ },
338
+ {
339
+ channelId: 'channel-multi-approval',
340
+ openingData: undefined,
341
+ state: 'open',
342
+ send: (event: AIStreamEvent) => {
343
+ events.push(event)
344
+ },
345
+ close: () => {},
346
+ },
347
+ {}
348
+ // No options → defaults to requiresToolApproval: 'explicit'
349
+ )
350
+
351
+ // Should suspend on first tool call
352
+ const approvalEvent = events.find(
353
+ (e) => e.type === 'approval-request'
354
+ ) as any
355
+ assert.ok(approvalEvent, 'Should have an approval-request event')
356
+ assert.equal(approvalEvent.toolCallId, 'tc-1')
357
+ assert.equal(approvalEvent.toolName, 'todos__addTodo')
358
+
359
+ // Run should be suspended
360
+ assert.ok(
361
+ updates.some((u) => (u.patch as any).status === 'suspended'),
362
+ 'Run should be suspended for approval'
363
+ )
364
+
365
+ assert.ok(events.some((e) => e.type === 'done'))
366
+ })
367
+
368
+ test('suspends run for addon tools using namespaced resolution (explicit policy)', async () => {
369
+ addTestAgent('addon-approval-agent')
370
+
371
+ const updates: Array<{ runId: string; patch: unknown }> = []
372
+ const events: AIStreamEvent[] = []
373
+
374
+ const mockServices = {
375
+ logger: {
376
+ info: () => {},
377
+ warn: () => {},
378
+ error: () => {},
379
+ debug: () => {},
380
+ },
381
+ aiAgentRunner: {
382
+ stream: async (params: any): Promise<AIAgentStepResult> => {
383
+ // Verify the tool was built with needsApproval
384
+ const addTodoTool = params.tools.find(
385
+ (t: any) => t.name === 'todos__addTodo'
386
+ )
387
+ assert.ok(addTodoTool, 'Tool todos__addTodo should exist')
388
+ assert.equal(
389
+ addTodoTool.needsApproval,
390
+ true,
391
+ 'Tool should have needsApproval=true'
392
+ )
393
+
394
+ // Simulate LLM calling the tool
395
+ return makeStepResult({
396
+ toolCalls: [
397
+ {
398
+ toolCallId: 'tc-1',
399
+ toolName: 'todos__addTodo',
400
+ args: { title: 'new todo' },
401
+ },
402
+ ],
403
+ })
404
+ },
405
+ },
406
+ aiRunState: {
407
+ createRun: async () => 'run-addon-approval',
408
+ updateRun: async (runId: string, patch: unknown) => {
409
+ updates.push({ runId, patch })
410
+ },
411
+ },
412
+ } as any
413
+
414
+ pikkuState(null, 'package', 'singletonServices', mockServices)
415
+
416
+ // Register addon package
417
+ pikkuState(null, 'addons', 'packages').set('todos', {
418
+ package: '@test/addon-todos',
419
+ })
420
+
421
+ // Set up addon package's function metadata
422
+ pikkuState('@test/addon-todos', 'function', 'meta')['addTodo'] = {
423
+ description: 'Add a todo',
424
+ approvalRequired: true,
425
+ inputSchemaName: 'AddTodoInput',
426
+ }
427
+ pikkuState('@test/addon-todos', 'misc', 'schemas').set('AddTodoInput', {
428
+ type: 'object',
429
+ properties: { title: { type: 'string' } },
430
+ })
431
+
432
+ // Set up agent with namespaced tool
433
+ const agentName = 'addon-approval-agent'
434
+ pikkuState(null, 'agent', 'agentsMeta')[agentName].tools = ['todos:addTodo']
435
+
436
+ await streamAIAgent(
437
+ agentName,
438
+ {
439
+ message: 'add a todo',
440
+ threadId: 'thread-addon-approval',
441
+ resourceId: 'resource-addon-approval',
442
+ },
443
+ {
444
+ channelId: 'channel-addon-approval',
445
+ openingData: undefined,
446
+ state: 'open',
447
+ send: (event: AIStreamEvent) => {
448
+ events.push(event)
449
+ },
450
+ close: () => {},
451
+ },
452
+ {}
453
+ )
454
+
455
+ // Should suspend on the tool call
456
+ const approvalEvent = events.find(
457
+ (e) => e.type === 'approval-request'
458
+ ) as any
459
+ assert.ok(approvalEvent, 'Should have an approval-request event')
460
+ assert.equal(approvalEvent.toolCallId, 'tc-1')
461
+ assert.equal(approvalEvent.toolName, 'todos__addTodo')
462
+
463
+ // Run should be suspended
464
+ assert.ok(
465
+ updates.some((u) => (u.patch as any).status === 'suspended'),
466
+ 'Run should be suspended for approval'
467
+ )
468
+ })
469
+
263
470
  test('suspends with agent-call type when sub-agent returns approval sentinel', async () => {
264
471
  addTestAgent('parent-agent')
265
472
 
@@ -39,10 +39,6 @@ import {
39
39
  type StreamAIAgentOptions,
40
40
  type StreamContext,
41
41
  } from './ai-agent-prepare.js'
42
- import {
43
- createAssistantUIChannel,
44
- parseAssistantUIInput,
45
- } from './ai-agent-assistant-ui.js'
46
42
  import { resolveModelConfig } from './ai-agent-model-config.js'
47
43
  import type { AIRunStateService } from '../../services/ai-run-state-service.js'
48
44
  import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
@@ -238,7 +234,7 @@ type StepLoopParams = {
238
234
 
239
235
  type StepLoopResult =
240
236
  | { outcome: 'done' }
241
- | { outcome: 'approval'; approval: ToolApprovalRequired }
237
+ | { outcome: 'approval'; approvals: ToolApprovalRequired[] }
242
238
 
243
239
  async function runStreamStepLoop(
244
240
  params: StepLoopParams
@@ -291,13 +287,26 @@ async function runStreamStepLoop(
291
287
 
292
288
  if (stepResult.toolCalls.length === 0) break
293
289
 
294
- const approvalNeeded = checkForApproval(
290
+ const approvalsNeeded = checkForApprovals(
295
291
  stepResult,
296
292
  runnerParams.tools,
297
293
  runId
298
294
  )
299
- if (approvalNeeded) {
300
- return { outcome: 'approval', approval: approvalNeeded }
295
+ if (approvalsNeeded.length > 0) {
296
+ // For each approval, call approvalDescriptionFn if available
297
+ for (const approval of approvalsNeeded) {
298
+ const toolDef = runnerParams.tools.find(
299
+ (t) => t.name === approval.toolName
300
+ )
301
+ if (toolDef?.approvalDescriptionFn && !approval.reason) {
302
+ try {
303
+ approval.reason = await toolDef.approvalDescriptionFn(approval.args)
304
+ } catch {
305
+ // If description generation fails, continue without it
306
+ }
307
+ }
308
+ }
309
+ return { outcome: 'approval', approvals: approvalsNeeded }
301
310
  }
302
311
 
303
312
  appendStepMessages(runnerParams, stepResult)
@@ -306,16 +315,20 @@ async function runStreamStepLoop(
306
315
  return { outcome: 'done' }
307
316
  }
308
317
 
309
- function checkForApproval(
318
+ export function checkForApprovals(
310
319
  stepResult: AIAgentStepResult,
311
320
  tools: AIAgentRunnerParams['tools'],
312
321
  runId: string
313
- ): ToolApprovalRequired | null {
322
+ ): ToolApprovalRequired[] {
323
+ const approvals: ToolApprovalRequired[] = []
314
324
  for (const tc of stepResult.toolCalls) {
315
325
  const toolDef = tools.find((t) => t.name === tc.toolName)
316
326
 
317
327
  if (toolDef?.needsApproval) {
318
- return new ToolApprovalRequired(tc.toolCallId, tc.toolName, tc.args)
328
+ approvals.push(
329
+ new ToolApprovalRequired(tc.toolCallId, tc.toolName, tc.args)
330
+ )
331
+ continue
319
332
  }
320
333
 
321
334
  const tr = stepResult.toolResults.find(
@@ -333,22 +346,46 @@ function checkForApproval(
333
346
  displayToolName?: string
334
347
  displayArgs?: unknown
335
348
  agentRunId?: string
349
+ subApprovals?: Array<{
350
+ toolCallId: string
351
+ toolName: string
352
+ args: unknown
353
+ runId: string
354
+ }>
355
+ }
356
+ if (r.subApprovals?.length) {
357
+ for (const sub of r.subApprovals) {
358
+ approvals.push(
359
+ new ToolApprovalRequired(
360
+ sub.toolCallId,
361
+ r.toolName,
362
+ r.args,
363
+ undefined,
364
+ sub.toolName,
365
+ sub.args,
366
+ r.agentRunId
367
+ )
368
+ )
369
+ }
370
+ } else {
371
+ approvals.push(
372
+ new ToolApprovalRequired(
373
+ tc.toolCallId,
374
+ r.toolName,
375
+ r.args,
376
+ r.reason,
377
+ r.displayToolName,
378
+ r.displayArgs,
379
+ r.agentRunId
380
+ )
381
+ )
336
382
  }
337
- return new ToolApprovalRequired(
338
- tc.toolCallId,
339
- r.toolName,
340
- r.args,
341
- r.reason,
342
- r.displayToolName,
343
- r.displayArgs,
344
- r.agentRunId
345
- )
346
383
  }
347
384
  }
348
- return null
385
+ return approvals
349
386
  }
350
387
 
351
- function appendStepMessages(
388
+ export function appendStepMessages(
352
389
  runnerParams: AIAgentRunnerParams,
353
390
  stepResult: AIAgentStepResult
354
391
  ): void {
@@ -384,8 +421,8 @@ function appendStepMessages(
384
421
  }
385
422
  }
386
423
 
387
- function handleApproval(
388
- err: ToolApprovalRequired,
424
+ function handleApprovals(
425
+ approvals: ToolApprovalRequired[],
389
426
  runId: string,
390
427
  channel: AIStreamChannel,
391
428
  aiRunState: AIRunStateService,
@@ -394,37 +431,41 @@ function handleApproval(
394
431
  return (async () => {
395
432
  await persistingChannel.flush()
396
433
 
397
- const pendingApproval = err.agentRunId
398
- ? {
399
- type: 'agent-call' as const,
400
- toolCallId: err.toolCallId,
401
- agentName: err.toolName,
402
- agentRunId: err.agentRunId,
403
- displayToolName: err.displayToolName ?? err.toolName,
404
- displayArgs: err.displayArgs ?? err.args,
405
- }
406
- : {
407
- type: 'tool-call' as const,
408
- toolCallId: err.toolCallId,
409
- toolName: err.toolName,
410
- args: err.args,
411
- }
434
+ const pendingApprovals = approvals.map((err) =>
435
+ err.agentRunId
436
+ ? {
437
+ type: 'agent-call' as const,
438
+ toolCallId: err.toolCallId,
439
+ agentName: err.toolName,
440
+ agentRunId: err.agentRunId,
441
+ displayToolName: err.displayToolName ?? err.toolName,
442
+ displayArgs: err.displayArgs ?? err.args,
443
+ }
444
+ : {
445
+ type: 'tool-call' as const,
446
+ toolCallId: err.toolCallId,
447
+ toolName: err.toolName,
448
+ args: err.args,
449
+ }
450
+ )
412
451
 
413
452
  await aiRunState.updateRun(runId, {
414
453
  status: 'suspended',
415
454
  suspendReason: 'approval',
416
- pendingApprovals: [pendingApproval],
455
+ pendingApprovals,
417
456
  })
418
457
 
419
- const approvalEvent = {
420
- type: 'approval-request' as const,
421
- toolCallId: err.toolCallId,
422
- toolName: err.displayToolName ?? err.toolName,
423
- args: err.displayArgs ?? err.args,
424
- reason: err.reason,
425
- runId,
458
+ for (const err of approvals) {
459
+ const approvalEvent = {
460
+ type: 'approval-request' as const,
461
+ toolCallId: err.toolCallId,
462
+ toolName: err.displayToolName ?? err.toolName,
463
+ args: err.displayArgs ?? err.args,
464
+ reason: err.reason,
465
+ runId,
466
+ }
467
+ channel.send(approvalEvent as any)
426
468
  }
427
- channel.send(approvalEvent as any)
428
469
  channel.send({ type: 'done' })
429
470
  channel.close()
430
471
  })()
@@ -432,34 +473,24 @@ function handleApproval(
432
473
 
433
474
  export async function streamAIAgent(
434
475
  agentName: string,
435
- input:
436
- | { message: string; threadId: string; resourceId: string }
437
- | Record<string, unknown>,
476
+ input: {
477
+ message: string
478
+ threadId: string
479
+ resourceId: string
480
+ model?: string
481
+ temperature?: number
482
+ },
438
483
  channel: AIStreamChannel,
439
484
  params: RunAIAgentParams,
440
485
  agentSessionMap?: Map<string, string>,
441
486
  options?: StreamAIAgentOptions
442
- ): Promise<void> {
487
+ ): Promise<string> {
443
488
  const sessionMap = agentSessionMap ?? new Map<string, string>()
444
489
 
445
- const { agent: resolvedAgentForProtocol } = resolveAgent(agentName)
446
- const inputRecord = input as Record<string, unknown>
447
- const useUIMessageStream =
448
- resolvedAgentForProtocol.protocol === 'ui-message-stream' ||
449
- Array.isArray(inputRecord.messages)
450
- let normalizedInput: { message: string; threadId: string; resourceId: string }
451
- if (useUIMessageStream) {
452
- normalizedInput = parseAssistantUIInput(inputRecord)
453
- channel = createAssistantUIChannel(channel)
454
- } else {
455
- normalizedInput = input as {
456
- message: string
457
- threadId: string
458
- resourceId: string
459
- }
460
- }
490
+ const normalizedInput = input
461
491
 
462
492
  const streamContext: StreamContext = { channel, options }
493
+ // delegateState is attached after prepareAgentRun resolves the agent config
463
494
 
464
495
  const {
465
496
  agent,
@@ -502,7 +533,7 @@ export async function streamAIAgent(
502
533
  })
503
534
  channel.send({ type: 'suspended', reason: 'rpc-missing', missingRpcs })
504
535
  channel.send({ type: 'done' })
505
- return
536
+ return ''
506
537
  }
507
538
 
508
539
  const aiMiddlewares: PikkuAIMiddlewareHooks[] = agent.aiMiddleware ?? []
@@ -580,8 +611,32 @@ export async function streamAIAgent(
580
611
  ).channel as AIStreamChannel)
581
612
  : channel
582
613
 
614
+ // In delegate mode (default), suppress parent's text from reaching the client
615
+ // AFTER a sub-agent has been called. If the parent responds directly (no delegation),
616
+ // its text goes through normally. Sub-agent text bypasses this path entirely
617
+ // (goes through subChannel → channel directly).
618
+ const isDelegateMode = agent.agentMode !== 'supervise' && meta.agents?.length
619
+ const delegateState = { delegated: false }
620
+ if (isDelegateMode) {
621
+ streamContext.delegateState = delegateState
622
+ }
623
+ const outputChannel = isDelegateMode
624
+ ? {
625
+ ...wrappedChannel,
626
+ send: (event: AIStreamEvent) => {
627
+ if (
628
+ delegateState.delegated &&
629
+ (event.type === 'text-delta' || event.type === 'reasoning-delta')
630
+ )
631
+ return
632
+ wrappedChannel.send(event)
633
+ },
634
+ delegateState,
635
+ }
636
+ : wrappedChannel
637
+
583
638
  const persistingChannel = createPersistingChannel(
584
- wrappedChannel,
639
+ outputChannel,
585
640
  storage,
586
641
  threadId
587
642
  )
@@ -599,14 +654,14 @@ export async function streamAIAgent(
599
654
  })
600
655
 
601
656
  if (loopResult.outcome === 'approval') {
602
- await handleApproval(
603
- loopResult.approval,
657
+ await handleApprovals(
658
+ loopResult.approvals,
604
659
  runId,
605
660
  channel,
606
661
  aiRunState,
607
662
  persistingChannel
608
663
  )
609
- return
664
+ return persistingChannel.fullText
610
665
  }
611
666
 
612
667
  await postStreamCleanup(
@@ -624,6 +679,7 @@ export async function streamAIAgent(
624
679
 
625
680
  channel.send({ type: 'done' })
626
681
  channel.close()
682
+ return persistingChannel.fullText
627
683
  } catch (err) {
628
684
  for (const mw of aiMiddlewares) {
629
685
  if (mw.onError) {
@@ -638,13 +694,17 @@ export async function streamAIAgent(
638
694
  }
639
695
  }
640
696
  }
641
- await aiRunState.updateRun(runId, { status: 'failed' })
697
+ await aiRunState.updateRun(runId, {
698
+ status: 'failed',
699
+ errorMessage: err instanceof Error ? err.message : String(err),
700
+ })
642
701
  channel.send({
643
702
  type: 'error',
644
703
  message: err instanceof Error ? err.message : String(err),
645
704
  })
646
705
  channel.send({ type: 'done' })
647
706
  channel.close()
707
+ return persistingChannel.fullText
648
708
  }
649
709
  }
650
710
 
@@ -696,7 +756,8 @@ export async function resumeAIAgent(
696
756
  await aiRunState.updateRun(pending.agentRunId, { status: 'failed' })
697
757
  }
698
758
 
699
- const denialResult = 'Tool call was not approved by the user'
759
+ const denialResult =
760
+ 'The user explicitly declined this action. Inform them that it was declined and do not retry.'
700
761
 
701
762
  if (storage) {
702
763
  await storage.saveMessages(run.threadId, [
@@ -718,6 +779,17 @@ export async function resumeAIAgent(
718
779
  ])
719
780
  }
720
781
 
782
+ // Check remaining pending approvals
783
+ const updatedRun = await aiRunState.getRun(run.runId)
784
+ const remaining = updatedRun?.pendingApprovals ?? []
785
+
786
+ if (remaining.length > 0) {
787
+ // Still waiting for more approvals - don't continue step loop
788
+ channel.send({ type: 'done' })
789
+ channel.close()
790
+ return
791
+ }
792
+
721
793
  await aiRunState.updateRun(run.runId, { status: 'running' })
722
794
 
723
795
  await continueAfterToolResult(
@@ -741,7 +813,9 @@ export async function resumeAIAgent(
741
813
  if (!subRun) {
742
814
  throw new Error(`Sub-agent run not found: ${pending.agentRunId}`)
743
815
  }
744
- const subPending = subRun.pendingApprovals?.[0]
816
+ const subPending =
817
+ subRun.pendingApprovals?.find((p) => p.toolCallId === input.toolCallId) ??
818
+ subRun.pendingApprovals?.[0]
745
819
  if (!subPending) {
746
820
  throw new Error(
747
821
  `No pending approval on sub-agent run ${pending.agentRunId}`
@@ -797,7 +871,7 @@ export async function resumeAIAgent(
797
871
  }
798
872
  const aiMiddlewaresForResume: PikkuAIMiddlewareHooks[] =
799
873
  agent.aiMiddleware ?? []
800
- const { tools } = buildToolDefs(
874
+ const { tools } = await buildToolDefs(
801
875
  params,
802
876
  new Map<string, string>(),
803
877
  run.resourceId,
@@ -818,10 +892,12 @@ export async function resumeAIAgent(
818
892
  typeof pending.args === 'string' ? JSON.parse(pending.args) : pending.args
819
893
 
820
894
  let toolResult: unknown
895
+ let isError = false
821
896
  try {
822
897
  toolResult = await matchingTool.execute(toolArgs)
823
898
  } catch (execErr) {
824
899
  toolResult = `Error: ${execErr instanceof Error ? execErr.message : String(execErr)}`
900
+ isError = true
825
901
  }
826
902
 
827
903
  const resultStr =
@@ -848,9 +924,21 @@ export async function resumeAIAgent(
848
924
  toolCallId: input.toolCallId,
849
925
  toolName: pending.toolName,
850
926
  result: toolResult,
927
+ ...(isError ? { isError: true } : {}),
851
928
  })
852
929
  }
853
930
 
931
+ // Check remaining pending approvals after processing this one
932
+ const updatedRun = await aiRunState.getRun(run.runId)
933
+ const remaining = updatedRun?.pendingApprovals ?? []
934
+
935
+ if (remaining.length > 0) {
936
+ // Still waiting for more approvals - don't continue step loop
937
+ channel.send({ type: 'done' })
938
+ channel.close()
939
+ return
940
+ }
941
+
854
942
  await aiRunState.updateRun(run.runId, { status: 'running' })
855
943
 
856
944
  await continueAfterToolResult(
@@ -906,7 +994,7 @@ async function continueAfterToolResult(
906
994
  const allMessages = [...contextMessages, ...messages]
907
995
  const trimmedMessages = trimMessages(allMessages)
908
996
 
909
- const instructions = buildInstructions(resolvedName, packageName)
997
+ const instructions = await buildInstructions(resolvedName, packageName)
910
998
 
911
999
  const aiMiddlewares: PikkuAIMiddlewareHooks[] = agent.aiMiddleware ?? []
912
1000
  let modifiedMessages = trimmedMessages
@@ -971,14 +1059,16 @@ async function continueAfterToolResult(
971
1059
  )
972
1060
 
973
1061
  const streamContext: StreamContext = { channel, options }
974
- const resumeTools = buildToolDefs(
975
- params,
976
- new Map<string, string>(),
977
- run.resourceId,
978
- resolvedName,
979
- packageName,
980
- streamContext,
981
- aiMiddlewares
1062
+ const resumeTools = (
1063
+ await buildToolDefs(
1064
+ params,
1065
+ new Map<string, string>(),
1066
+ run.resourceId,
1067
+ resolvedName,
1068
+ packageName,
1069
+ streamContext,
1070
+ aiMiddlewares
1071
+ )
982
1072
  ).tools
983
1073
 
984
1074
  const resolved = resolveModelConfig(resolvedName, agent)
@@ -1010,8 +1100,8 @@ async function continueAfterToolResult(
1010
1100
  })
1011
1101
 
1012
1102
  if (loopResult.outcome === 'approval') {
1013
- await handleApproval(
1014
- loopResult.approval,
1103
+ await handleApprovals(
1104
+ loopResult.approvals,
1015
1105
  run.runId,
1016
1106
  channel,
1017
1107
  aiRunState,
@@ -1049,7 +1139,10 @@ async function continueAfterToolResult(
1049
1139
  }
1050
1140
  }
1051
1141
  }
1052
- await aiRunState.updateRun(run.runId, { status: 'failed' })
1142
+ await aiRunState.updateRun(run.runId, {
1143
+ status: 'failed',
1144
+ errorMessage: err instanceof Error ? err.message : String(err),
1145
+ })
1053
1146
  channel.send({
1054
1147
  type: 'error',
1055
1148
  message: err instanceof Error ? err.message : String(err),