@pikku/core 0.12.80 → 0.12.83

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 (237) hide show
  1. package/CHANGELOG.md +345 -0
  2. package/dist/errors/index.d.ts +1 -1
  3. package/dist/errors/index.js +1 -1
  4. package/dist/function/function-runner.js +2 -5
  5. package/dist/function/index.d.ts +1 -1
  6. package/dist/index.d.ts +11 -11
  7. package/dist/index.js +3 -3
  8. package/dist/pikku-state.js +4 -0
  9. package/dist/services/ai-agent-runner-service.d.ts +7 -0
  10. package/dist/services/ai-run-state-service.d.ts +10 -0
  11. package/dist/services/in-memory-ai-run-state-service.d.ts +5 -1
  12. package/dist/services/in-memory-ai-run-state-service.js +9 -0
  13. package/dist/services/index.d.ts +15 -16
  14. package/dist/services/index.js +5 -5
  15. package/dist/services/meta-service.d.ts +2 -1
  16. package/dist/services/scoped-credential-service.d.ts +21 -0
  17. package/dist/services/scoped-credential-service.js +53 -0
  18. package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
  19. package/dist/types/core.types.d.ts +2 -3
  20. package/dist/types/state.types.d.ts +19 -1
  21. package/dist/wirings/actor-flow/index.d.ts +1 -1
  22. package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
  23. package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
  24. package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
  25. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
  26. package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
  27. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
  28. package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -0
  30. package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
  31. package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
  32. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
  33. package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
  35. package/dist/wirings/ai-agent/index.d.ts +8 -7
  36. package/dist/wirings/ai-agent/index.js +5 -4
  37. package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
  38. package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
  39. package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
  40. package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
  41. package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
  42. package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
  43. package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
  44. package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
  45. package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
  46. package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
  47. package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
  48. package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
  49. package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
  50. package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
  51. package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
  52. package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
  53. package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
  54. package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
  55. package/dist/wirings/ai-scorer/index.d.ts +6 -0
  56. package/dist/wirings/ai-scorer/index.js +5 -0
  57. package/dist/wirings/channel/index.d.ts +5 -6
  58. package/dist/wirings/channel/index.js +3 -4
  59. package/dist/wirings/channel/local/local-channel-runner.js +8 -1
  60. package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
  61. package/dist/wirings/cli/channel/index.d.ts +1 -2
  62. package/dist/wirings/cli/channel/index.js +0 -1
  63. package/dist/wirings/cli/cli-runner.js +13 -1
  64. package/dist/wirings/credential/index.d.ts +1 -1
  65. package/dist/wirings/gateway/index.d.ts +1 -1
  66. package/dist/wirings/http/http-runner.js +8 -2
  67. package/dist/wirings/http/index.d.ts +1 -2
  68. package/dist/wirings/mcp/index.d.ts +1 -1
  69. package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
  70. package/dist/wirings/mcp/mcp-runner.js +18 -5
  71. package/dist/wirings/persona/index.d.ts +3 -4
  72. package/dist/wirings/persona/index.js +2 -3
  73. package/dist/wirings/queue/index.d.ts +1 -3
  74. package/dist/wirings/queue/index.js +1 -3
  75. package/dist/wirings/rpc/addon-runner.d.ts +8 -0
  76. package/dist/wirings/rpc/addon-runner.js +31 -3
  77. package/dist/wirings/rpc/rpc-runner.js +4 -0
  78. package/dist/wirings/rpc/rpc-types.d.ts +8 -0
  79. package/dist/wirings/rpc/wire-addon.d.ts +25 -0
  80. package/dist/wirings/rpc/wire-addon.js +8 -0
  81. package/dist/wirings/scheduler/index.d.ts +1 -1
  82. package/dist/wirings/trigger/index.d.ts +1 -1
  83. package/dist/wirings/virtual-user/index.d.ts +5 -6
  84. package/dist/wirings/virtual-user/index.js +2 -4
  85. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
  86. package/dist/wirings/workflow/feature.d.ts +2 -1
  87. package/dist/wirings/workflow/index.d.ts +5 -16
  88. package/dist/wirings/workflow/index.js +1 -9
  89. package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -7
  90. package/dist/wirings/workflow/pikku-scenario-service.js +48 -13
  91. package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
  92. package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
  93. package/dist/wirings/workflow/scenario.types.d.ts +37 -0
  94. package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
  95. package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
  96. package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
  97. package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
  98. package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
  99. package/dist/wirings/workflow/workflow-approval.js +65 -2
  100. package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
  101. package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
  102. package/dist/wirings/workflow/workflow.types.d.ts +2 -37
  103. package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
  104. package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
  105. package/knowledge/decisions/internals/index.md +30 -3
  106. package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
  107. package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
  108. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
  109. package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
  110. package/knowledge/decisions/security/index.md +3 -1
  111. package/knowledge/questions/index.md +1 -1
  112. package/package.json +3 -2
  113. package/scripts/generate-api-report.mts +143 -18
  114. package/src/api-report.test.ts +2 -2
  115. package/src/errors/index.ts +1 -1
  116. package/src/function/function-runner.test.ts +52 -0
  117. package/src/function/function-runner.ts +5 -9
  118. package/src/function/index.ts +0 -2
  119. package/src/index.ts +0 -35
  120. package/src/pikku-state.ts +5 -0
  121. package/src/public-surface.json +81 -118
  122. package/src/services/ai-agent-runner-service.ts +12 -1
  123. package/src/services/ai-run-state-service.ts +11 -0
  124. package/src/services/in-memory-ai-run-state-service.ts +13 -0
  125. package/src/services/index.ts +7 -58
  126. package/src/services/meta-service.ts +2 -4
  127. package/src/services/scoped-credential-service.test.ts +86 -0
  128. package/src/services/scoped-credential-service.ts +63 -0
  129. package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
  130. package/src/types/core.types.ts +4 -7
  131. package/src/types/state.types.ts +21 -1
  132. package/src/wirings/actor-flow/index.ts +0 -3
  133. package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
  134. package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
  135. package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
  136. package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
  137. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
  138. package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
  139. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -0
  140. package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
  141. package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
  142. package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
  143. package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
  144. package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
  145. package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
  146. package/src/wirings/ai-agent/index.ts +2 -16
  147. package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
  148. package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
  149. package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
  150. package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
  151. package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
  152. package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
  153. package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
  154. package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
  155. package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
  156. package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
  157. package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
  158. package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
  159. package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
  160. package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
  161. package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
  162. package/src/wirings/ai-scorer/index.ts +24 -0
  163. package/src/wirings/channel/index.ts +1 -20
  164. package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
  165. package/src/wirings/channel/local/local-channel-runner.ts +8 -1
  166. package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
  167. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
  168. package/src/wirings/cli/channel/index.ts +0 -7
  169. package/src/wirings/cli/cli-runner.test.ts +68 -0
  170. package/src/wirings/cli/cli-runner.ts +18 -1
  171. package/src/wirings/credential/index.ts +0 -1
  172. package/src/wirings/gateway/index.ts +0 -3
  173. package/src/wirings/http/http-runner.test.ts +66 -0
  174. package/src/wirings/http/http-runner.ts +10 -2
  175. package/src/wirings/http/index.ts +1 -1
  176. package/src/wirings/mcp/index.ts +0 -1
  177. package/src/wirings/mcp/mcp-runner.test.ts +181 -0
  178. package/src/wirings/mcp/mcp-runner.ts +35 -5
  179. package/src/wirings/persona/index.ts +0 -8
  180. package/src/wirings/queue/index.ts +0 -14
  181. package/src/wirings/rpc/addon-runner.ts +62 -3
  182. package/src/wirings/rpc/addon-secrets.test.ts +391 -0
  183. package/src/wirings/rpc/rpc-runner.test.ts +2 -0
  184. package/src/wirings/rpc/rpc-runner.ts +4 -0
  185. package/src/wirings/rpc/rpc-types.ts +8 -0
  186. package/src/wirings/rpc/wire-addon.ts +33 -0
  187. package/src/wirings/scheduler/index.ts +0 -1
  188. package/src/wirings/trigger/index.ts +0 -1
  189. package/src/wirings/virtual-user/index.ts +0 -16
  190. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
  191. package/src/wirings/workflow/feature.ts +2 -5
  192. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  193. package/src/wirings/workflow/index.ts +2 -68
  194. package/src/wirings/workflow/pikku-scenario-service.ts +81 -16
  195. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
  196. package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
  197. package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
  198. package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
  199. package/src/wirings/workflow/scenario-step.types.ts +8 -0
  200. package/src/wirings/workflow/scenario.types.ts +63 -0
  201. package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
  202. package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
  203. package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
  204. package/src/wirings/workflow/workflow-approval.ts +113 -9
  205. package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
  206. package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
  207. package/src/wirings/workflow/workflow.types.ts +1 -63
  208. package/src/wirings-stay-decoupled.test.ts +6 -2
  209. package/tsconfig.tsbuildinfo +1 -1
  210. package/dist/internal.d.ts +0 -3
  211. package/dist/internal.js +0 -2
  212. package/dist/middleware/timeout.d.ts +0 -9
  213. package/dist/middleware/timeout.js +0 -15
  214. package/dist/pikku-response.d.ts +0 -6
  215. package/dist/pikku-response.js +0 -6
  216. package/dist/services/gopass-secrets.d.ts +0 -15
  217. package/dist/services/gopass-secrets.js +0 -76
  218. package/dist/services/http-scenario-actors.d.ts +0 -75
  219. package/dist/services/http-scenario-actors.js +0 -195
  220. package/dist/services/http-user-flow-actors.d.ts +0 -67
  221. package/dist/services/http-user-flow-actors.js +0 -193
  222. package/dist/services/scenario-actors-service.d.ts +0 -127
  223. package/dist/services/scenario-actors-service.js +0 -40
  224. package/dist/services/user-flow-actors-service.d.ts +0 -39
  225. package/dist/wirings/credential/wire-credential.d.ts +0 -48
  226. package/dist/wirings/credential/wire-credential.js +0 -47
  227. package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
  228. package/dist/wirings/oauth2/oauth2-client.js +0 -263
  229. package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
  230. package/dist/wirings/oauth2/oauth2-routes.js +0 -146
  231. package/dist/wirings/scope/wire-scope.d.ts +0 -33
  232. package/dist/wirings/scope/wire-scope.js +0 -32
  233. package/dist/wirings/workflow/dsl/index.d.ts +0 -5
  234. package/dist/wirings/workflow/dsl/index.js +0 -4
  235. package/dist/wirings/workflow/graph/index.d.ts +0 -5
  236. package/dist/wirings/workflow/graph/index.js +0 -4
  237. /package/dist/{services/user-flow-actors-service.js → wirings/workflow/scenario.types.js} +0 -0
