@pikku/core 0.12.78 → 0.12.80

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 (341) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/dist/dev/hot-reload.js +1 -6
  3. package/dist/ecosystem.d.ts +27 -0
  4. package/dist/ecosystem.js +26 -0
  5. package/dist/errors/error-handler.js +4 -2
  6. package/dist/function/function-runner.js +20 -42
  7. package/dist/function/functions.types.d.ts +9 -5
  8. package/dist/index.d.ts +4 -9
  9. package/dist/index.js +3 -8
  10. package/dist/middleware/auth-cookie.d.ts +0 -4
  11. package/dist/middleware/auth-cookie.js +38 -2
  12. package/dist/middleware/cors.js +1 -0
  13. package/dist/middleware/index.d.ts +1 -1
  14. package/dist/middleware/index.js +1 -1
  15. package/dist/middleware/remote-auth.js +14 -3
  16. package/dist/permissions.d.ts +8 -10
  17. package/dist/permissions.js +0 -11
  18. package/dist/pikku-state.js +12 -5
  19. package/dist/schema.js +35 -1
  20. package/dist/services/ai-run-state-service.d.ts +7 -1
  21. package/dist/services/in-memory-ai-run-state-service.d.ts +1 -1
  22. package/dist/services/in-memory-ai-run-state-service.js +2 -1
  23. package/dist/services/in-memory-workflow-service.d.ts +10 -0
  24. package/dist/services/in-memory-workflow-service.js +25 -0
  25. package/dist/services/local-content-request-handler.d.ts +21 -0
  26. package/dist/services/local-content-request-handler.js +72 -53
  27. package/dist/services/local-content.d.ts +6 -0
  28. package/dist/services/local-content.js +14 -1
  29. package/dist/services/workflow-service.d.ts +4 -2
  30. package/dist/testing/service-tests/agent-run-service-tests.d.ts +10 -0
  31. package/dist/testing/service-tests/agent-run-service-tests.js +72 -0
  32. package/dist/testing/service-tests/ai-storage-service-tests.d.ts +3 -0
  33. package/dist/testing/service-tests/ai-storage-service-tests.js +226 -0
  34. package/dist/testing/service-tests/channel-store-tests.d.ts +3 -0
  35. package/dist/testing/service-tests/channel-store-tests.js +72 -0
  36. package/dist/testing/service-tests/credential-service-tests.d.ts +3 -0
  37. package/dist/testing/service-tests/credential-service-tests.js +109 -0
  38. package/dist/testing/service-tests/deployment-service-tests.d.ts +3 -0
  39. package/dist/testing/service-tests/deployment-service-tests.js +21 -0
  40. package/dist/testing/service-tests/event-hub-store-tests.d.ts +3 -0
  41. package/dist/testing/service-tests/event-hub-store-tests.js +34 -0
  42. package/dist/testing/service-tests/secret-service-tests.d.ts +3 -0
  43. package/dist/testing/service-tests/secret-service-tests.js +80 -0
  44. package/dist/testing/service-tests/session-store-tests.d.ts +3 -0
  45. package/dist/testing/service-tests/session-store-tests.js +43 -0
  46. package/dist/testing/service-tests/workflow-run-service-tests.d.ts +3 -0
  47. package/dist/testing/service-tests/workflow-run-service-tests.js +42 -0
  48. package/dist/testing/service-tests/workflow-service-tests.d.ts +3 -0
  49. package/dist/testing/service-tests/workflow-service-tests.js +150 -0
  50. package/dist/testing/service-tests.d.ts +6 -0
  51. package/dist/testing/service-tests.js +26 -791
  52. package/dist/types/core.types.d.ts +11 -1
  53. package/dist/types/state.types.d.ts +1 -1
  54. package/dist/utils/node-host-resolver.d.ts +12 -0
  55. package/dist/utils/node-host-resolver.js +16 -0
  56. package/dist/utils/safe-fetch.d.ts +18 -0
  57. package/dist/utils/safe-fetch.js +167 -29
  58. package/dist/wirings/actor-flow/run-conversation.js +1 -6
  59. package/dist/wirings/ai-agent/agent-rpc.d.ts +15 -0
  60. package/dist/wirings/ai-agent/agent-rpc.js +53 -0
  61. package/dist/wirings/ai-agent/ai-agent-agui.js +1 -5
  62. package/dist/wirings/ai-agent/ai-agent-memory.js +3 -1
  63. package/dist/wirings/ai-agent/ai-agent-prepare.js +3 -9
  64. package/dist/wirings/ai-agent/ai-agent-runner.js +28 -107
  65. package/dist/wirings/ai-agent/ai-agent-stream.js +20 -61
  66. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +56 -0
  67. package/dist/wirings/ai-agent/ai-agent-turn.js +81 -0
  68. package/dist/wirings/ai-agent/ai-agent.types.d.ts +1 -1
  69. package/dist/wirings/ai-agent/voice-input.js +1 -6
  70. package/dist/wirings/ai-agent/voice-output.js +2 -12
  71. package/dist/wirings/channel/channel-common.js +1 -0
  72. package/dist/wirings/channel/channel-handler.js +3 -5
  73. package/dist/wirings/channel/channel-rpc-service.d.ts +0 -6
  74. package/dist/wirings/channel/channel-rpc-service.js +0 -8
  75. package/dist/wirings/channel/channel-rpc.types.d.ts +6 -0
  76. package/dist/wirings/channel/channel-rpc.types.js +8 -0
  77. package/dist/wirings/channel/channel-runner.d.ts +1 -3
  78. package/dist/wirings/channel/channel-runner.js +16 -8
  79. package/dist/wirings/channel/channel.types.d.ts +2 -0
  80. package/dist/wirings/channel/pikku-abstract-channel-handler.js +1 -0
  81. package/dist/wirings/channel/serverless/serverless-channel-runner.js +3 -0
  82. package/dist/wirings/cli/channel/cli-channel-runner.js +2 -0
  83. package/dist/wirings/cli/cli-runner.js +4 -2
  84. package/dist/wirings/cli/cli.types.d.ts +0 -8
  85. package/dist/wirings/cli/command-parser.js +13 -0
  86. package/dist/wirings/gateway/gateway-runner.js +41 -5
  87. package/dist/wirings/http/http-routes.js +2 -0
  88. package/dist/wirings/http/http-runner.d.ts +0 -10
  89. package/dist/wirings/http/http-runner.js +6 -13
  90. package/dist/wirings/http/http.types.d.ts +0 -10
  91. package/dist/wirings/http/index.d.ts +1 -1
  92. package/dist/wirings/http/index.js +1 -1
  93. package/dist/wirings/mcp/mcp-runner.d.ts +0 -7
  94. package/dist/wirings/mcp/mcp-runner.js +0 -6
  95. package/dist/wirings/rpc/rpc-runner.d.ts +8 -51
  96. package/dist/wirings/rpc/rpc-runner.js +53 -86
  97. package/dist/wirings/rpc/rpc-types.d.ts +3 -0
  98. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  99. package/dist/wirings/trigger/pikku-trigger-service.d.ts +0 -4
  100. package/dist/wirings/trigger/trigger-runner.js +1 -0
  101. package/dist/wirings/virtual-user/run-virtual-user.js +11 -11
  102. package/dist/wirings/virtual-user/virtual-user-derive.js +4 -25
  103. package/dist/wirings/virtual-user/virtual-user-dispositions.js +1 -4
  104. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +29 -2
  105. package/dist/wirings/workflow/feature.js +7 -4
  106. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -2
  107. package/dist/wirings/workflow/graph/graph-runner.js +8 -7
  108. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +0 -4
  109. package/dist/wirings/workflow/index.d.ts +7 -2
  110. package/dist/wirings/workflow/index.js +5 -1
  111. package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -3
  112. package/dist/wirings/workflow/pikku-scenario-service.js +38 -10
  113. package/dist/wirings/workflow/pikku-workflow-service.d.ts +44 -111
  114. package/dist/wirings/workflow/pikku-workflow-service.js +86 -404
  115. package/dist/wirings/workflow/workflow-approval.d.ts +39 -0
  116. package/dist/wirings/workflow/workflow-approval.js +114 -0
  117. package/dist/wirings/workflow/workflow-constants.d.ts +26 -0
  118. package/dist/wirings/workflow/workflow-constants.js +35 -0
  119. package/dist/wirings/workflow/workflow-errors.d.ts +58 -0
  120. package/dist/wirings/workflow/workflow-errors.js +112 -0
  121. package/dist/wirings/workflow/workflow-meta-resolver.d.ts +11 -0
  122. package/dist/wirings/workflow/workflow-meta-resolver.js +31 -0
  123. package/dist/wirings/workflow/workflow-queue-routing.d.ts +8 -0
  124. package/dist/wirings/workflow/workflow-queue-routing.js +38 -0
  125. package/dist/wirings/workflow/workflow-queue-wiring.d.ts +20 -0
  126. package/dist/wirings/workflow/workflow-queue-wiring.js +79 -0
  127. package/dist/wirings/workflow/workflow-recovery.d.ts +68 -0
  128. package/dist/wirings/workflow/workflow-recovery.js +101 -0
  129. package/dist/wirings/workflow/workflow-run-engine.types.d.ts +54 -0
  130. package/dist/wirings/workflow/workflow-run-engine.types.js +1 -0
  131. package/dist/wirings/workflow/workflow-run-ownership.d.ts +16 -0
  132. package/dist/wirings/workflow/workflow-run-ownership.js +29 -0
  133. package/dist/wirings/workflow/workflow-suspend.d.ts +12 -0
  134. package/dist/wirings/workflow/workflow-suspend.js +33 -0
  135. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  136. package/knowledge/decisions/internals/a-non-streaming-agent-run-registers-with-airunstate-too.md +22 -0
  137. package/knowledge/decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md +20 -0
  138. package/knowledge/decisions/internals/a-scenario-step-template-is-offered-unfilled.md +21 -0
  139. package/knowledge/decisions/internals/a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md +21 -0
  140. package/knowledge/decisions/internals/a-wall-clock-threshold-is-a-load-test-in-disguise.md +46 -0
  141. package/knowledge/decisions/internals/a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md +45 -0
  142. package/knowledge/decisions/internals/agent-context-waits-for-a-tool-result-still-being-written.md +21 -0
  143. package/knowledge/decisions/internals/agent-speech-travels-as-a-custom-agui-event.md +22 -0
  144. package/knowledge/decisions/internals/an-agent-interrupt-is-not-a-failure.md +23 -0
  145. package/knowledge/decisions/internals/an-agent-run-owned-by-another-instance-says-so.md +25 -0
  146. package/knowledge/decisions/internals/an-agent-stream-send-must-return-the-inner-sends-promise.md +22 -0
  147. package/knowledge/decisions/internals/an-empty-text-part-is-omitted-from-an-agent-message.md +20 -0
  148. package/knowledge/decisions/internals/an-empty-transcript-is-not-recorded.md +22 -0
  149. package/knowledge/decisions/internals/an-unref-d-timer-cannot-be-awaited-under-node-test.md +66 -0
  150. package/knowledge/decisions/internals/channel-state-accessors-are-unsound-generics-that-every-implementation-asserts.md +33 -0
  151. package/knowledge/decisions/internals/gateway-listener-middleware-runs-without-an-rpc-on-the-wire.md +43 -0
  152. package/knowledge/decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md +26 -0
  153. package/knowledge/decisions/internals/index.md +38 -26
  154. package/knowledge/decisions/internals/only-exposed-functions-enter-a-virtual-user-catalogue.md +21 -0
  155. package/knowledge/decisions/internals/scenario-given-and-when-are-sugar-but-then-is-not.md +24 -0
  156. package/knowledge/decisions/internals/side-effects-are-an-allowlist-not-a-boolean.md +34 -0
  157. package/knowledge/decisions/internals/speech-synthesis-picks-a-voice-per-sentence-and-warns-once.md +24 -0
  158. package/knowledge/decisions/internals/the-actor-prompt-says-json-because-of-json-object-mode.md +22 -0
  159. package/knowledge/decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md +26 -0
  160. package/knowledge/decisions/internals/the-api-report-pins-members-not-just-names.md +43 -0
  161. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +58 -0
  162. package/knowledge/decisions/internals/the-middleware-resolution-cache-is-deliberately-unbounded.md +40 -0
  163. package/knowledge/decisions/internals/the-per-invocation-rpc-view-is-a-class.md +40 -0
  164. package/knowledge/decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md +28 -0
  165. package/knowledge/decisions/internals/the-transcript-event-is-sent-ahead-of-the-run.md +25 -0
  166. package/knowledge/decisions/internals/the-virtual-user-catalogue-is-the-only-gate-on-what-may-be-called.md +21 -0
  167. package/knowledge/decisions/internals/the-worker-disposition-is-the-one-that-is-not-testing.md +22 -0
  168. package/knowledge/decisions/internals/thread-history-records-the-transcript-not-the-audio.md +25 -0
  169. package/knowledge/decisions/internals/virtual-user-step-order-comes-from-insertion-order.md +21 -0
  170. package/knowledge/decisions/internals/voice-output-speaks-unless-voice-input-explicitly-says-otherwise.md +24 -0
  171. package/knowledge/decisions/internals/wiring-registries-erase-the-generics-their-wire-functions-capture.md +35 -0
  172. package/knowledge/decisions/security/a-dropped-audit-write-is-always-logged.md +4 -2
  173. package/knowledge/decisions/security/a-graph-run-starts-at-an-entry-node-the-graph-declared.md +33 -0
  174. package/knowledge/decisions/security/a-permission-gets-a-wire-it-cannot-reply-on.md +33 -0
  175. package/knowledge/decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md +37 -0
  176. package/knowledge/decisions/security/a-virtual-user-is-never-offered-a-step-that-would-forge-its-own-oracle.md +28 -0
  177. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +34 -0
  178. package/knowledge/decisions/security/an-agent-approval-is-claimed-before-the-tool-runs.md +33 -0
  179. package/knowledge/decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md +24 -0
  180. package/knowledge/decisions/security/gateway-handlers-run-through-the-function-runner-gate.md +13 -3
  181. package/knowledge/decisions/security/index.md +7 -0
  182. package/knowledge/questions/channel-middleware-accepts-bare-factories-that-nothing-resolves.md +35 -0
  183. package/knowledge/questions/index.md +2 -1
  184. package/knowledge/questions/unauthorized-channel-replies-escape-the-declared-out-type.md +44 -0
  185. package/package.json +14 -3
  186. package/scripts/generate-api-report.d.mts +1 -0
  187. package/scripts/generate-api-report.mjs +89 -0
  188. package/scripts/generate-api-report.mts +218 -0
  189. package/src/api-report.test.ts +69 -0
  190. package/src/column-form.ts +1 -4
  191. package/src/crypto-utils.test.ts +15 -2
  192. package/src/dev/hot-reload.ts +2 -7
  193. package/src/ecosystem.ts +40 -0
  194. package/src/errors/error-handler.ts +5 -3
  195. package/src/errors/error.test.ts +4 -1
  196. package/src/function/function-runner.test.ts +1 -1
  197. package/src/function/function-runner.ts +28 -39
  198. package/src/function/functions.types.ts +15 -9
  199. package/src/handle-error.ts +1 -1
  200. package/src/index.ts +3 -22
  201. package/src/middleware/auth-cookie.test.ts +50 -0
  202. package/src/middleware/auth-cookie.ts +38 -2
  203. package/src/middleware/cors.ts +2 -1
  204. package/src/middleware/index.ts +1 -1
  205. package/src/middleware/remote-auth.test.ts +43 -0
  206. package/src/middleware/remote-auth.ts +17 -3
  207. package/src/middleware-runner.ts +4 -2
  208. package/src/no-any-casts.test.ts +57 -0
  209. package/src/permissions.test.ts +3 -1
  210. package/src/permissions.ts +22 -24
  211. package/src/pikku-state.ts +17 -6
  212. package/src/public-surface.json +578 -0
  213. package/src/public-surface.json.README +25 -0
  214. package/src/public-surface.test.ts +105 -0
  215. package/src/removed-legacy-exports.test.ts +62 -0
  216. package/src/schema.test.ts +78 -0
  217. package/src/schema.ts +36 -1
  218. package/src/services/ai-run-state-service.ts +7 -1
  219. package/src/services/audit-service.ts +2 -2
  220. package/src/services/in-memory-ai-run-state-service.ts +3 -2
  221. package/src/services/in-memory-workflow-service.ts +25 -0
  222. package/src/services/index.ts +1 -4
  223. package/src/services/local-content-request-handler.test.ts +43 -5
  224. package/src/services/local-content-request-handler.ts +103 -74
  225. package/src/services/local-content.ts +15 -1
  226. package/src/services/local-email-service.ts +5 -1
  227. package/src/services/system-role-guard.test.ts +4 -1
  228. package/src/services/workflow-service.ts +9 -2
  229. package/src/side-effects-are-declared.test.ts +84 -0
  230. package/src/source-files-stay-composable.test.ts +41 -0
  231. package/src/testing/service-tests/agent-run-service-tests.ts +98 -0
  232. package/src/testing/service-tests/ai-storage-service-tests.ts +286 -0
  233. package/src/testing/service-tests/channel-store-tests.ts +98 -0
  234. package/src/testing/service-tests/credential-service-tests.ts +143 -0
  235. package/src/testing/service-tests/deployment-service-tests.ts +33 -0
  236. package/src/testing/service-tests/event-hub-store-tests.ts +48 -0
  237. package/src/testing/service-tests/secret-service-tests.ts +105 -0
  238. package/src/testing/service-tests/session-store-tests.ts +62 -0
  239. package/src/testing/service-tests/workflow-run-service-tests.ts +59 -0
  240. package/src/testing/service-tests/workflow-service-tests.ts +308 -0
  241. package/src/testing/service-tests.ts +31 -1111
  242. package/src/types/core.types.ts +15 -1
  243. package/src/types/state.types.ts +1 -1
  244. package/src/utils/node-host-resolver.ts +20 -0
  245. package/src/utils/safe-fetch.test.ts +143 -1
  246. package/src/utils/safe-fetch.ts +200 -25
  247. package/src/wirings/actor-flow/run-conversation.ts +1 -6
  248. package/src/wirings/ai-agent/agent-rpc.ts +120 -0
  249. package/src/wirings/ai-agent/ai-agent-agui.ts +1 -5
  250. package/src/wirings/ai-agent/ai-agent-interrupt.test.ts +2 -1
  251. package/src/wirings/ai-agent/ai-agent-memory.ts +8 -1
  252. package/src/wirings/ai-agent/ai-agent-prepare.ts +10 -12
  253. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +1 -0
  254. package/src/wirings/ai-agent/ai-agent-runner.test.ts +82 -2
  255. package/src/wirings/ai-agent/ai-agent-runner.ts +49 -120
  256. package/src/wirings/ai-agent/ai-agent-stream.test.ts +2 -0
  257. package/src/wirings/ai-agent/ai-agent-stream.ts +36 -63
  258. package/src/wirings/ai-agent/ai-agent-thread-ownership.test.ts +1 -1
  259. package/src/wirings/ai-agent/ai-agent-turn.ts +121 -0
  260. package/src/wirings/ai-agent/ai-agent.types.ts +1 -1
  261. package/src/wirings/ai-agent/voice-input.ts +1 -6
  262. package/src/wirings/ai-agent/voice-output.ts +2 -12
  263. package/src/wirings/channel/channel-common.ts +5 -2
  264. package/src/wirings/channel/channel-handler-shapes.test.ts +72 -0
  265. package/src/wirings/channel/channel-handler.ts +7 -7
  266. package/src/wirings/channel/channel-rpc-service.ts +0 -14
  267. package/src/wirings/channel/channel-rpc.test.ts +25 -4
  268. package/src/wirings/channel/channel-rpc.types.ts +14 -0
  269. package/src/wirings/channel/channel-runner.ts +33 -20
  270. package/src/wirings/channel/channel.types.ts +2 -0
  271. package/src/wirings/channel/local/local-channel-runner.ts +1 -1
  272. package/src/wirings/channel/pikku-abstract-channel-handler.ts +2 -1
  273. package/src/wirings/channel/serverless/serverless-channel-runner.ts +6 -3
  274. package/src/wirings/cli/channel/cli-channel-runner.ts +3 -1
  275. package/src/wirings/cli/channel/cli-raw-client-runner.ts +4 -1
  276. package/src/wirings/cli/cli-runner.ts +7 -4
  277. package/src/wirings/cli/cli.types.ts +0 -39
  278. package/src/wirings/cli/command-parser.test.ts +19 -0
  279. package/src/wirings/cli/command-parser.ts +17 -1
  280. package/src/wirings/gateway/gateway-authorization.test.ts +131 -0
  281. package/src/wirings/gateway/gateway-channel-meta.test.ts +44 -0
  282. package/src/wirings/gateway/gateway-runner.ts +62 -22
  283. package/src/wirings/http/http-routes.ts +4 -1
  284. package/src/wirings/http/http-runner.ts +11 -26
  285. package/src/wirings/http/http.types.ts +0 -15
  286. package/src/wirings/http/index.ts +1 -7
  287. package/src/wirings/http/pikku-fetch-http-request.ts +2 -2
  288. package/src/wirings/http/web-request.ts +1 -1
  289. package/src/wirings/mcp/mcp-runner.ts +2 -16
  290. package/src/wirings/persona/persona-environments.test.ts +14 -3
  291. package/src/wirings/persona/persona.test.ts +13 -3
  292. package/src/wirings/persona/validate-personas.ts +5 -1
  293. package/src/wirings/queue/queue-runner.ts +1 -1
  294. package/src/wirings/rpc/addon-auth-tags.test.ts +1 -5
  295. package/src/wirings/rpc/rpc-runner.ts +58 -136
  296. package/src/wirings/rpc/rpc-types.ts +7 -0
  297. package/src/wirings/rpc/wire-addon.ts +3 -1
  298. package/src/wirings/secret/validate-secret-definitions.test.ts +69 -0
  299. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  300. package/src/wirings/trigger/pikku-trigger-service.ts +0 -5
  301. package/src/wirings/trigger/trigger-runner.ts +7 -5
  302. package/src/wirings/virtual-user/run-virtual-user.test.ts +18 -9
  303. package/src/wirings/virtual-user/run-virtual-user.ts +28 -15
  304. package/src/wirings/virtual-user/virtual-user-agents.test.ts +4 -1
  305. package/src/wirings/virtual-user/virtual-user-derive.ts +4 -25
  306. package/src/wirings/virtual-user/virtual-user-dispositions.test.ts +7 -2
  307. package/src/wirings/virtual-user/virtual-user-dispositions.ts +1 -4
  308. package/src/wirings/virtual-user/virtual-user-intents.test.ts +13 -3
  309. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -2
  310. package/src/wirings/workflow/feature.ts +7 -4
  311. package/src/wirings/workflow/graph/graph-node.ts +1 -1
  312. package/src/wirings/workflow/graph/graph-runner.test.ts +4 -2
  313. package/src/wirings/workflow/graph/graph-runner.ts +13 -16
  314. package/src/wirings/workflow/graph/workflow-graph.types.ts +0 -5
  315. package/src/wirings/workflow/index.ts +12 -4
  316. package/src/wirings/workflow/pikku-scenario-service.ts +54 -25
  317. package/src/wirings/workflow/pikku-workflow-service.test.ts +2 -2
  318. package/src/wirings/workflow/pikku-workflow-service.ts +208 -630
  319. package/src/wirings/workflow/scenario-hooks.test.ts +51 -0
  320. package/src/wirings/workflow/workflow-approval.ts +185 -0
  321. package/src/wirings/workflow/workflow-child-run-session.test.ts +89 -0
  322. package/src/wirings/workflow/workflow-constants.ts +47 -0
  323. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  324. package/src/wirings/workflow/workflow-dispatch-relay.test.ts +128 -0
  325. package/src/wirings/workflow/workflow-errors.ts +128 -0
  326. package/src/wirings/workflow/workflow-inline-authority.test.ts +12 -6
  327. package/src/wirings/workflow/workflow-meta-resolver.ts +45 -0
  328. package/src/wirings/workflow/workflow-missing-meta.test.ts +92 -0
  329. package/src/wirings/workflow/workflow-queue-routing.ts +85 -0
  330. package/src/wirings/workflow/workflow-queue-wiring.ts +149 -0
  331. package/src/wirings/workflow/workflow-recovery.ts +162 -0
  332. package/src/wirings/workflow/workflow-retry-policy.test.ts +1 -1
  333. package/src/wirings/workflow/workflow-run-authority.test.ts +215 -0
  334. package/src/wirings/workflow/workflow-run-engine.types.ts +91 -0
  335. package/src/wirings/workflow/workflow-run-ownership.ts +36 -0
  336. package/src/wirings/workflow/workflow-suspend.ts +61 -0
  337. package/src/wirings/workflow/workflow.types.ts +7 -0
  338. package/src/wirings-stay-decoupled.test.ts +123 -0
  339. package/tsconfig.json +1 -1
  340. package/tsconfig.tsbuildinfo +1 -1
  341. package/src/internal.ts +0 -10
