@pikku/core 0.12.80 → 0.12.82

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 (231) hide show
  1. package/CHANGELOG.md +312 -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 -15
  14. package/dist/services/index.js +5 -5
  15. package/dist/services/scoped-credential-service.d.ts +21 -0
  16. package/dist/services/scoped-credential-service.js +53 -0
  17. package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
  18. package/dist/types/core.types.d.ts +0 -2
  19. package/dist/types/state.types.d.ts +13 -0
  20. package/dist/wirings/actor-flow/index.d.ts +1 -1
  21. package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
  22. package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
  23. package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
  24. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
  25. package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
  26. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
  27. package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
  28. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -0
  29. package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
  30. package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
  31. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
  32. package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
  33. package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
  34. package/dist/wirings/ai-agent/index.d.ts +8 -7
  35. package/dist/wirings/ai-agent/index.js +5 -4
  36. package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
  37. package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
  38. package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
  39. package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
  40. package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
  41. package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
  42. package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
  43. package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
  44. package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
  45. package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
  46. package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
  47. package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
  48. package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
  49. package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
  50. package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
  51. package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
  52. package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
  53. package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
  54. package/dist/wirings/ai-scorer/index.d.ts +6 -0
  55. package/dist/wirings/ai-scorer/index.js +5 -0
  56. package/dist/wirings/channel/index.d.ts +5 -6
  57. package/dist/wirings/channel/index.js +3 -4
  58. package/dist/wirings/channel/local/local-channel-runner.js +8 -1
  59. package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
  60. package/dist/wirings/cli/channel/index.d.ts +1 -2
  61. package/dist/wirings/cli/channel/index.js +0 -1
  62. package/dist/wirings/cli/cli-runner.js +13 -1
  63. package/dist/wirings/credential/index.d.ts +1 -1
  64. package/dist/wirings/gateway/index.d.ts +1 -1
  65. package/dist/wirings/http/http-runner.js +8 -2
  66. package/dist/wirings/http/index.d.ts +1 -2
  67. package/dist/wirings/mcp/index.d.ts +1 -1
  68. package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
  69. package/dist/wirings/mcp/mcp-runner.js +18 -5
  70. package/dist/wirings/persona/index.d.ts +3 -4
  71. package/dist/wirings/persona/index.js +2 -3
  72. package/dist/wirings/queue/index.d.ts +1 -3
  73. package/dist/wirings/queue/index.js +1 -3
  74. package/dist/wirings/rpc/addon-runner.d.ts +4 -0
  75. package/dist/wirings/rpc/addon-runner.js +19 -3
  76. package/dist/wirings/rpc/rpc-runner.js +2 -0
  77. package/dist/wirings/rpc/rpc-types.d.ts +4 -0
  78. package/dist/wirings/rpc/wire-addon.d.ts +13 -0
  79. package/dist/wirings/rpc/wire-addon.js +4 -0
  80. package/dist/wirings/scheduler/index.d.ts +1 -1
  81. package/dist/wirings/trigger/index.d.ts +1 -1
  82. package/dist/wirings/virtual-user/index.d.ts +5 -6
  83. package/dist/wirings/virtual-user/index.js +2 -4
  84. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
  85. package/dist/wirings/workflow/index.d.ts +6 -6
  86. package/dist/wirings/workflow/index.js +2 -2
  87. package/dist/wirings/workflow/pikku-scenario-service.d.ts +7 -7
  88. package/dist/wirings/workflow/pikku-scenario-service.js +39 -13
  89. package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
  90. package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
  91. package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
  92. package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
  93. package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
  94. package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
  95. package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
  96. package/dist/wirings/workflow/workflow-approval.js +65 -2
  97. package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
  98. package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
  99. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  100. package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
  101. package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
  102. package/knowledge/decisions/internals/index.md +30 -3
  103. package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
  104. package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
  105. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
  106. package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
  107. package/knowledge/decisions/security/index.md +3 -1
  108. package/knowledge/questions/index.md +1 -1
  109. package/package.json +3 -1
  110. package/scripts/generate-api-report.mts +143 -18
  111. package/src/api-report.test.ts +2 -2
  112. package/src/errors/index.ts +1 -1
  113. package/src/function/function-runner.test.ts +52 -0
  114. package/src/function/function-runner.ts +5 -9
  115. package/src/function/index.ts +0 -2
  116. package/src/index.ts +0 -35
  117. package/src/pikku-state.ts +5 -0
  118. package/src/public-surface.json +70 -94
  119. package/src/services/ai-agent-runner-service.ts +12 -1
  120. package/src/services/ai-run-state-service.ts +11 -0
  121. package/src/services/in-memory-ai-run-state-service.ts +13 -0
  122. package/src/services/index.ts +3 -43
  123. package/src/services/scoped-credential-service.test.ts +86 -0
  124. package/src/services/scoped-credential-service.ts +63 -0
  125. package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
  126. package/src/types/core.types.ts +3 -6
  127. package/src/types/state.types.ts +16 -0
  128. package/src/wirings/actor-flow/index.ts +0 -3
  129. package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
  130. package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
  131. package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
  132. package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
  133. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
  134. package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
  135. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -0
  136. package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
  137. package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
  138. package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
  139. package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
  140. package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
  141. package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
  142. package/src/wirings/ai-agent/index.ts +2 -16
  143. package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
  144. package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
  145. package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
  146. package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
  147. package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
  148. package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
  149. package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
  150. package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
  151. package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
  152. package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
  153. package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
  154. package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
  155. package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
  156. package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
  157. package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
  158. package/src/wirings/ai-scorer/index.ts +24 -0
  159. package/src/wirings/channel/index.ts +1 -20
  160. package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
  161. package/src/wirings/channel/local/local-channel-runner.ts +8 -1
  162. package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
  163. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
  164. package/src/wirings/cli/channel/index.ts +0 -7
  165. package/src/wirings/cli/cli-runner.test.ts +68 -0
  166. package/src/wirings/cli/cli-runner.ts +18 -1
  167. package/src/wirings/credential/index.ts +0 -1
  168. package/src/wirings/gateway/index.ts +0 -3
  169. package/src/wirings/http/http-runner.test.ts +66 -0
  170. package/src/wirings/http/http-runner.ts +10 -2
  171. package/src/wirings/http/index.ts +1 -1
  172. package/src/wirings/mcp/index.ts +0 -1
  173. package/src/wirings/mcp/mcp-runner.test.ts +181 -0
  174. package/src/wirings/mcp/mcp-runner.ts +35 -5
  175. package/src/wirings/persona/index.ts +0 -8
  176. package/src/wirings/queue/index.ts +0 -14
  177. package/src/wirings/rpc/addon-runner.ts +34 -3
  178. package/src/wirings/rpc/addon-secrets.test.ts +261 -0
  179. package/src/wirings/rpc/rpc-runner.test.ts +2 -0
  180. package/src/wirings/rpc/rpc-runner.ts +2 -0
  181. package/src/wirings/rpc/rpc-types.ts +4 -0
  182. package/src/wirings/rpc/wire-addon.ts +17 -0
  183. package/src/wirings/scheduler/index.ts +0 -1
  184. package/src/wirings/trigger/index.ts +0 -1
  185. package/src/wirings/virtual-user/index.ts +0 -16
  186. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
  187. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  188. package/src/wirings/workflow/index.ts +2 -20
  189. package/src/wirings/workflow/pikku-scenario-service.ts +60 -15
  190. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
  191. package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
  192. package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
  193. package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
  194. package/src/wirings/workflow/scenario-step.types.ts +8 -0
  195. package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
  196. package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
  197. package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
  198. package/src/wirings/workflow/workflow-approval.ts +113 -9
  199. package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
  200. package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
  201. package/src/wirings/workflow/workflow.types.ts +0 -9
  202. package/src/wirings-stay-decoupled.test.ts +6 -2
  203. package/tsconfig.tsbuildinfo +1 -1
  204. package/dist/internal.d.ts +0 -3
  205. package/dist/internal.js +0 -2
  206. package/dist/middleware/timeout.d.ts +0 -9
  207. package/dist/middleware/timeout.js +0 -15
  208. package/dist/pikku-response.d.ts +0 -6
  209. package/dist/pikku-response.js +0 -6
  210. package/dist/services/gopass-secrets.d.ts +0 -15
  211. package/dist/services/gopass-secrets.js +0 -76
  212. package/dist/services/http-scenario-actors.d.ts +0 -75
  213. package/dist/services/http-scenario-actors.js +0 -195
  214. package/dist/services/http-user-flow-actors.d.ts +0 -67
  215. package/dist/services/http-user-flow-actors.js +0 -193
  216. package/dist/services/scenario-actors-service.d.ts +0 -127
  217. package/dist/services/scenario-actors-service.js +0 -40
  218. package/dist/services/user-flow-actors-service.d.ts +0 -39
  219. package/dist/services/user-flow-actors-service.js +0 -1
  220. package/dist/wirings/credential/wire-credential.d.ts +0 -48
  221. package/dist/wirings/credential/wire-credential.js +0 -47
  222. package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
  223. package/dist/wirings/oauth2/oauth2-client.js +0 -263
  224. package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
  225. package/dist/wirings/oauth2/oauth2-routes.js +0 -146
  226. package/dist/wirings/scope/wire-scope.d.ts +0 -33
  227. package/dist/wirings/scope/wire-scope.js +0 -32
  228. package/dist/wirings/workflow/dsl/index.d.ts +0 -5
  229. package/dist/wirings/workflow/dsl/index.js +0 -4
  230. package/dist/wirings/workflow/graph/index.d.ts +0 -5
  231. package/dist/wirings/workflow/graph/index.js +0 -4