@@ -49,6 +49,11 @@ import {
49
49
  notifyAfterStep,
50
50
  toAccumulatedStep,
51
51
  } from './ai-agent-turn.js'
52
+ import {
53
+ applyOutputMiddleware,
54
+ finalizeAgentRun,
55
+ lastUserMessageText,
56
+ } from './ai-agent-finalize.js'
52
57
  import { resolveModelConfig } from './ai-agent-model-config.js'
53
58
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
54
59
  import { randomUUID } from './ai-agent-utils.js'
@@ -303,20 +308,13 @@ export async function runAIAgent(
303
308
  steps: accumulatedSteps,
304
309
  }
305
310
 
306
- let outputText = finalText
307
- let outputMessages = runnerParams.messages
308
- for (let i = aiMiddlewares.length - 1; i >= 0; i--) {
309
- const mw = aiMiddlewares[i]
310
- if (mw.modifyOutput) {
311
- const modResult = await mw.modifyOutput(singletonServices, {
312
- text: outputText,
313
- messages: outputMessages,
314
- usage: totalUsage,
315
- })
316
- outputText = modResult.text
317
- outputMessages = modResult.messages
318
- }
319
- }
311
+ const { text: outputText, steps: outputSteps } =
312
+ await applyOutputMiddleware(aiMiddlewares, singletonServices, {
313
+ text: finalText,
314
+ messages: runnerParams.messages,
315
+ steps: result.steps,
316
+ usage: totalUsage,
317
+ })
320
318
 
