@pikku/core 0.12.86 → 0.12.89

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 (49) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/dev/hot-reload.d.ts +1 -1
  3. package/dist/dev/hot-reload.js +1 -1
  4. package/dist/types/core.types.d.ts +1 -1
  5. package/dist/types/index.d.ts +1 -1
  6. package/dist/types/index.js +1 -1
  7. package/dist/wirings/agent/agent-memory.d.ts +11 -0
  8. package/dist/wirings/agent/agent-memory.js +17 -2
  9. package/dist/wirings/agent/agent-stream.js +115 -91
  10. package/dist/wirings/agent/agent.types.d.ts +9 -8
  11. package/dist/wirings/rpc/rpc-runner.js +1 -1
  12. package/dist/wirings/variable/validate-variable-definitions.js +2 -2
  13. package/dist/wirings/variable/variable.types.d.ts +13 -7
  14. package/dist/wirings/workflow/pikku-scenario-service.d.ts +2 -2
  15. package/dist/wirings/workflow/pikku-workflow-service.d.ts +20 -6
  16. package/dist/wirings/workflow/pikku-workflow-service.js +29 -27
  17. package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
  18. package/dist/wirings/workflow/workflow-constants.js +17 -0
  19. package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
  20. package/dist/wirings/workflow/workflow-recovery.js +30 -2
  21. package/dist/wirings/workflow/workflow-step-claim.d.ts +16 -0
  22. package/dist/wirings/workflow/workflow-step-claim.js +27 -0
  23. package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md +47 -0
  24. package/knowledge/decisions/security/index.md +2 -1
  25. package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md +6 -0
  26. package/package.json +1 -1
  27. package/src/dev/hot-reload.ts +1 -1
  28. package/src/types/core.types.ts +1 -1
  29. package/src/types/index.ts +24 -1
  30. package/src/wirings/agent/agent-memory.test.ts +73 -0
  31. package/src/wirings/agent/agent-memory.ts +16 -2
  32. package/src/wirings/agent/agent-middleware.types.test.ts +41 -0
  33. package/src/wirings/agent/agent-stream-delegate.test.ts +381 -0
  34. package/src/wirings/agent/agent-stream.ts +158 -78
  35. package/src/wirings/agent/agent.types.ts +9 -8
  36. package/src/wirings/rpc/rpc-runner.test.ts +6 -1
  37. package/src/wirings/rpc/rpc-runner.ts +1 -1
  38. package/src/wirings/variable/validate-variable-definitions.test.ts +11 -10
  39. package/src/wirings/variable/validate-variable-definitions.ts +2 -2
  40. package/src/wirings/variable/variable.types.ts +13 -7
  41. package/src/wirings/workflow/pikku-scenario-service.ts +29 -6
  42. package/src/wirings/workflow/pikku-workflow-service.ts +34 -27
  43. package/src/wirings/workflow/workflow-constants.ts +19 -0
  44. package/src/wirings/workflow/workflow-recovery.ts +31 -1
  45. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
  46. package/src/wirings/workflow/workflow-step-claim.ts +46 -0
  47. package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
  48. package/tsconfig.tsbuildinfo +1 -1
  49. package/tsconfig.type-tests.json +2 -1