@@ -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
+ })
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AIStreamChannel,
3
3
  AIStreamEvent,
4
+ AIAgentStep,
4
5
  AIMessage,
5
6
  AIToolCall,
6
7
  AIToolResult,
@@ -9,6 +10,10 @@ import type {
9
10
  CoreAIAgent,
10
11
  AIAgentMemoryConfig,
11
12
  } from './ai-agent.types.js'
13
+ import {
14
+ finalizeAgentRun,
15
+ lastUserMessageText,
16
+ } from './ai-agent-finalize.js'
12
17
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
13
18
  import { applyInputMiddleware } from './ai-agent-turn.js'
14
19
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
@@ -68,6 +73,8 @@ type PersistingChannel = AIStreamChannel & {
68
73
  fullText: string
69
74
  flush: (opts?: { interrupted?: boolean }) => Promise<void>
70
75
  totalUsage: { inputTokens: number; outputTokens: number; model?: string }
76
+ /** Every tool the run called, kept for the whole run rather than per step. */
77
+ runToolCalls: NonNullable<AIAgentStep['toolCalls']>
71
78
  }
72
79
 
73
80
  function createPersistingChannel(
@@ -89,6 +96,9 @@ function createPersistingChannel(
89
96
  inputTokens: 0,
90
97
  outputTokens: 0,
91
98
  }
99
+ // Survives the per-step flush below, which clears its own buffers: the run
100
+ // record needs every call the run made, not just the last step's.
101
+ const runToolCalls: NonNullable<AIAgentStep['toolCalls']> = []
92
102
 
93
103
  const flushStep = async (opts?: { interrupted?: boolean }) => {
94
104
  if (!storage) return
@@ -137,6 +147,8 @@ function createPersistingChannel(
137
147
  })
138
148
  }