321
319
  await saveMessages(
322
320
  storage,
@@ -325,14 +323,20 @@ export async function runAIAgent(
325
323
  memoryConfig,
326
324
  persistedUserMessage,
327
325
  {
328
- ...result,
329
326
  text: outputText,
327
+ steps: outputSteps,
330
328
  uiSpec: structuredOutput.uiSpec,
331
329
  }
332
330
  )
333
331
 
334
- await aiRunState.updateRun(runId, {
335
- status: 'completed',
332
+ await finalizeAgentRun(aiRunState, {
333
+ runId,
334
+ agentName,
335
+ threadId,
336
+ resourceId: input.resourceId,
337
+ input: lastUserMessageText(runnerParams.messages),
338
+ text: outputText,
339
+ steps: outputSteps,
336
340
  usage: { ...totalUsage, model: agent.model },
337
341
  })
338
342
 
@@ -341,7 +345,7 @@ export async function runAIAgent(
341
345
  text: outputText,
342
346
  object: finalObject,
343
347
  threadId,
344
- steps: accumulatedSteps,
348
+ steps: outputSteps,
345
349
  usage: totalUsage,
346
350
  }
347
351
  } catch (error) {
@@ -462,6 +466,7 @@ export async function resumeAIAgentSync(
462
466
  toolName: string
463
467
  args: any
464
468
  result: string
469
+ error?: string
465
470
  }[] = []
466
471
 
467
472
  for (const pending of savedPendingApprovals) {
@@ -471,6 +476,7 @@ export async function resumeAIAgentSync(
471
476
  if (!claimedIds.has(toolCallId)) continue
472
477
 
473
478
  let resultStr: string
479
+ let toolError: string | undefined
474
480
 
475
481
  if (rejectedIds.has(toolCallId)) {
476
482
  resultStr =
@@ -497,7 +503,8 @@ export async function resumeAIAgentSync(
497
503
  if (err?.payload?.error === 'missing_credential') {
498
504
  resultStr = JSON.stringify(err.payload)
499
505
  } else {
500
- resultStr = `Error: ${err instanceof Error ? err.message : String(err)}`
506
+ toolError = err instanceof Error ? err.message : String(err)
507
+ resultStr = `Error: ${toolError}`
501
508
  }
502
509
  }
503
510
  } else {
@@ -512,6 +519,7 @@ export async function resumeAIAgentSync(
512
519
  ? JSON.parse(pending.args)
513
520
  : pending.args,
514
521
  result: resultStr,
522
+ ...(toolError ? { error: toolError } : {}),
515
523
  })
516
524
  }
517
525
 
@@ -541,7 +549,21 @@ export async function resumeAIAgentSync(
541
549
  memoryConfig,
542
550
  agentRunner,
543
551
  params,
544
- aiRunState
552
+ aiRunState,
553
+ // The approved tools were executed here, before the model was re-entered,
554
+ // so they belong to the run's step record — otherwise a tool that failed
555
+ // after approval leaves no trace on the run at all.
556
+ toolCallMessages.length > 0
557
+ ? {
558
+ usage: { inputTokens: 0, outputTokens: 0 },
559
+ toolCalls: toolCallMessages.map((tc) => ({
560
+ name: tc.toolName,
561
+ args: (tc.args ?? {}) as Record<string, unknown>,
562
+ result: tc.result,
563
+ ...(tc.error ? { error: tc.error } : {}),
564
+ })),
565
+ }
566
+ : undefined
545
567
  )
546
568
  }
547
569
 
@@ -554,7 +576,8 @@ async function continueAfterToolResultSync(
554
576
  memoryConfig: AIAgentMemoryConfig | undefined,
555
577
  agentRunner: AIAgentRunnerService,
556
578
  params: RunAIAgentParams,
557
- aiRunState: AIRunStateService
579
+ aiRunState: AIRunStateService,
580
+ resumedToolStep?: AIAgentStep
558
581
  ): Promise<AIAgentOutput> {
559
582
  const singletonServices = getSingletonServices()
560
583
  const agentsMeta = pikkuState(packageName, 'agent', 'agentsMeta')
@@ -639,6 +662,13 @@ async function continueAfterToolResultSync(
639
662
  runnerParams.tools = trackToolExecution(runnerParams.tools, interruptHandle)
640
663
 
641
664
  try {
665
+ // Kept out of `accumulatedSteps` deliberately: that array drives
666
+ // `saveMessages`, and the approved tool's messages were already written to
667
+ // the thread before the model was re-entered. It belongs to the run's step
668
+ // record, not to persistence.
669
+ const withResumedStep = (steps: AIAgentStep[]): AIAgentStep[] =>
670
+ resumedToolStep ? [resumedToolStep, ...steps] : steps
671
+
642
672
  const accumulatedSteps: AIAgentStep[] = []
643
673
  const totalUsage = { inputTokens: 0, outputTokens: 0 }
644
674
  let lastStepResult: AIAgentStepResult | null = null
@@ -717,7 +747,7 @@ async function continueAfterToolResultSync(
717
747
  runId: run.runId,
718
748
  text: suspendedText,
719
749
  threadId: run.threadId,
720
- steps: accumulatedSteps,
750
+ steps: withResumedStep(accumulatedSteps),
721
751
  usage: totalUsage,
722
752
  status: 'suspended',
723
753
  pendingApprovals: approvalsNeeded.map((a) => ({
@@ -741,20 +771,13 @@ async function continueAfterToolResultSync(
741
771
  steps: accumulatedSteps,
742
772
  }
743
773
 
744
- let outputText = finalText
745
- let outputMessages = runnerParams.messages
746
- for (let i = aiMiddlewares.length - 1; i >= 0; i--) {
747
- const mw = aiMiddlewares[i]
748
- if (mw.modifyOutput) {
749
- const modResult = await mw.modifyOutput(singletonServices, {
750
- text: outputText,
751
- messages: outputMessages,
752
- usage: totalUsage,
753
- })
754
- outputText = modResult.text
755
- outputMessages = modResult.messages
756
- }
757
- }
774
+ const { text: outputText, steps: outputSteps } =
775
+ await applyOutputMiddleware(aiMiddlewares, singletonServices, {
776
+ text: finalText,
777
+ messages: runnerParams.messages,
778
+ steps: withResumedStep(result.steps),
779
+ usage: totalUsage,
780
+ })
758
781
 
759
782
  await saveMessages(
760
783
  storage,
@@ -763,13 +786,21 @@ async function continueAfterToolResultSync(
763
786
  memoryConfig,
764
787
  null,
765
788
  {
766
- ...result,
767
789
  text: outputText,
790
+ // The approved tool's messages were written before the model was
791
+ // re-entered, so only the steps this leg generated are persisted here.
792
+ steps: accumulatedSteps,
768
793
  }
769
794
  )
770
795
 
771
- await aiRunState.updateRun(run.runId, {
772
- status: 'completed',
796
+ await finalizeAgentRun(aiRunState, {
797
+ runId: run.runId,
798
+ agentName: resolvedName,
799
+ threadId: run.threadId,
800
+ resourceId: run.resourceId,
801
+ input: lastUserMessageText(runnerParams.messages),
802
+ text: outputText,
803
+ steps: outputSteps,
773
804
  usage: { ...totalUsage, model: agent.model },
774
805
  })
775
806
 
@@ -778,7 +809,7 @@ async function continueAfterToolResultSync(
778
809
  text: outputText,
779
810
  object: finalObject,
780
811
  threadId: run.threadId,
781
- steps: accumulatedSteps,
812
+ steps: outputSteps,
782
813
  usage: totalUsage,
783
814
  }
784
815
  } catch (error) {
@@ -0,0 +1,353 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
+ import { streamAIAgent } from './ai-agent-stream.js'
6
+ import type { CoreAIAgent, PikkuAIMiddlewareHooks } from './ai-agent.types.js'
7
+ import type { AIAgentStepResult } from '../../services/ai-agent-runner-service.js'
8
+
9
+ beforeEach(() => {
10
+ resetPikkuState()
11
+ })
12
+
13
+ const addTestAgent = (agentName: string) => {
14
+ const agent: CoreAIAgent = {
15
+ name: agentName,
16
+ description: 'test agent',
17
+ instructions: 'be helpful',
18
+ model: 'test/test-model',
19
+ }
20
+
21
+ pikkuState(null, 'agent', 'agentsMeta')[agentName] = {
22
+ ...agent,
23
+ inputSchema: null,
24
+ outputSchema: null,
25
+ workingMemorySchema: null,
26
+ }
27
+ pikkuState(null, 'agent', 'agents').set(agentName, agent)
28
+ }
29
+
30
+ const makeStepResult = (
31
+ overrides?: Partial<AIAgentStepResult>
32
+ ): AIAgentStepResult => ({
33
+ text: '',
34
+ toolCalls: [],
35
+ toolResults: [],
36
+ usage: { inputTokens: 0, outputTokens: 0 },
37
+ finishReason: 'stop',
38
+ ...overrides,
39
+ })
40
+
41
+ describe('streamAIAgent output hooks', () => {
42
+ test('does not run modifyOutput on a streamed run, and warns the hook it is inert there', async () => {
43
+ addTestAgent('stream-modify-output-agent')
44
+
45
+ const warnings: unknown[][] = []
46
+ const modifyOutputCalls: unknown[] = []
47
+ const sideEffects: string[] = []
48
+
49
+ const middleware: PikkuAIMiddlewareHooks = {
50
+ modifyOutput: async (_services, ctx) => {
51
+ modifyOutputCalls.push(ctx)
52
+ sideEffects.push(ctx.text)
53
+ return { text: `${ctx.text} [redacted]`, messages: ctx.messages }
54
+ },
55
+ }
56
+ const agent = pikkuState(null, 'agent', 'agents').get(
57
+ 'stream-modify-output-agent'
58
+ )!
59
+ agent.aiMiddleware = [middleware] as any
60
+ pikkuState(null, 'agent', 'agents').set('stream-modify-output-agent', agent)
61
+
62
+ const mockServices = {
63
+ logger: {
64
+ info: () => {},
65
+ warn: (...args: unknown[]) => warnings.push(args),
66
+ error: () => {},
67
+ debug: () => {},
68
+ },
69
+ aiAgentRunner: {
70
+ stream: async (_params: any, channel: any) => {
71
+ channel.send({ type: 'text-delta', text: 'Hello' })
72
+ return makeStepResult({ text: 'Hello', finishReason: 'stop' })
73
+ },
74
+ },
75
+ aiRunState: {
76
+ createRun: async () => 'run-modify-output',
77
+ updateRun: async () => {},
78
+ },
79
+ } as any
80
+
81
+ pikkuState(null, 'package', 'singletonServices', mockServices)
82
+
83
+ const result = await streamAIAgent(
84
+ 'stream-modify-output-agent',
85
+ {
86
+ message: 'hello',
87
+ threadId: 'thread-modify-output',
88
+ resourceId: 'resource-modify-output',
89
+ },
90
+ {
91
+ channelId: 'channel-modify-output',
92
+ openingData: undefined,
93
+ state: 'open',
94
+ send: () => {},
95
+ close: () => {},
96
+ },
97
+ {}
98
+ )
99
+
100
+ // It does not run at all — nothing on this path could act on what it
101
+ // returns, and the one hook that used to rely on the side effect (working
102
+ // memory) now persists from its own modifyOutputStream.
103
+ assert.equal(modifyOutputCalls.length, 0)
104
+ assert.deepEqual(sideEffects, [])
105
+ assert.equal(result, 'Hello')
106
+
107
+ // And the author of that hook has to be told, or the gap is silent.
108
+ assert.equal(
109
+ warnings.filter((args) =>
110
+ args.some(
111
+ (arg) =>
112
+ typeof arg === 'string' &&
113
+ arg.includes('modifyOutput') &&
114
+ arg.includes('stream-modify-output-agent')
115
+ )
116
+ ).length,
117
+ 1
118
+ )
119
+ })
120
+
121
+ test('persists working memory from a streamed run', async () => {
122
+ addTestAgent('stream-working-memory-agent')
123
+
124
+ const savedWorkingMemory: unknown[] = []
125
+
126
+ const agent = pikkuState(null, 'agent', 'agents').get(
127
+ 'stream-working-memory-agent'
128
+ )!
129
+ agent.memory = { workingMemory: true } as any
130
+ pikkuState(null, 'agent', 'agents').set(
131
+ 'stream-working-memory-agent',
132
+ agent
133
+ )
134
+
135
+ const mockServices = {
136
+ logger: {
137
+ info: () => {},
138
+ warn: () => {},
139
+ error: () => {},
140
+ debug: () => {},
141
+ },
142
+ aiAgentRunner: {
143
+ stream: async (_params: any, channel: any) => {
144
+ channel.send({
145
+ type: 'text-delta',
146
+ text: 'Noted <working_memory>{"city":"Berlin"}</working_memory>',
147
+ })
148
+ return makeStepResult({ text: 'Noted', finishReason: 'stop' })
149
+ },
150
+ },
151
+ aiRunState: {
152
+ createRun: async () => 'run-working-memory',
153
+ updateRun: async () => {},
154
+ },
155
+ aiStorage: {
156
+ createThread: async () => {},
157
+ getMessages: async () => [],
158
+ saveMessages: async () => {},
159
+ getWorkingMemory: async () => ({}),
160
+ saveWorkingMemory: async (
161
+ threadId: string,
162
+ scope: string,
163
+ value: unknown
164
+ ) => {
165
+ savedWorkingMemory.push({ threadId, scope, value })
166
+ },
167
+ },
168
+ } as any
169
+
170
+ pikkuState(null, 'package', 'singletonServices', mockServices)
171
+
172
+ await streamAIAgent(
173
+ 'stream-working-memory-agent',
174
+ {
175
+ message: 'remember I live in Berlin',
176
+ threadId: 'thread-working-memory',
177
+ resourceId: 'resource-working-memory',
178
+ },
179
+ {
180
+ channelId: 'channel-working-memory',
181
+ openingData: undefined,
182
+ state: 'open',
183
+ send: () => {},
184
+ close: () => {},
185
+ },
186
+ {}
187
+ )
188
+
189
+ // The block never reaches modifyOutput on this path: the middleware's own
190
+ // stream hook strips it before the persisting channel accumulates the text.
191
+ // Persisting has to happen from the stream hook, where the raw text is.
192
+ assert.deepEqual(savedWorkingMemory, [
193
+ {
194
+ threadId: 'thread-working-memory',
195
+ scope: 'thread',
196
+ value: { city: 'Berlin' },
197
+ },
198
+ ])
199
+ })
200
+
201
+ test('a failing tool on a streamed run is persisted as a failure, not as text that reads like one', async () => {
202
+ addTestAgent('stream-tool-error-agent')
203
+
204
+ const savedMessages: any[] = []
205
+
206
+ const mockServices = {
207
+ logger: {
208
+ info: () => {},
209
+ warn: () => {},
210
+ error: () => {},
211
+ debug: () => {},
212
+ },
213
+ aiAgentRunner: {
214
+ stream: async (_params: any, channel: any) => {
215
+ channel.send({
216
+ type: 'tool-call',
217
+ toolCallId: 'call-1',
218
+ toolName: 'lookup',
219
+ args: { city: 'Berlin' },
220
+ })
221
+ channel.send({
222
+ type: 'tool-result',
223
+ toolCallId: 'call-1',
224
+ toolName: 'lookup',
225
+ result: 'Error: upstream refused',
226
+ error: 'upstream refused',
227
+ })
228
+ channel.send({
229
+ type: 'tool-call',
230
+ toolCallId: 'call-2',
231
+ toolName: 'echo',
232
+ args: {},
233
+ })
234
+ channel.send({
235
+ type: 'tool-result',
236
+ toolCallId: 'call-2',
237
+ toolName: 'echo',
238
+ result: 'Error: this is just what the tool said',
239
+ })
240
+ return makeStepResult({ text: 'done', finishReason: 'stop' })
241
+ },
242
+ },
243
+ aiRunState: {
244
+ createRun: async () => 'run-tool-error',
245
+ updateRun: async () => {},
246
+ },
247
+ aiStorage: {
248
+ createThread: async () => {},
249
+ getMessages: async () => [],
250
+ saveMessages: async (_threadId: string, messages: any[]) => {
251
+ savedMessages.push(...messages)
252
+ },
253
+ },
254
+ } as any
255
+
256
+ pikkuState(null, 'package', 'singletonServices', mockServices)
257
+
258
+ await streamAIAgent(
259
+ 'stream-tool-error-agent',
260
+ {
261
+ message: 'look it up',
262
+ threadId: 'thread-tool-error',
263
+ resourceId: 'resource-tool-error',
264
+ },
265
+ {
266
+ channelId: 'channel-tool-error',
267
+ openingData: undefined,
268
+ state: 'open',
269
+ send: () => {},
270
+ close: () => {},
271
+ },
272
+ {}
273
+ )
274
+
275
+ const toolResults = savedMessages
276
+ .filter((message) => message.role === 'tool')
277
+ .flatMap((message) => message.toolResults ?? [])
278
+
279
+ assert.deepEqual(
280
+ toolResults.map((r: any) => [r.name, r.error]),
281
+ [
282
+ ['lookup', 'upstream refused'],
283
+ ['echo', undefined],
284
+ ]
285
+ )
286
+ })
287
+
288
+ test('does not warn about modifyOutput when the middleware also handles the stream', async () => {
289
+ addTestAgent('stream-both-hooks-agent')
290
+
291
+ const warnings: unknown[][] = []
292
+
293
+ const middleware: PikkuAIMiddlewareHooks = {
294
+ modifyOutput: async (_services, ctx) => ({
295
+ text: ctx.text,
296
+ messages: ctx.messages,
297
+ }),
298
+ modifyOutputStream: async (_services, ctx) => ctx.event,
299
+ }
300
+ const agent = pikkuState(null, 'agent', 'agents').get(
301
+ 'stream-both-hooks-agent'
302
+ )!
303
+ agent.aiMiddleware = [middleware] as any
304
+ pikkuState(null, 'agent', 'agents').set('stream-both-hooks-agent', agent)
305
+
306
+ const mockServices = {
307
+ logger: {
308
+ info: () => {},
309
+ warn: (...args: unknown[]) => warnings.push(args),
310
+ error: () => {},
311
+ debug: () => {},
312
+ },
313
+ aiAgentRunner: {
314
+ stream: async (_params: any, channel: any) => {
315
+ channel.send({ type: 'text-delta', text: 'Hi' })
316
+ return makeStepResult({ text: 'Hi', finishReason: 'stop' })
317
+ },
318
+ },
319
+ aiRunState: {
320
+ createRun: async () => 'run-both-hooks',
321
+ updateRun: async () => {},
322
+ },
323
+ } as any
324
+
325
+ pikkuState(null, 'package', 'singletonServices', mockServices)
326
+
327
+ await streamAIAgent(
328
+ 'stream-both-hooks-agent',
329
+ {
330
+ message: 'hello',
331
+ threadId: 'thread-both-hooks',
332
+ resourceId: 'resource-both-hooks',
333
+ },
334
+ {
335
+ channelId: 'channel-both-hooks',
336
+ openingData: undefined,
337
+ state: 'open',
338
+ send: () => {},
339
+ close: () => {},
340
+ },
341
+ {}
342
+ )
343
+
344
+ assert.deepEqual(
345
+ warnings.filter((args) =>
346
+ args.some(
347
+ (arg) => typeof arg === 'string' && arg.includes('modifyOutput')
348
+ )
349
+ ),
350
+ []
351
+ )
352
+ })
353
+ })