@@ -0,0 +1,381 @@
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 { streamAgent, resumeAgent } from './agent-stream.js'
6
+ import type {
7
+ CoreAgent,
8
+ AgentStreamChannel,
9
+ AgentStreamEvent,
10
+ } from './agent.types.js'
11
+ import type {
12
+ AgentStepResult,
13
+ AgentRunnerParams,
14
+ } from '../../services/agent-runner-service.js'
15
+
16
+ beforeEach(() => {
17
+ resetPikkuState()
18
+ })
19
+
20
+ const registerAgent = (
21
+ name: string,
22
+ overrides: Partial<CoreAgent> = {},
23
+ metaOverrides: Record<string, unknown> = {}
24
+ ) => {
25
+ const agent: CoreAgent = {
26
+ name,
27
+ description: `${name} agent`,
28
+ instructions: name,
29
+ model: 'test/test-model',
30
+ ...overrides,
31
+ }
32
+
33
+ pikkuState(null, 'agent', 'agentsMeta')[name] = {
34
+ ...agent,
35
+ inputSchema: null,
36
+ outputSchema: null,
37
+ workingMemorySchema: null,
38
+ ...metaOverrides,
39
+ } as any
40
+ pikkuState(null, 'agent', 'agents').set(name, agent)
41
+ return agent
42
+ }
43
+
44
+ const makeStepResult = (
45
+ overrides?: Partial<AgentStepResult>
46
+ ): AgentStepResult => ({
47
+ text: '',
48
+ toolCalls: [],
49
+ toolResults: [],
50
+ usage: { inputTokens: 0, outputTokens: 0 },
51
+ finishReason: 'stop',
52
+ ...overrides,
53
+ })
54
+
55
+ const recordingChannel = (channelId: string) => {
56
+ const events: AgentStreamEvent[] = []
57
+ const channel = {
58
+ channelId,
59
+ openingData: undefined,
60
+ state: 'open',
61
+ send: (event: AgentStreamEvent) => {
62
+ events.push(event)
63
+ },
64
+ sendBinary: () => {},
65
+ setState: () => {},
66
+ close: () => {},
67
+ } as unknown as AgentStreamChannel
68
+ return { channel, events }
69
+ }
70
+
71
+ const parentTextOf = (events: AgentStreamEvent[]) =>
72
+ events
73
+ .filter((e) => e.type === 'text-delta' && !(e as any).agent)
74
+ .map((e) => (e as any).text as string)
75
+ .join('')
76
+
77
+ /**
78
+ * A parent that speaks either side of a hand-off to a specialist — the shape
79
+ * the issue describes, with the model's text made deterministic so the
80
+ * plumbing can be observed without a live call.
81
+ */
82
+ const delegatingRunner = (
83
+ script: { preHandoff: string; postHandoff: string } = {
84
+ preHandoff: 'Planning. ',
85
+ postHandoff: 'Handed off. <working_memory>{"phase":"two"}</working_memory>',
86
+ }
87
+ ) => {
88
+ let parentStep = 0
89
+ return {
90
+ stream: async (params: AgentRunnerParams, channel: AgentStreamChannel) => {
91
+ const subTool = params.tools.find((t) => t.name === 'sub')
92
+ if (!subTool) {
93
+ channel.send({ type: 'text-delta', text: 'specialist output' })
94
+ channel.send({
95
+ type: 'usage',
96
+ tokens: { input: 1, output: 1 },
97
+ } as AgentStreamEvent)
98
+ return makeStepResult({ text: 'specialist output' })
99
+ }
100
+
101
+ if (parentStep++ === 0) {
102
+ channel.send({ type: 'text-delta', text: script.preHandoff })
103
+
104
+ await subTool.execute!({ message: 'do phase two', session: 's1' })
105
+
106
+ channel.send({ type: 'text-delta', text: script.postHandoff })
107
+ channel.send({
108
+ type: 'usage',
109
+ tokens: { input: 1, output: 1 },
110
+ } as AgentStreamEvent)
111
+ return makeStepResult({
112
+ text: 'Planning. Handed off.',
113
+ toolCalls: [
114
+ {
115
+ toolCallId: 'call-1',
116
+ toolName: 'sub',
117
+ args: { message: 'do phase two', session: 's1' },
118
+ },
119
+ ],
120
+ toolResults: [
121
+ {
122
+ toolCallId: 'call-1',
123
+ toolName: 'sub',
124
+ result: 'specialist output',
125
+ },
126
+ ],
127
+ finishReason: 'tool-calls',
128
+ })
129
+ }
130
+
131
+ channel.send({
132
+ type: 'text-delta',
133
+ text: 'Done. <working_memory>{"phase":"three"}</working_memory>',
134
+ })
135
+ channel.send({
136
+ type: 'usage',
137
+ tokens: { input: 1, output: 1 },
138
+ } as AgentStreamEvent)
139
+ return makeStepResult({ text: 'Done.' })
140
+ },
141
+ }
142
+ }
143
+
144
+ const makeServices = (
145
+ runner: { stream: any },
146
+ savedWorkingMemory: unknown[],
147
+ savedMessages: any[] = []
148
+ ) => {
149
+ let stored: Record<string, unknown> = {}
150
+ return {
151
+ logger: {
152
+ info: () => {},
153
+ warn: () => {},
154
+ error: () => {},
155
+ debug: () => {},
156
+ },
157
+ agentRunner: runner,
158
+ agentRunState: {
159
+ createRun: async () => 'run-1',
160
+ updateRun: async () => {},
161
+ },
162
+ agentStorage: {
163
+ createThread: async () => {},
164
+ getMessages: async () => [],
165
+ saveMessages: async (threadId: string, messages: any[]) => {
166
+ savedMessages.push(
167
+ ...messages.map((message) => ({ threadId, message }))
168
+ )
169
+ },
170
+ getWorkingMemory: async () => stored,
171
+ saveWorkingMemory: async (
172
+ threadId: string,
173
+ scope: string,
174
+ value: any
175
+ ) => {
176
+ stored = value
177
+ savedWorkingMemory.push({ threadId, scope, value })
178
+ },
179
+ },
180
+ } as any
181
+ }
182
+
183
+ const wireDelegatingPair = (agentMode: 'delegate' | 'supervise') => {
184
+ registerAgent('sub', { instructions: 'sub' })
185
+ registerAgent(
186
+ 'parent',
187
+ {
188
+ instructions: 'parent',
189
+ agentMode,
190
+ memory: { workingMemory: true } as any,
191
+ },
192
+ { agents: ['sub'] }
193
+ )
194
+ }
195
+
196
+ const runParent = async (script?: {
197
+ preHandoff: string
198
+ postHandoff: string
199
+ }) => {
200
+ const savedWorkingMemory: unknown[] = []
201
+ const savedMessages: any[] = []
202
+ pikkuState(
203
+ null,
204
+ 'package',
205
+ 'singletonServices',
206
+ makeServices(delegatingRunner(script), savedWorkingMemory, savedMessages)
207
+ )
208
+ const { channel, events } = recordingChannel('c1')
209
+ const fullText = await streamAgent(
210
+ 'parent',
211
+ { message: 'go', threadId: 't1', resourceId: 'r1' },
212
+ channel,
213
+ {}
214
+ )
215
+ return { savedWorkingMemory, savedMessages, events, fullText }
216
+ }
217
+
218
+ describe('delegate mode and working memory', () => {
219
+ test('collects working memory the parent writes after a hand-off', async () => {
220
+ wireDelegatingPair('delegate')
221
+ const { savedWorkingMemory } = await runParent()
222
+
223
+ assert.deepEqual(
224
+ savedWorkingMemory.map((s: any) => s.value.phase),
225
+ ['two', 'three']
226
+ )
227
+ })
228
+
229
+ test('still collects working memory written before the hand-off', async () => {
230
+ wireDelegatingPair('delegate')
231
+ const { savedWorkingMemory } = await runParent({
232
+ preHandoff: 'Planning. <working_memory>{"phase":"one"}</working_memory>',
233
+ postHandoff: 'Handed off. ',
234
+ })
235
+
236
+ assert.deepEqual(
237
+ savedWorkingMemory.map((s: any) => s.value.phase),
238
+ ['one', 'three']
239
+ )
240
+ })
241
+
242
+ test('the client receives no parent text after the hand-off', async () => {
243
+ wireDelegatingPair('delegate')
244
+ const { events } = await runParent()
245
+
246
+ assert.equal(parentTextOf(events), 'Planning. ')
247
+ })
248
+
249
+ test('the parent assistant text persisted stays suppressed', async () => {
250
+ wireDelegatingPair('delegate')
251
+ const { fullText, savedMessages } = await runParent()
252
+
253
+ assert.equal(fullText, 'Planning. ')
254
+ const assistantText = savedMessages
255
+ .filter(
256
+ ({ threadId, message }: any) =>
257
+ threadId === 't1' && message.role === 'assistant'
258
+ )
259
+ .map(({ message }: any) =>
260
+ typeof message.content === 'string'
261
+ ? message.content
262
+ : (message.content ?? [])
263
+ .filter((part: any) => part.type === 'text')
264
+ .map((part: any) => part.text)
265
+ .join('')
266
+ )
267
+ .join('')
268
+ assert.equal(assistantText, 'Planning. ')
269
+ })
270
+
271
+ test('supervise mode streams every parent delta and collects every update', async () => {
272
+ wireDelegatingPair('supervise')
273
+ const { events, savedWorkingMemory } = await runParent()
274
+
275
+ assert.equal(parentTextOf(events), 'Planning. Handed off. Done. ')
276
+ assert.deepEqual(
277
+ savedWorkingMemory.map((s: any) => s.value.phase),
278
+ ['two', 'three']
279
+ )
280
+ })
281
+
282
+ test('user channel middleware sees exactly the deltas the client sees', async () => {
283
+ wireDelegatingPair('delegate')
284
+ const seen: string[] = []
285
+ const agent = pikkuState(null, 'agent', 'agents').get('parent')!
286
+ agent.channelMiddleware = [
287
+ async (_services: any, event: any, next: any) => {
288
+ if (event.type === 'text-delta') seen.push(event.text)
289
+ await next(event)
290
+ },
291
+ ] as any
292
+ pikkuState(null, 'agent', 'agents').set('parent', agent)
293
+
294
+ const { events } = await runParent({
295
+ preHandoff: 'Planning. <working_memory>{"phase":"one"}</working_memory>',
296
+ postHandoff: 'Handed off. ',
297
+ })
298
+
299
+ const clientDeltas = events
300
+ .filter((e) => e.type === 'text-delta' && !(e as any).agent)
301
+ .map((e) => (e as any).text as string)
302
+
303
+ assert.deepEqual(seen, clientDeltas)
304
+ assert.deepEqual(seen, ['Planning. '])
305
+ })
306
+
307
+ test('the raw text a delegating parent speaks still reaches afterStep', async () => {
308
+ wireDelegatingPair('delegate')
309
+
310
+ const stepTexts: string[] = []
311
+ const agent = pikkuState(null, 'agent', 'agents').get('parent')!
312
+ agent.agentMiddleware = [
313
+ {
314
+ afterStep: async (_services: any, ctx: any) => {
315
+ stepTexts.push(ctx.text)
316
+ },
317
+ },
318
+ ] as any
319
+ pikkuState(null, 'agent', 'agents').set('parent', agent)
320
+
321
+ await runParent()
322
+
323
+ assert.deepEqual(stepTexts, ['Planning. Handed off.', 'Done.'])
324
+ })
325
+ })
326
+
327
+ describe('delegate mode on the resume path', () => {
328
+ test('suppresses parent text after a hand-off and still collects working memory', async () => {
329
+ wireDelegatingPair('delegate')
330
+
331
+ const savedWorkingMemory: unknown[] = []
332
+ const services = makeServices(delegatingRunner(), savedWorkingMemory)
333
+ let pendingApprovals: unknown[] = [
334
+ {
335
+ type: 'tool-call',
336
+ toolCallId: 'tc-1',
337
+ toolName: 'sub',
338
+ args: { message: 'approved hand-off', session: 's0' },
339
+ runId: 'run-1',
340
+ },
341
+ ]
342
+ services.agentRunState = {
343
+ createRun: async () => 'run-1',
344
+ updateRun: async () => {},
345
+ resolveApproval: async () => {
346
+ pendingApprovals = []
347
+ return true
348
+ },
349
+ getRun: async () => ({
350
+ runId: 'run-1',
351
+ id: 'run-1',
352
+ agentName: 'parent',
353
+ threadId: 't1',
354
+ resourceId: 'r1',
355
+ status: 'suspended',
356
+ pendingApprovals,
357
+ messages: [],
358
+ }),
359
+ }
360
+ pikkuState(null, 'package', 'singletonServices', services)
361
+
362
+ const { channel, events } = recordingChannel('c1')
363
+ await resumeAgent(
364
+ { runId: 'run-1', toolCallId: 'tc-1', approved: true },
365
+ channel,
366
+ {
367
+ sessionService: {
368
+ get: () => ({ userId: 'r1' }),
369
+ setInitial: () => {},
370
+ sessionChanged: false,
371
+ },
372
+ } as any
373
+ )
374
+
375
+ assert.equal(parentTextOf(events), 'Planning. ')
376
+ assert.deepEqual(
377
+ savedWorkingMemory.map((s: any) => s.value.phase),
378
+ ['two', 'three']
379
+ )
380
+ })
381
+ })
@@ -20,6 +20,8 @@ import {
20
20
  combineChannelMiddleware,
21
21
  wrapChannelWithMiddleware,
22
22
  } from '../channel/channel-middleware-runner.js'