139
149
 
150
+ const runToolCallIndex = new Map<string, number>()
151
+
140
152
  const channel: PersistingChannel = {
141
153
  channelId: parent.channelId,
142
154
  openingData: parent.openingData,
@@ -149,6 +161,9 @@ function createPersistingChannel(
149
161
  get totalUsage() {
150
162
  return totalUsage
151
163
  },
164
+ get runToolCalls() {
165
+ return runToolCalls
166
+ },
152
167
  flush: flushStep,
153
168
  close: () => parent.close(),
154
169
  sendBinary: (data) => parent.sendBinary(data),
@@ -157,6 +172,26 @@ function createPersistingChannel(
157
172
  // the client was streamed, and an interrupted run has to be able to
158
173
  // report the fragment it got through even with persistence turned off.
159
174
  if (event.type === 'text-delta') fullText += event.text
175
+ if (event.type === 'tool-call') {
176
+ runToolCallIndex.set(event.toolCallId, runToolCalls.length)
177
+ runToolCalls.push({
178
+ name: event.toolName,
179
+ args: event.args as Record<string, unknown>,
180
+ result: '',
181
+ })
182
+ }
183
+ if (event.type === 'tool-result') {
184
+ const index = runToolCallIndex.get(event.toolCallId)
185
+ const result =
186
+ typeof event.result === 'string'
187
+ ? event.result
188
+ : JSON.stringify(event.result)
189
+ const call = index === undefined ? undefined : runToolCalls[index]
190
+ if (call) {
191
+ call.result = result
192
+ if (event.error) call.error = event.error
193
+ }
194
+ }
160
195
  if (storage) {
161
196
  switch (event.type) {
162
197
  case 'text-delta':
@@ -177,6 +212,7 @@ function createPersistingChannel(
177
212
  typeof event.result === 'string'
178
213
  ? event.result
179
214
  : JSON.stringify(event.result),
215
+ ...(event.error ? { error: event.error } : {}),
180
216
  })
181
217
  break
182
218
  case 'generative-ui':
@@ -203,44 +239,67 @@ function createPersistingChannel(
203
239
  return channel
204
240
  }
205
241
 
242
+ /**
243
+ * Agents already warned about, so a per-request hook does not become a
244
+ * per-request log line.
245
+ */
246
+ const warnedUnstreamedOutputHooks = new Set<string>()
247
+
248
+ /**
249
+ * `modifyOutput` does not run on a streamed run at all. Nothing here could act
250
+ * on what it returns — the text has already reached the client, and
251
+ * `createPersistingChannel` flushes each step to storage as it goes, so by the
252
+ * time the run ends the transcript is already written.
253
+ *
254
+ * Rewriting on this path belongs to `modifyOutputStream`, which genuinely
255
+ * works: the stream middleware wraps the persisting channel, so what is stored
256
+ * and accumulated is already what the client was sent. A middleware that
257
+ * rewrites in `modifyOutput` only — a redaction hook, typically — is therefore
258
+ * silently ineffective when the agent is streamed, and is told so once.
259
+ */
260
+ const warnUnstreamedOutputHooks = (
261
+ agentName: string,
262
+ aiMiddlewares: PikkuAIMiddlewareHooks[],
263
+ logger?: { warn: (...args: any[]) => void }
264
+ ) => {
265
+ if (warnedUnstreamedOutputHooks.has(agentName)) return
266
+ const unstreamed = aiMiddlewares.some(
267
+ (mw) => mw.modifyOutput && !mw.modifyOutputStream
268
+ )
269
+ if (!unstreamed) return
270
+ warnedUnstreamedOutputHooks.add(agentName)
271
+ logger?.warn(
272
+ `Agent '${agentName}' has AI middleware with modifyOutput but no modifyOutputStream — modifyOutput does not apply to streamed runs. Implement modifyOutputStream to affect a streamed reply.`
273
+ )
274
+ }
275
+
206
276
  async function postStreamCleanup(
207
277
  persistingChannel: PersistingChannel,
208
- aiMiddlewares: PikkuAIMiddlewareHooks[],
209
- singletonServices: any,
210
- messages: AIMessage[],
211
278
  aiRunState: AIRunStateService,
212
- runId: string
213
- ): Promise<void> {
214
- const usage = persistingChannel.totalUsage
215
- let outputText = persistingChannel.fullText
216
- let outputMessages = messages
217
- for (let i = aiMiddlewares.length - 1; i >= 0; i--) {
218
- const mw = aiMiddlewares[i]
219
- if (mw.modifyOutput) {
220
- const result = await mw.modifyOutput(singletonServices, {
221
- text: outputText,
222
- messages: outputMessages,
223
- usage: {
224
- inputTokens: usage.inputTokens,
225
- outputTokens: usage.outputTokens,
226
- },
227
- })
228
- outputText = result.text
229
- outputMessages = result.messages
230
- }
279
+ runId: string,
280
+ run: {
281
+ agentName: string
282
+ threadId: string
283
+ resourceId?: string
284
+ input: string
231
285
  }
232
-
233
- await aiRunState.updateRun(runId, {
234
- status: 'completed',
235
- ...(usage.model
236
- ? {
237
- usage: {
238
- inputTokens: usage.inputTokens,
239
- outputTokens: usage.outputTokens,
240
- model: usage.model,
241
- },
242
- }
243
- : {}),
286
+ ): Promise<void> {
287
+ await finalizeAgentRun(aiRunState, {
288
+ runId,
289
+ agentName: run.agentName,
290
+ threadId: run.threadId,
291
+ resourceId: run.resourceId,
292
+ input: run.input,
293
+ // Already what the client received: the stream middleware wraps the
294
+ // persisting channel, so both were accumulated post-rewrite.
295
+ text: persistingChannel.fullText,
296
+ steps: [
297
+ {
298
+ usage: persistingChannel.totalUsage,
299
+ toolCalls: persistingChannel.runToolCalls,
300
+ },
301
+ ],
302
+ usage: persistingChannel.totalUsage,
244
303
  })
245
304
  }
246
305
 
@@ -723,6 +782,8 @@ export async function streamAIAgent(
723
782
  await storage.saveMessages(threadId, [persistedUserMessage])
724
783
  }
725
784
 
785
+ warnUnstreamedOutputHooks(agentName, aiMiddlewares, singletonServices.logger)
786
+
726
787
  const streamMiddleware = aiMiddlewares
727
788
  .filter((mw) => mw.modifyOutputStream)
728
789
  .map((mw) => {
@@ -849,14 +910,12 @@ export async function streamAIAgent(
849
910
  return persistingChannel.fullText
850
911
  }
851
912
 
852
- await postStreamCleanup(
853
- persistingChannel,
854
- aiMiddlewares,
855
- singletonServices,
856
- runnerParams.messages,
857
- aiRunState,
858
- runId
859
- )
913
+ await postStreamCleanup(persistingChannel, aiRunState, runId, {
914
+ agentName,
915
+ threadId,
916
+ resourceId: input.resourceId,
917
+ input: lastUserMessageText(runnerParams.messages),
918
+ })
860
919
 
861
920
  // knowledge: decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md
862
921
  await outputChannel.send({ type: 'done' })
@@ -1184,16 +1243,17 @@ export async function resumeAIAgent(
1184
1243
  typeof pending.args === 'string' ? JSON.parse(pending.args) : pending.args
1185
1244
 
1186
1245
  let toolResult: unknown
1187
- let isError = false
1246
+ let toolError: string | undefined
1188
1247
  try {
1189
1248
  toolResult = await matchingTool.execute(toolArgs)
1190
1249
  } catch (execErr: any) {
1191
1250
  if (execErr?.payload?.error === 'missing_credential') {
1192
1251
  toolResult = execErr.payload
1252
+ toolError = 'missing_credential'
1193
1253
  } else {
1194
- toolResult = `Error: ${execErr instanceof Error ? execErr.message : String(execErr)}`
1254
+ toolError = execErr instanceof Error ? execErr.message : String(execErr)
1255
+ toolResult = `Error: ${toolError}`
1195
1256
  }
1196
- isError = true
1197
1257
  }
1198
1258
 
1199
1259
  const resultStr =
@@ -1220,7 +1280,7 @@ export async function resumeAIAgent(
1220
1280
  toolCallId: input.toolCallId,
1221
1281
  toolName: pending.toolName,
1222
1282
  result: toolResult,
1223
- ...(isError ? { isError: true } : {}),
1283
+ ...(toolError ? { error: toolError } : {}),
1224
1284
  })
1225
1285
  }
1226
1286
 
@@ -1313,6 +1373,12 @@ async function continueAfterToolResult(
1313
1373
  // knowledge: decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md
1314
1374
  const interruptHandle = registerInterruptibleRun(run.runId)
1315
1375
 
1376
+ warnUnstreamedOutputHooks(
1377
+ run.agentName,
1378
+ aiMiddlewares,
1379
+ singletonServices.logger
1380
+ )
1381
+
1316
1382
  const streamMiddleware = aiMiddlewares
1317
1383
  .filter((mw) => mw.modifyOutputStream)
1318
1384
  .map((mw) => {
@@ -1434,14 +1500,10 @@ async function continueAfterToolResult(
1434
1500
  return
1435
1501
  }
1436
1502
 
1437
- await postStreamCleanup(
1438
- persistingChannel,
1439
- aiMiddlewares,
1440
- singletonServices,
1441
- runnerParams.messages,
1442
- aiRunState,
1443
- run.runId
1444
- )
1503
+ await postStreamCleanup(persistingChannel, aiRunState, run.runId, {
1504
+ ...run,
1505
+ input: lastUserMessageText(runnerParams.messages),
1506
+ })
1445
1507
 
1446
1508
  // knowledge: decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md
1447
1509
  await wrappedChannel.send({ type: 'done' })
@@ -0,0 +1,67 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { toAccumulatedStep } from './ai-agent-turn.js'
5
+ import type { AIAgentStepResult } from '../../services/ai-agent-runner-service.js'
6
+
7
+ const stepResult = (
8
+ overrides?: Partial<AIAgentStepResult>
9
+ ): AIAgentStepResult => ({
10
+ text: '',
11
+ toolCalls: [],
12
+ toolResults: [],
13
+ usage: { inputTokens: 0, outputTokens: 0 },
14
+ finishReason: 'stop',
15
+ ...overrides,
16
+ })
17
+
18
+ describe('toAccumulatedStep', () => {
19
+ test('carries a tool failure as its own field, not only as rendered text', () => {
20
+ const step = toAccumulatedStep(
21
+ stepResult({
22
+ toolCalls: [
23
+ { toolCallId: 'call-1', toolName: 'lookupOrder', args: { id: 7 } },
24
+ ],
25
+ toolResults: [
26
+ {
27
+ toolCallId: 'call-1',
28
+ toolName: 'lookupOrder',
29
+ result: 'Error: order service unreachable',
30
+ error: 'order service unreachable',
31
+ },
32
+ ],
33
+ })
34
+ )
35
+
36
+ assert.deepEqual(step.toolCalls, [
37
+ {
38
+ name: 'lookupOrder',
39
+ args: { id: 7 },
40
+ result: 'Error: order service unreachable',
41
+ error: 'order service unreachable',
42
+ },
43
+ ])
44
+ })
45
+
46
+ test('leaves error unset on a tool that returned normally, even if it says Error', () => {
47
+ // A tool is allowed to return the word "Error" — which is exactly why
48
+ // "did this fail" cannot be answered by matching on the result text.
49
+ const step = toAccumulatedStep(
50
+ stepResult({
51
+ toolCalls: [
52
+ { toolCallId: 'call-1', toolName: 'searchLogs', args: { q: 'x' } },
53
+ ],
54
+ toolResults: [
55
+ {
56
+ toolCallId: 'call-1',
57
+ toolName: 'searchLogs',
58
+ result: 'Error: connection refused (1 match)',
59
+ },
60
+ ],
61
+ })
62
+ )
63
+
64
+ assert.equal(step.toolCalls[0].error, undefined)
65
+ assert.ok(!('error' in step.toolCalls[0]))
66
+ })
67
+ })