@@ -12,7 +12,11 @@ import type {
12
12
  } from './ai-agent.types.js'
13
13
  import type { AIAgentRunnerParams } from '../../services/ai-agent-runner-service.js'
14
14
  import { PikkuError } from '../../errors/error-handler.js'
15
- import { checkAuthPermissions, runPermissions } from '../../permissions.js'
15
+ import {
16
+ checkAuthPermissions,
17
+ runPermissions,
18
+ type PermissionWire,
19
+ } from '../../permissions.js'
16
20
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
17
21
  import { ForbiddenError } from '../../errors/errors.js'
18
22
  import { verifyScopes } from '../../scopes.js'
@@ -319,8 +323,8 @@ export async function assertAgentAuthorized(
319
323
 
320
324
  await runPermissions({
321
325
  funcPermissions: agent.permissions,
322
- services: singletonServices as any,
323
- wire: wire as any,
326
+ services: singletonServices,
327
+ wire: wire as PermissionWire,
324
328
  data: {},
325
329
  packageName,
326
330
  label: 'agent',
@@ -395,7 +399,7 @@ export function createScopedChannel(
395
399
  toolName: event.toolName,
396
400
  args: event.args,
397
401
  ...(event.reason !== undefined ? { reason: event.reason } : {}),
398
- runId: (event as any).runId,
402
+ runId: event.runId,
399
403
  })
400
404
  return
401
405
  }
@@ -940,10 +944,7 @@ export async function prepareAgentRun(
940
944
 
941
945
  let messages: AIMessage[] = []
942
946
  if (storage) {
943
- // A tool whose run was interrupted may still be writing its result to this
944
- // thread. In voice the next turn lands within a second or two, so without
945
- // this the model would load context that is missing the very thing it is
946
- // about to be asked about.
947
+ // knowledge: decisions/internals/agent-context-waits-for-a-tool-result-still-being-written.md
947
948
  await awaitPendingInterruptNote(threadId)
948
949
  messages = await storage.getMessages(threadId, {
949
950
  lastN: memoryConfig?.lastMessages ?? 20,
@@ -959,10 +960,7 @@ export async function prepareAgentRun(
959
960
 
960
961
  const userContent: AIMessage['content'] = input.attachments?.length
961
962
  ? [
962
- // Omitted when there is nothing to say. An attachment on its own is a
963
- // real turn — a spoken one carries audio and no text at all — and an
964
- // empty text part alongside it is a part providers are entitled to
965
- // reject, for a caller who never wrote one.
963
+ // knowledge: decisions/internals/an-empty-text-part-is-omitted-from-an-agent-message.md
966
964
  ...(input.message
967
965
  ? [{ type: 'text' as const, text: input.message }]
968
966
  : []),
@@ -43,6 +43,7 @@ const setup = (agentName: string, resourceId = 'u1') => {
43
43
  getRun: async () => suspendedRun(agentName, resourceId),
44
44
  resolveApproval: async (toolCallId: string) => {
45
45
  resolved.push(toolCallId)
46
+ return true
46
47
  },
47
48
  },
48
49
  } as any)
@@ -707,7 +707,7 @@ describe('resumeAIAgentSync', () => {
707
707
  createdAt: new Date(),
708
708
  updatedAt: new Date(),
709
709
  }),
710
- resolveApproval: async () => {},
710
+ resolveApproval: async () => true,
711
711
  updateRun: async () => {},
712
712
  },
713
713
  } as any)
@@ -784,6 +784,7 @@ describe('resumeAIAgentSync', () => {
784
784
  },
785
785
  resolveApproval: async (toolCallId: string, status: string) => {
786
786
  resolveCalls.push({ toolCallId, status })
787
+ return true
787
788
  },
788
789
  },
789
790
  aiStorage: {
@@ -816,6 +817,84 @@ describe('resumeAIAgentSync', () => {
816
817
  ])
817
818
  })
818
819
 
820
+ test('an approver that loses the claim never runs the tool', async () => {
821
+ addTestAgent('claim-agent')
822
+ pikkuState(null, 'agent', 'agentsMeta')['claim-agent'].tools = ['refund']
823
+ pikkuState(null, 'rpc', 'meta').refund = 'refund'
824
+ pikkuState(null, 'function', 'meta').refund = {
825
+ description: 'Refund',
826
+ sessionless: true,
827
+ }
828
+
829
+ let executions = 0
830
+ pikkuState(null, 'function', 'functions').set('refund', {
831
+ func: async () => {
832
+ executions++
833
+ return { refunded: true }
834
+ },
835
+ })
836
+
837
+ const run: AgentRunState = {
838
+ runId: 'run-claim',
839
+ agentName: 'claim-agent',
840
+ threadId: 'thread-claim',
841
+ resourceId: 'resource-claim',
842
+ status: 'suspended',
843
+ suspendReason: 'approval',
844
+ pendingApprovals: [
845
+ {
846
+ type: 'tool-call',
847
+ toolCallId: 'tc-claim',
848
+ toolName: 'refund',
849
+ args: {},
850
+ },
851
+ ],
852
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test/test-model' },
853
+ createdAt: new Date(),
854
+ updatedAt: new Date(),
855
+ }
856
+
857
+ // Every concurrent approver reads the same `suspended` run with the same
858
+ // pending list; the store hands the claim to exactly one of them.
859
+ let claimsLeft = 1
860
+ pikkuState(null, 'package', 'singletonServices', {
861
+ logger: {
862
+ info: () => {},
863
+ warn: () => {},
864
+ error: () => {},
865
+ debug: () => {},
866
+ },
867
+ aiAgentRunner: {
868
+ run: async (): Promise<AIAgentStepResult> =>
869
+ makeStepResult({
870
+ text: 'Refunded',
871
+ usage: { inputTokens: 1, outputTokens: 1 },
872
+ }),
873
+ },
874
+ aiRunState: {
875
+ getRun: async () => run,
876
+ createRun: async () => 'unused',
877
+ updateRun: async () => {},
878
+ resolveApproval: async () => claimsLeft-- > 0,
879
+ },
880
+ aiStorage: {
881
+ saveMessages: async () => {},
882
+ getMessages: async () => [],
883
+ },
884
+ } as any)
885
+
886
+ const approvals = [{ toolCallId: 'tc-claim', approved: true }]
887
+ await resumeAIAgentSync('run-claim', approvals, asOwner('resource-claim'))
888
+ assert.equal(executions, 1, 'the winner ran the tool once')
889
+
890
+ await assert.rejects(
891
+ () =>
892
+ resumeAIAgentSync('run-claim', approvals, asOwner('resource-claim')),
893
+ /already resolved by another caller/
894
+ )
895
+ assert.equal(executions, 1, 'the loser did not run it a second time')
896
+ })
897
+
819
898
  test('records denied approvals and rejects agent mismatches', async () => {
820
899
  addTestAgent('resume-agent')
821
900
 
@@ -861,6 +940,7 @@ describe('resumeAIAgentSync', () => {
861
940
  updateRun: async () => {},
862
941
  resolveApproval: async (toolCallId: string, status: string) => {
863
942
  resolveCalls.push({ toolCallId, status })
943
+ return true
864
944
  },
865
945
  },
866
946
  aiStorage: {
@@ -959,7 +1039,7 @@ describe('resumeAIAgentSync', () => {
959
1039
  },
960
1040
  aiRunState: {
961
1041
  getRun: async () => run,
962
- resolveApproval: async () => {},
1042
+ resolveApproval: async () => true,
963
1043
  updateRun: async () => {},
964
1044
  },
965
1045
  aiStorage: {
@@ -43,6 +43,12 @@ import {
43
43
  trackToolExecution,
44
44
  } from './ai-agent-interrupt.js'
45
45
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
46
+ import {
47
+ applyInputMiddleware,
48
+ describeApprovals,
49
+ notifyAfterStep,
50
+ toAccumulatedStep,
51
+ } from './ai-agent-turn.js'
46
52
  import { resolveModelConfig } from './ai-agent-model-config.js'
47
53
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
48
54
  import { randomUUID } from './ai-agent-utils.js'
@@ -147,26 +153,20 @@ export async function runAIAgent(
147
153
  // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
148
154
  const sharedNotes: Record<string, unknown> = {}
149
155
 
150
- let modifiedMessages = runnerParams.messages
151
- let modifiedInstructions = runnerParams.instructions
152
- for (const mw of aiMiddlewares) {
153
- if (mw.modifyInput) {
154
- const result = await mw.modifyInput(singletonServices, {
155
- messages: modifiedMessages,
156
- instructions: modifiedInstructions,
157
- shared: sharedNotes,
158
- })
159
- modifiedMessages = result.messages
160
- modifiedInstructions = result.instructions
161
- }
162
- }
156
+ const { messages: modifiedMessages, instructions: modifiedInstructions } =
157
+ await applyInputMiddleware(
158
+ aiMiddlewares,
159
+ singletonServices,
160
+ {
161
+ messages: runnerParams.messages,
162
+ instructions: runnerParams.instructions,
163
+ },
164
+ sharedNotes
165
+ )
163
166
  runnerParams.messages = modifiedMessages
164
167
  runnerParams.instructions = modifiedInstructions
165
168
 
166
- // History records what the model was asked, which for a spoken turn is the
167
- // transcript rather than the base64 audio that arrived — see the same note on
168
- // the streaming path. Identity-checked, because a middleware may legitimately
169
- // replace the message list with something unrelated to this turn.
169
+ // knowledge: decisions/internals/thread-history-records-the-transcript-not-the-audio.md
170
170
  const lastModified = modifiedMessages[modifiedMessages.length - 1]
171
171
  const persistedUserMessage =
172
172
  lastModified?.id === userMessage.id ? lastModified : userMessage
@@ -181,10 +181,7 @@ export async function runAIAgent(
181
181
  updatedAt: new Date(),
182
182
  })
183
183
 
184
- // Registered on the same terms as `streamAIAgent`. A run created here is
185
- // visible to `interruptAIAgent` through `aiRunState` either way, so skipping
186
- // this would leave that call finding the run, passing the ownership check and
187
- // then failing to stop it — reported as if it were running on another host.
184
+ // knowledge: decisions/internals/a-non-streaming-agent-run-registers-with-airunstate-too.md
188
185
  const interruptHandle = registerInterruptibleRun(runId)
189
186
  runnerParams.abortSignal = interruptHandle.signal
190
187
  runnerParams.tools = trackToolExecution(runnerParams.tools, interruptHandle)
@@ -216,52 +213,15 @@ export async function runAIAgent(
216
213
  totalUsage.inputTokens += stepResult.usage.inputTokens
217
214
  totalUsage.outputTokens += stepResult.usage.outputTokens
218
215
 
219
- for (const mw of aiMiddlewares) {
220
- if (mw.afterStep) {
221
- await mw.afterStep(singletonServices, {
222
- stepNumber: step,
223
- text: stepResult.text,
224
- toolCalls: stepResult.toolCalls,
225
- toolResults: stepResult.toolResults,
226
- usage: stepResult.usage,
227
- finishReason: stepResult.finishReason,
228
- })
229
- }
230
- }
216
+ await notifyAfterStep(aiMiddlewares, singletonServices, step, stepResult)
231
217
 
232
- accumulatedSteps.push({
233
- usage: stepResult.usage,
234
- toolCalls: stepResult.toolCalls.map((tc) => {
235
- const tr = stepResult.toolResults.find(
236
- (r) => r.toolCallId === tc.toolCallId
237
- )
238
- return {
239
- name: tc.toolName,
240
- args: tc.args as Record<string, unknown>,
241
- result:
242
- typeof tr?.result === 'string'
243
- ? tr.result
244
- : JSON.stringify(tr?.result ?? ''),
245
- }
246
- }),
247
- })
218
+ accumulatedSteps.push(toAccumulatedStep(stepResult))
248
219
 
249
220
  if (stepResult.toolCalls.length === 0) break
250
221
 
251
222
  const approvalsNeeded = checkForApprovals(stepResult, runnerParams.tools)
252
223
  if (approvalsNeeded.length > 0) {
253
- for (const approval of approvalsNeeded) {
254
- const toolDef = runnerParams.tools.find(
255
- (t) => t.name === approval.toolName
256
- )
257
- if (toolDef?.approvalDescriptionFn && !approval.reason) {
258
- try {
259
- approval.reason = await toolDef.approvalDescriptionFn(
260
- approval.args
261
- )
262
- } catch {}
263
- }
264
- }
224
+ await describeApprovals(approvalsNeeded, runnerParams.tools)
265
225
 
266
226
  const pendingApprovals = approvalsNeeded.map((a) =>
267
227
  a.agentRunId
@@ -385,10 +345,7 @@ export async function runAIAgent(
385
345
  usage: totalUsage,
386
346
  }
387
347
  } catch (error) {
388
- // An interrupt is not a failure, so it skips the `onError` hooks and never
389
- // becomes an `errorMessage`. Unlike the streaming path there is no partial
390
- // reply to hand back — nothing was delivered — so the caller gets a typed
391
- // throw it can tell apart from a provider outage instead of a result.
348
+ // knowledge: decisions/internals/an-agent-interrupt-is-not-a-failure.md
392
349
  const interruption = interruptHandle.interruption
393
350
  if (interruption || isAbortError(error)) {
394
351
  await aiRunState.updateRun(runId, { status: 'interrupted' })
@@ -470,11 +427,24 @@ export async function resumeAIAgentSync(
470
427
 
471
428
  const savedPendingApprovals = [...(run.pendingApprovals ?? [])]
472
429
 
430
+ // The read above is not a claim — concurrent resumes all see the same pending
431
+ // list. `resolveApproval` is the claim, and only the caller it returns true
432
+ // for may run the tool.
433
+ const claimedIds = new Set<string>()
473
434
  for (const { toolCallId, approved } of approvals) {
474
- await aiRunState.resolveApproval(
435
+ const claimed = await aiRunState.resolveApproval(
475
436
  toolCallId,
476
437
  approved ? 'approved' : 'denied'
477
438
  )
439
+ if (claimed) {
440
+ claimedIds.add(toolCallId)
441
+ }
442
+ }
443
+
444
+ if (approvals.length > 0 && claimedIds.size === 0) {
445
+ throw new Error(
446
+ `Approvals for run ${runId} were already resolved by another caller`
447
+ )
478
448
  }
479
449
 
480
450
  const { tools } = await buildToolDefs(
@@ -498,6 +468,8 @@ export async function resumeAIAgentSync(
498
468
  if (pending.type !== 'tool-call') continue
499
469
 
500
470
  const toolCallId = pending.toolCallId
471
+ if (!claimedIds.has(toolCallId)) continue
472
+
501
473
  let resultStr: string
502
474
 
503
475
  if (rejectedIds.has(toolCallId)) {
@@ -623,19 +595,13 @@ async function continueAfterToolResultSync(
623
595
  // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
624
596
  const sharedNotes: Record<string, unknown> = {}
625
597
 
626
- let modifiedMessages = trimmedMessages
627
- let modifiedInstructions = instructions
628
- for (const mw of aiMiddlewares) {
629
- if (mw.modifyInput) {
630
- const result = await mw.modifyInput(singletonServices, {
631
- messages: modifiedMessages,
632
- instructions: modifiedInstructions,
633
- shared: sharedNotes,
634
- })
635
- modifiedMessages = result.messages
636
- modifiedInstructions = result.instructions
637
- }
638
- }
598
+ const { messages: modifiedMessages, instructions: modifiedInstructions } =
599
+ await applyInputMiddleware(
600
+ aiMiddlewares,
601
+ singletonServices,
602
+ { messages: trimmedMessages, instructions: instructions },
603
+ sharedNotes
604
+ )
639
605
 
640
606
  const { tools: resumeTools } = await buildToolDefs(
641
607
  params,
@@ -684,52 +650,15 @@ async function continueAfterToolResultSync(
684
650
  totalUsage.inputTokens += stepResult.usage.inputTokens
685
651
  totalUsage.outputTokens += stepResult.usage.outputTokens
686
652
 
687
- for (const mw of aiMiddlewares) {
688
- if (mw.afterStep) {
689
- await mw.afterStep(singletonServices, {
690
- stepNumber: step,
691
- text: stepResult.text,
692
- toolCalls: stepResult.toolCalls,
693
- toolResults: stepResult.toolResults,
694
- usage: stepResult.usage,
695
- finishReason: stepResult.finishReason,
696
- })
697
- }
698
- }
653
+ await notifyAfterStep(aiMiddlewares, singletonServices, step, stepResult)
699
654
 
700
- accumulatedSteps.push({
701
- usage: stepResult.usage,
702
- toolCalls: stepResult.toolCalls.map((tc) => {
703
- const tr = stepResult.toolResults.find(
704
- (r) => r.toolCallId === tc.toolCallId
705
- )
706
- return {
707
- name: tc.toolName,
708
- args: tc.args as Record<string, unknown>,
709
- result:
710
- typeof tr?.result === 'string'
711
- ? tr.result
712
- : JSON.stringify(tr?.result ?? ''),
713
- }
714
- }),
715
- })
655
+ accumulatedSteps.push(toAccumulatedStep(stepResult))
716
656
 
717
657
  if (stepResult.toolCalls.length === 0) break
718
658
 
719
659
  const approvalsNeeded = checkForApprovals(stepResult, runnerParams.tools)
720
660
  if (approvalsNeeded.length > 0) {
721
- for (const approval of approvalsNeeded) {
722
- const toolDef = runnerParams.tools.find(
723
- (t) => t.name === approval.toolName
724
- )
725
- if (toolDef?.approvalDescriptionFn && !approval.reason) {
726
- try {
727
- approval.reason = await toolDef.approvalDescriptionFn(
728
- approval.args
729
- )
730
- } catch {}
731
- }
732
- }
661
+ await describeApprovals(approvalsNeeded, runnerParams.tools)
733
662
 
734
663
  const pendingApprovals = approvalsNeeded.map((a) =>
735
664
  a.agentRunId
@@ -832,7 +761,7 @@ async function continueAfterToolResultSync(
832
761
  run.threadId,
833
762
  run.resourceId,
834
763
  memoryConfig,
835
- null as any,
764
+ null,
836
765
  {
837
766
  ...result,
838
767
  text: outputText,
@@ -1629,6 +1629,7 @@ describe('resumeAIAgent', () => {
1629
1629
  getRun: async () => remainingRun,
1630
1630
  resolveApproval: async (toolCallId: string, status: string) => {
1631
1631
  resolveCalls.push({ toolCallId, status })
1632
+ return true
1632
1633
  },
1633
1634
  updateRun: async (_runId: string, patch: any) => {
1634
1635
  updates.push(patch)
@@ -1750,6 +1751,7 @@ describe('resumeAIAgent', () => {
1750
1751
  },
1751
1752
  resolveApproval: async (toolCallId: string, status: string) => {
1752
1753
  resolveCalls.push({ toolCallId, status })
1754
+ return true
1753
1755
  },
1754
1756
  updateRun: async (_runId: string, patch: any) => {
1755
1757
  updates.push(patch)
@@ -10,6 +10,7 @@ import type {
10
10
  AIAgentMemoryConfig,
11
11
  } from './ai-agent.types.js'
12
12
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
13
+ import { applyInputMiddleware } from './ai-agent-turn.js'
13
14
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
14
15
  import { randomUUID } from './ai-agent-utils.js'
15
16
  import { SPOKEN_TRANSCRIPT } from './voice-input.js'
@@ -676,38 +677,26 @@ export async function streamAIAgent(
676
677
  // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
677
678
  const sharedNotes: Record<string, unknown> = {}
678
679
 
679
- let modifiedMessages = runnerParams.messages
680
- let modifiedInstructions = runnerParams.instructions
681
- for (const mw of aiMiddlewares) {
682
- if (mw.modifyInput) {
683
- const result = await mw.modifyInput(singletonServices, {
684
- messages: modifiedMessages,
685
- instructions: modifiedInstructions,
686
- shared: sharedNotes,
687
- })
688
- modifiedMessages = result.messages
689
- modifiedInstructions = result.instructions
690
- }
691
- }
680
+ const { messages: modifiedMessages, instructions: modifiedInstructions } =
681
+ await applyInputMiddleware(
682
+ aiMiddlewares,
683
+ singletonServices,
684
+ {
685
+ messages: runnerParams.messages,
686
+ instructions: runnerParams.instructions,
687
+ },
688
+ sharedNotes
689
+ )
692
690
  runnerParams.messages = modifiedMessages
693
691
  runnerParams.instructions = modifiedInstructions
694
692
 
695
- // Sent on the raw channel, ahead of the run. A voice client sent audio and so
696
- // has no idea what it said; until this arrives its own message is a blank
697
- // bubble. Ahead of the run rather than alongside it because the answer starts
698
- // streaming within a few hundred milliseconds, and a question that appears
699
- // after its answer reads as the wrong question.
693
+ // knowledge: decisions/internals/the-transcript-event-is-sent-ahead-of-the-run.md
700
694
  const transcript = sharedNotes[SPOKEN_TRANSCRIPT]
701
695
  if (typeof transcript === 'string') {
702
696
  channel.send({ type: 'transcript', text: transcript })
703
697
  }
704
698
 
705
- // What goes into thread history is what the model was actually asked. For a
706
- // spoken turn that is not what arrived over the wire: the wire carried a
707
- // base64 audio blob, and persisting it would write megabytes of unreadable
708
- // data into the history while losing the only readable record of the turn.
709
- // Identity-checked rather than assumed — a middleware is free to rewrite the
710
- // message list into something with no relation to the turn.
699
+ // knowledge: decisions/internals/thread-history-records-the-transcript-not-the-audio.md
711
700
  const lastModified = modifiedMessages[modifiedMessages.length - 1]
712
701
  const persistedUserMessage =
713
702
  lastModified?.id === userMessage.id ? lastModified : userMessage
@@ -768,7 +757,7 @@ export async function streamAIAgent(
768
757
  {
769
758
  wireInheritedChannelMiddleware: meta?.channelMiddleware,
770
759
  wireChannelMiddleware: [
771
- ...((agent.channelMiddleware as any[]) ?? []),
760
+ ...(agent.channelMiddleware ?? []),
772
761
  ...streamMiddleware,
773
762
  ],
774
763
  }
@@ -792,10 +781,7 @@ export async function streamAIAgent(
792
781
 
793
782
  const credentialFilteredChannel: AIStreamChannel = {
794
783
  ...wrappedChannel,
795
- // Returns what the inner send returns. Middleware runs asynchronously, so
796
- // swallowing the promise here makes every `await channel.send(...)` upstream
797
- // a no-op — including the one that waits for a buffering hook to flush
798
- // before the channel closes.
784
+ // knowledge: decisions/internals/an-agent-stream-send-must-return-the-inner-sends-promise.md
799
785
  send: (event: AIStreamEvent) => {
800
786
  if (
801
787
  event.type === 'tool-result' &&
@@ -872,11 +858,7 @@ export async function streamAIAgent(
872
858
  runId
873
859
  )
874
860
 
875
- // Through the middleware rather than straight at the channel, and awaited.
876
- // `done` is the only signal a stream hook gets that the reply is over, and
877
- // the ones that buffer need it: `voiceOutput` speaks a trailing fragment
878
- // that never reached a full stop, and waits for audio it has already paid
879
- // to synthesize. Sent raw, that work is discarded by the `close()` below.
861
+ // knowledge: decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md
880
862
  await outputChannel.send({ type: 'done' })
881
863
  channel.close()
882
864
  return persistingChannel.fullText
@@ -979,12 +961,7 @@ export async function interruptAIAgent(
979
961
  reason: input.reason ?? 'user',
980
962
  })
981
963
 
982
- // A run still marked `running` that this process cannot abort is executing on
983
- // another instance. Returning a bare `false` there is indistinguishable from
984
- // "it already finished", so the one deployment shape this registry does not
985
- // cover fails silently — an agent that simply refuses to shut up, with nothing
986
- // in the logs. Say so instead. See `signalRunInterrupt` for the fix (fan the
987
- // interrupt out over eventHub so every instance tries locally).
964
+ // knowledge: decisions/internals/an-agent-run-owned-by-another-instance-says-so.md
988
965
  if (!stopped && run.status === 'running') {
989
966
  logger?.warn(
990
967
  `Could not interrupt run ${input.runId}: it is running in another process. ` +
@@ -1037,10 +1014,18 @@ export async function resumeAIAgent(
1037
1014
 
1038
1015
  await assertAgentAuthorized(agent, params, packageName)
1039
1016
 
1040
- await aiRunState.resolveApproval(
1017
+ // The read above is not a claim — concurrent resumes all see the same pending
1018
+ // approval. `resolveApproval` is the claim, and the loser must not go on to
1019
+ // run the tool a second time.
1020
+ const claimed = await aiRunState.resolveApproval(
1041
1021
  input.toolCallId,
1042
1022
  input.approved ? 'approved' : 'denied'
1043
1023
  )
1024
+ if (!claimed) {
1025
+ throw new Error(
1026
+ `Approval for toolCallId ${input.toolCallId} was already resolved by another caller`
1027
+ )
1028
+ }
1044
1029
 
1045
1030
  const { storage } = resolveMemoryServices(agent, singletonServices)
1046
1031
  const memoryConfig = agent.memory
@@ -1317,24 +1302,15 @@ async function continueAfterToolResult(
1317
1302
  // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
1318
1303
  const sharedNotes: Record<string, unknown> = {}
1319
1304
 
1320
- let modifiedMessages = trimmedMessages
1321
- let modifiedInstructions = instructions
1322
- for (const mw of aiMiddlewares) {
1323
- if (mw.modifyInput) {
1324
- const result = await mw.modifyInput(singletonServices, {
1325
- messages: modifiedMessages,
1326
- instructions: modifiedInstructions,
1327
- shared: sharedNotes,
1328
- })
1329
- modifiedMessages = result.messages
1330
- modifiedInstructions = result.instructions
1331
- }
1332
- }
1305
+ const { messages: modifiedMessages, instructions: modifiedInstructions } =
1306
+ await applyInputMiddleware(
1307
+ aiMiddlewares,
1308
+ singletonServices,
1309
+ { messages: trimmedMessages, instructions: instructions },
1310
+ sharedNotes
1311
+ )
1333
1312
 
1334
- // Resuming is as interruptible as the first turn. It is the same person
1335
- // listening to the same voice, and after an approval it is where most of the
1336
- // reply actually gets spoken — an approved delete is followed by the agent
1337
- // talking about it, and that is a normal thing to talk over.
1313
+ // knowledge: decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md
1338
1314
  const interruptHandle = registerInterruptibleRun(run.runId)
1339
1315
 
1340
1316
  const streamMiddleware = aiMiddlewares
@@ -1369,7 +1345,7 @@ async function continueAfterToolResult(
1369
1345
  {
1370
1346
  wireInheritedChannelMiddleware: meta?.channelMiddleware,
1371
1347
  wireChannelMiddleware: [
1372
- ...((agent.channelMiddleware as any[]) ?? []),
1348
+ ...(agent.channelMiddleware ?? []),
1373
1349
  ...streamMiddleware,
1374
1350
  ],
1375
1351
  }
@@ -1467,10 +1443,7 @@ async function continueAfterToolResult(
1467
1443
  run.runId
1468
1444
  )
1469
1445
 
1470
- // Through the middleware, for the same reason as the first turn — and it
1471
- // matters more here. After an approval, most of what gets spoken is the
1472
- // agent talking about what it just did, so this is the half of the reply a
1473
- // dropped flush would silence.
1446
+ // knowledge: decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md
1474
1447
  await wrappedChannel.send({ type: 'done' })
1475
1448
  channel.close()
1476
1449
  } catch (err) {
@@ -114,7 +114,7 @@ const createHarness = (
114
114
  ],
115
115
  messages: [],
116
116
  }),
117
- resolveApproval: async () => {},
117
+ resolveApproval: async () => true,
118
118
  },
119
119
  } as any)
120
120