23
+ import type { CorePikkuChannelMiddleware } from '../channel/channel.types.js'
24
+ import type { CoreSingletonServices } from '../../types/core.types.js'
23
25
  import type { AgentStorageService } from '../../services/agent-storage-service.js'
24
26
  import type {
25
27
  AgentRunnerParams,
@@ -300,6 +302,64 @@ async function postStreamCleanup(
300
302
  })
301
303
  }
302
304
 
305
+ /**
306
+ * Adapts `modifyOutputStream` hooks to channel middleware, one closure per
307
+ * hook so each keeps its own `state` and event log for the whole run.
308
+ */
309
+ const toChannelStreamMiddleware = (
310
+ hooks: PikkuAgentMiddlewareHooks[],
311
+ sharedNotes: Record<string, unknown>,
312
+ signal: AbortSignal
313
+ ) =>
314
+ hooks
315
+ .filter((mw) => mw.modifyOutputStream)
316
+ .map((mw) => {
317
+ const state: Record<string, unknown> = {}
318
+ const allEvents: AgentStreamEvent[] = []
319
+ return async (services: any, event: any, next: any) => {
320
+ allEvents.push(event)
321
+ const result = await mw.modifyOutputStream!(services, {
322
+ event,
323
+ allEvents,
324
+ state,
325
+ shared: sharedNotes,
326
+ // Sends downstream directly, so a hook can hand back the fast event
327
+ // now and push the slow one when it is ready.
328
+ emit: next,
329
+ signal,
330
+ })
331
+ if (result == null) return
332
+ if (Array.isArray(result)) {
333
+ for (const r of result) await next(r)
334
+ } else {
335
+ await next(result)
336
+ }
337
+ }
338
+ })
339
+
340
+ /**
341
+ * The branch a delegating parent's own spoken text is routed down once it has
342
+ * handed off: the same working-memory hook instances as the main chain, so the
343
+ * in-band `<working_memory>` blocks are still collected, ending in a sink so
344
+ * neither the client, the thread history, nor user channel middleware sees
345
+ * text the parent was supposed to keep to itself.
346
+ *
347
+ * Routing at ingress rather than dropping further down the chain keeps the
348
+ * decision synchronous with the send: the working-memory hook is async, so a
349
+ * hand-off landing mid-flight would otherwise retroactively suppress text the
350
+ * parent spoke before it.
351
+ */
352
+ const createWorkingMemoryOnlyChannel = (
353
+ channel: AgentStreamChannel,
354
+ services: CoreSingletonServices,
355
+ workingMemoryMiddleware: readonly CorePikkuChannelMiddleware[]
356
+ ): AgentStreamChannel =>
357
+ wrapChannelWithMiddleware(
358
+ { channel: { ...channel, send: () => {} } as AgentStreamChannel },
359
+ services,
360
+ workingMemoryMiddleware
361
+ ).channel as AgentStreamChannel
362
+
303
363
  type StepLoopParams = {
304
364
  agent: CoreAgent
305
365
  runnerParams: AgentRunnerParams
@@ -720,13 +780,18 @@ export async function streamAgent(
720
780
  return ''
721
781
  }
722
782
 
723
- const agentMiddlewares: PikkuAgentMiddlewareHooks[] = [
724
- ...getWorkingMemoryMiddleware(memoryConfig, storage, {
783
+ const workingMemoryMiddleware = getWorkingMemoryMiddleware(
784
+ memoryConfig,
785
+ storage,
786
+ {
725
787
  threadId,
726
788
  workingMemorySchemaName,
727
789
  logger: singletonServices.logger,
728
790
  schemaService: singletonServices.schema,
729
- }),
791
+ }
792
+ )
793
+ const agentMiddlewares: PikkuAgentMiddlewareHooks[] = [
794
+ ...workingMemoryMiddleware,
730
795
  ...(agent.agentMiddleware ?? []),
731
796
  ]
732
797
 
@@ -785,45 +850,36 @@ export async function streamAgent(
785
850
  singletonServices.logger
786
851
  )
787
852
 
788
- const streamMiddleware = agentMiddlewares
789
- .filter((mw) => mw.modifyOutputStream)
790
- .map((mw) => {
791
- const state: Record<string, unknown> = {}
792
- const allEvents: AgentStreamEvent[] = []
793
- return async (services: any, event: any, next: any) => {
794
- allEvents.push(event)
795
- const result = await mw.modifyOutputStream!(services, {
796
- event,
797
- allEvents,
798
- state,
799
- shared: sharedNotes,
800
- // Sends downstream directly, so a hook can hand back the fast event
801
- // now and push the slow one when it is ready.
802
- emit: next,
803
- signal: interruptHandle.signal,
804
- })
805
- if (result == null) return
806
- if (Array.isArray(result)) {
807
- for (const r of result) await next(r)
808
- } else {
809
- await next(result)
810
- }
811
- }
812
- })
853
+ const workingMemoryStreamMiddleware = toChannelStreamMiddleware(
854
+ workingMemoryMiddleware,
855
+ sharedNotes,
856
+ interruptHandle.signal
857
+ )
858
+ const streamMiddleware = toChannelStreamMiddleware(
859
+ agent.agentMiddleware ?? [],
860
+ sharedNotes,
861
+ interruptHandle.signal
862
+ )
813
863
 
814
864
  const agentsMeta = pikkuState(packageName, 'agent', 'agentsMeta')
815
865
  const meta = agentsMeta[resolvedName]
816
- const allChannelMiddleware = combineChannelMiddleware(
817
- 'agent',
818
- `stream:${agentName}`,
819
- {
866
+
867
+ const isDelegateMode = agent.agentMode !== 'supervise' && meta?.agents?.length
868
+ const delegateState = { delegated: false }
869
+ if (isDelegateMode) {
870
+ streamContext.delegateState = delegateState
871
+ }
872
+
873
+ const allChannelMiddleware = [
874
+ ...workingMemoryStreamMiddleware,
875
+ ...combineChannelMiddleware('agent', `stream:${agentName}`, {
820
876
  wireInheritedChannelMiddleware: meta?.channelMiddleware,
821
877
  wireChannelMiddleware: [
822
878
  ...(agent.channelMiddleware ?? []),
823
879
  ...streamMiddleware,
824
880
  ],
825
- }
826
- )
881
+ }),
882
+ ]
827
883
 
828
884
  const persistingChannel = createPersistingChannel(
829
885
  channel,
@@ -857,23 +913,27 @@ export async function streamAgent(
857
913
  },
858
914
  }
859
915
 
860
- const isDelegateMode = agent.agentMode !== 'supervise' && meta?.agents?.length
861
- const delegateState = { delegated: false }
862
- if (isDelegateMode) {
863
- streamContext.delegateState = delegateState
864
- }
865
- const outputChannel = isDelegateMode
916
+ const workingMemoryOnlyChannel =
917
+ isDelegateMode && workingMemoryStreamMiddleware.length > 0
918
+ ? createWorkingMemoryOnlyChannel(
919
+ channel,
920
+ singletonServices,
921
+ workingMemoryStreamMiddleware
922
+ )
923
+ : undefined
924
+
925
+ const outputChannel: AgentStreamChannel = isDelegateMode
866
926
  ? {
867
927
  ...credentialFilteredChannel,
868
928
  send: (event: AgentStreamEvent) => {
869
929
  if (
870
930
  delegateState.delegated &&
871
931
  (event.type === 'text-delta' || event.type === 'reasoning-delta')
872
- )
873
- return
932
+ ) {
933
+ return workingMemoryOnlyChannel?.send(event)
934
+ }
874
935
  return credentialFilteredChannel.send(event)
875
936
  },
876
- delegateState,
877
937
  }
878
938
  : credentialFilteredChannel
879
939
 
@@ -1350,13 +1410,18 @@ async function continueAfterToolResult(
1350
1410
 
1351
1411
  const instructions = await buildInstructions(resolvedName, packageName)
1352
1412
 
1353
- const agentMiddlewares: PikkuAgentMiddlewareHooks[] = [
1354
- ...getWorkingMemoryMiddleware(memoryConfig, storage, {
1413
+ const workingMemoryMiddleware = getWorkingMemoryMiddleware(
1414
+ memoryConfig,
1415
+ storage,
1416
+ {
1355
1417
  threadId: run.threadId,
1356
1418
  workingMemorySchemaName,
1357
1419
  logger: singletonServices.logger,
1358
1420
  schemaService: singletonServices.schema,
1359
- }),
1421
+ }
1422
+ )
1423
+ const agentMiddlewares: PikkuAgentMiddlewareHooks[] = [
1424
+ ...workingMemoryMiddleware,
1360
1425
  ...(agent.agentMiddleware ?? []),
1361
1426
  ]
1362
1427
  // One bag per run, shared by every middleware — see PikkuAgentMiddlewareHooks.
@@ -1379,43 +1444,27 @@ async function continueAfterToolResult(
1379
1444
  singletonServices.logger
1380
1445
  )
1381
1446
 
1382
- const streamMiddleware = agentMiddlewares
1383
- .filter((mw) => mw.modifyOutputStream)
1384
- .map((mw) => {
1385
- const state: Record<string, unknown> = {}
1386
- const allEvents: AgentStreamEvent[] = []
1387
- return async (services: any, event: any, next: any) => {
1388
- allEvents.push(event)
1389
- const result = await mw.modifyOutputStream!(services, {
1390
- event,
1391
- allEvents,
1392
- state,
1393
- shared: sharedNotes,
1394
- // Sends downstream directly, so a hook can hand back the fast event
1395
- // now and push the slow one when it is ready.
1396
- emit: next,
1397
- signal: interruptHandle.signal,
1398
- })
1399
- if (result == null) return
1400
- if (Array.isArray(result)) {
1401
- for (const r of result) await next(r)
1402
- } else {
1403
- await next(result)
1404
- }
1405
- }
1406
- })
1447
+ const workingMemoryStreamMiddleware = toChannelStreamMiddleware(
1448
+ workingMemoryMiddleware,
1449
+ sharedNotes,
1450
+ interruptHandle.signal
1451
+ )
1452
+ const streamMiddleware = toChannelStreamMiddleware(
1453
+ agent.agentMiddleware ?? [],
1454
+ sharedNotes,
1455
+ interruptHandle.signal
1456
+ )
1407
1457
 
1408
- const allChannelMiddleware = combineChannelMiddleware(
1409
- 'agent',
1410
- `stream:${run.agentName}`,
1411
- {
1458
+ const allChannelMiddleware = [
1459
+ ...workingMemoryStreamMiddleware,
1460
+ ...combineChannelMiddleware('agent', `stream:${run.agentName}`, {
1412
1461
  wireInheritedChannelMiddleware: meta?.channelMiddleware,
1413
1462
  wireChannelMiddleware: [
1414
1463
  ...(agent.channelMiddleware ?? []),
1415
1464
  ...streamMiddleware,
1416
1465
  ],
1417
- }
1418
- )
1466
+ }),
1467
+ ]
1419
1468
 
1420
1469
  const persistingChannel = createPersistingChannel(
1421
1470
  channel,
@@ -1433,7 +1482,38 @@ async function continueAfterToolResult(
1433
1482
  ).channel as AgentStreamChannel)
1434
1483
  : persistingChannel
1435
1484
 
1485
+ const isDelegateMode = agent.agentMode !== 'supervise' && meta?.agents?.length
1486
+ const delegateState = { delegated: false }
1487
+
1436
1488
  const streamContext: StreamContext = { channel, options }
1489
+ if (isDelegateMode) {
1490
+ streamContext.delegateState = delegateState
1491
+ }
1492
+
1493
+ const workingMemoryOnlyChannel =
1494
+ isDelegateMode && workingMemoryStreamMiddleware.length > 0
1495
+ ? createWorkingMemoryOnlyChannel(
1496
+ channel,
1497
+ singletonServices,
1498
+ workingMemoryStreamMiddleware
1499
+ )
1500
+ : undefined
1501
+
1502
+ const outputChannel: AgentStreamChannel = isDelegateMode
1503
+ ? {
1504
+ ...wrappedChannel,
1505
+ send: (event: AgentStreamEvent) => {
1506
+ if (
1507
+ delegateState.delegated &&
1508
+ (event.type === 'text-delta' || event.type === 'reasoning-delta')
1509
+ ) {
1510
+ return workingMemoryOnlyChannel?.send(event)
1511
+ }
1512
+ return wrappedChannel.send(event)
1513
+ },
1514
+ }
1515
+ : wrappedChannel
1516
+
1437
1517
  const resumeTools = (
1438
1518
  await buildToolDefs(
1439
1519
  params,
@@ -1472,7 +1552,7 @@ async function continueAfterToolResult(
1472
1552
  runnerParams,
1473
1553
  maxSteps,
1474
1554
  agentRunner,
1475
- streamChannel: wrappedChannel,
1555
+ streamChannel: outputChannel,
1476
1556
  persistingChannel,
1477
1557
  channel,
1478
1558
  agentMiddlewares,