Skip to content

Commit ad0061c

Browse files
authored
fix(multiagent): buffer graph node printer output to prevent interleaving (strands-agents#3077)
1 parent f5fb6f6 commit ad0061c

4 files changed

Lines changed: 178 additions & 1 deletion

File tree

strands-ts/src/multiagent/__tests__/graph.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, vi } from 'vitest'
22
import { Agent } from '../../agent/agent.js'
3+
import { AgentPrinter } from '../../agent/printer.js'
34
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
45
import { MockSnapshotStorage } from '../../__fixtures__/mock-storage-provider.js'
56
import { collectGenerator } from '../../__fixtures__/model-test-helpers.js'
@@ -716,6 +717,106 @@ describe('Graph', () => {
716717
)
717718
})
718719

720+
it('does not buffer agent printer output for sequential graphs', async () => {
721+
const a = makeAgent('a', 'a-reply')
722+
const b = makeAgent('b', 'b-reply')
723+
const printerA = new AgentPrinter(() => {})
724+
const printerB = new AgentPrinter(() => {})
725+
;(a as unknown as { _printer: AgentPrinter })._printer = printerA
726+
;(b as unknown as { _printer: AgentPrinter })._printer = printerB
727+
728+
const graph = new Graph({
729+
nodes: [a, b],
730+
edges: [['a', 'b']],
731+
})
732+
733+
const seenA: unknown[] = []
734+
const seenB: unknown[] = []
735+
const gen = graph.stream('go')
736+
for await (const event of gen) {
737+
if (event.type === 'nodeStreamUpdateEvent') {
738+
if (event.nodeId === 'a') seenA.push((a as unknown as { _printer: AgentPrinter })._printer)
739+
if (event.nodeId === 'b') seenB.push((b as unknown as { _printer: AgentPrinter })._printer)
740+
}
741+
}
742+
743+
expect(seenA.length).toBeGreaterThan(0)
744+
expect(seenB.length).toBeGreaterThan(0)
745+
expect(seenA.every((p) => p === printerA)).toBe(true)
746+
expect(seenB.every((p) => p === printerB)).toBe(true)
747+
})
748+
749+
it('buffers agent printer output when topology allows parallel execution', async () => {
750+
const a = makeAgent('a', 'a-reply')
751+
const b = makeAgent('b', 'b-reply')
752+
const printerA = new AgentPrinter(() => {})
753+
const printerB = new AgentPrinter(() => {})
754+
;(a as unknown as { _printer: AgentPrinter })._printer = printerA
755+
;(b as unknown as { _printer: AgentPrinter })._printer = printerB
756+
757+
// Two sources with no edges between them — both can run concurrently.
758+
const graph = new Graph({
759+
nodes: [a, b],
760+
edges: [],
761+
})
762+
763+
const seenA: unknown[] = []
764+
const seenB: unknown[] = []
765+
const gen = graph.stream('go')
766+
for await (const event of gen) {
767+
if (event.type === 'nodeStreamUpdateEvent') {
768+
if (event.nodeId === 'a') seenA.push((a as unknown as { _printer: AgentPrinter })._printer)
769+
if (event.nodeId === 'b') seenB.push((b as unknown as { _printer: AgentPrinter })._printer)
770+
}
771+
}
772+
773+
expect(seenA.some((p) => p !== printerA)).toBe(true)
774+
expect(seenB.some((p) => p !== printerB)).toBe(true)
775+
expect((a as unknown as { _printer: AgentPrinter })._printer).toBe(printerA)
776+
expect((b as unknown as { _printer: AgentPrinter })._printer).toBe(printerB)
777+
})
778+
779+
it('does not interleave output from parallel nodes on a shared writer', async () => {
780+
// Fan-out graph (A -> B, A -> C). B and C run in parallel after A completes.
781+
// Each emits several TextBlocks so their printer writes are naturally
782+
// interleavable; the buffering behavior guarantees each node's writes
783+
// land as a contiguous run on the shared writer.
784+
const a = makeAgent('a', 'a-reply')
785+
const b = new Agent({
786+
model: new MockMessageModel().addTurn(['b-1 ', 'b-2 ', 'b-3'].map((text) => new TextBlock(text))),
787+
printer: false,
788+
id: 'b',
789+
})
790+
const c = new Agent({
791+
model: new MockMessageModel().addTurn(['c-1 ', 'c-2 ', 'c-3'].map((text) => new TextBlock(text))),
792+
printer: false,
793+
id: 'c',
794+
})
795+
796+
const writes: string[] = []
797+
;(b as unknown as { _printer: AgentPrinter })._printer = new AgentPrinter((text) => writes.push('b:' + text))
798+
;(c as unknown as { _printer: AgentPrinter })._printer = new AgentPrinter((text) => writes.push('c:' + text))
799+
800+
const graph = new Graph({
801+
nodes: [a, b, c],
802+
edges: [
803+
['a', 'b'],
804+
['a', 'c'],
805+
],
806+
})
807+
808+
await graph.invoke('go')
809+
810+
// Each node's writes form one contiguous slice — no other node's writes appear between them.
811+
const bIndices = writes.map((w, i) => (w.startsWith('b:') ? i : -1)).filter((i) => i !== -1)
812+
const cIndices = writes.map((w, i) => (w.startsWith('c:') ? i : -1)).filter((i) => i !== -1)
813+
expect(bIndices.length).toBeGreaterThan(0)
814+
expect(cIndices.length).toBeGreaterThan(0)
815+
const isContiguous = (indices: number[]): boolean => indices.every((v, i) => i === 0 || v === indices[i - 1]! + 1)
816+
expect(isContiguous(bIndices)).toBe(true)
817+
expect(isContiguous(cIndices)).toBe(true)
818+
})
819+
719820
it('cleans up running nodes when consumer breaks mid-stream', async () => {
720821
const graph = new Graph({
721822
nodes: [makeAgent('a', 'a-reply'), makeAgent('b', 'b-reply')],

strands-ts/src/multiagent/__tests__/nodes.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it } from 'vitest'
22
import { z } from 'zod'
33
import { Agent } from '../../agent/agent.js'
4+
import { AgentPrinter } from '../../agent/printer.js'
45
import { BeforeInvocationEvent } from '../../hooks/events.js'
56
import type { MultiAgentInput } from '../multiagent.js'
67
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
@@ -233,6 +234,40 @@ describe('AgentNode', () => {
233234
expect(preserveContextAgent.appState.get<{ count: number }>('count')).toBe(2)
234235
})
235236

237+
it('leaves the original printer in place when bufferOutput is not set', async () => {
238+
const writes: string[] = []
239+
const printer = new AgentPrinter((text) => writes.push(text))
240+
;(agent as unknown as { _printer: AgentPrinter })._printer = printer
241+
242+
// Capture the active printer at every stream event — confirms it never gets
243+
// swapped for a buffering printer mid-stream on the sequential path.
244+
const seenPrinters: unknown[] = []
245+
const gen = node.stream([new TextBlock('prompt')], state)
246+
for await (const _event of gen) {
247+
seenPrinters.push((agent as unknown as { _printer: AgentPrinter })._printer)
248+
}
249+
250+
expect(seenPrinters.every((p) => p === printer)).toBe(true)
251+
expect(writes.join('')).toContain('reply')
252+
})
253+
254+
it('buffers printer output when bufferOutput is true and flushes on completion', async () => {
255+
const writes: string[] = []
256+
const printer = new AgentPrinter((text) => writes.push(text))
257+
;(agent as unknown as { _printer: AgentPrinter })._printer = printer
258+
259+
// While streaming, a buffering printer is installed in place of the original.
260+
const printerDuringStream: unknown[] = []
261+
const gen = node.stream([new TextBlock('prompt')], state, { bufferOutput: true })
262+
for await (const _event of gen) {
263+
printerDuringStream.push((agent as unknown as { _printer: AgentPrinter })._printer)
264+
}
265+
266+
expect(printerDuringStream.some((p) => p !== printer)).toBe(true)
267+
expect((agent as unknown as { _printer: AgentPrinter })._printer).toBe(printer)
268+
expect(writes.join('')).toContain('reply')
269+
})
270+
236271
it('passes structuredOutputSchema from options to the agent', async () => {
237272
const schema = z.object({ agentName: z.string().optional(), message: z.string() })
238273

strands-ts/src/multiagent/graph.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ export class Graph implements MultiAgent {
136136
private readonly _pluginRegistry: MultiAgentPluginRegistry
137137
private readonly _hookRegistry: HookRegistryImplementation
138138
private readonly _sources: Node[]
139+
/** Whether the topology can ever run two nodes in parallel. Gates per-node printer buffering. */
140+
private readonly _canRunConcurrently: boolean
139141
private readonly _tracer: Tracer
140142
readonly sessionManager?: SessionManager | undefined
141143
private _initialized: boolean
@@ -168,6 +170,7 @@ export class Graph implements MultiAgent {
168170
this.edges = this._resolveEdges(edges)
169171
this._sources = this._resolveSources(sources)
170172
this._validateSources()
173+
this._canRunConcurrently = this._computeCanRunConcurrently()
171174

172175
this.sessionManager = sessionManager
173176

@@ -525,7 +528,11 @@ export class Graph implements MultiAgent {
525528

526529
try {
527530
const gen = this._tracer.withSpanContext(nodeSpan, () =>
528-
node.stream(input, state, { invocationState, ...(cancelSignal && { cancelSignal }) })
531+
node.stream(input, state, {
532+
invocationState,
533+
...(cancelSignal && { cancelSignal }),
534+
...(this._canRunConcurrently && { bufferOutput: true }),
535+
})
529536
)
530537
let next = await this._tracer.withSpanContext(nodeSpan, () => gen.next())
531538
while (!next.done) {
@@ -682,6 +689,18 @@ export class Graph implements MultiAgent {
682689
return [...this.nodes.values()].filter((node) => !targetIds.has(node.id))
683690
}
684691

692+
/**
693+
* Static check: can two nodes ever run in parallel? False when `maxConcurrency` is 1,
694+
* there's a single source, and no node has more than one outgoing edge.
695+
*/
696+
private _computeCanRunConcurrently(): boolean {
697+
if (this.config.maxConcurrency < 2) return false
698+
if (this._sources.length > 1) return true
699+
const fanOut = new Map<string, number>()
700+
for (const e of this.edges) fanOut.set(e.source.id, (fanOut.get(e.source.id) ?? 0) + 1)
701+
return [...fanOut.values()].some((count) => count > 1)
702+
}
703+
685704
/**
686705
* Identifies terminus nodes and returns their combined content.
687706
* A terminus node is where an execution path ended: completed with no

strands-ts/src/multiagent/nodes.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Agent } from '../agent/agent.js'
2+
import { AgentPrinter, type Printer } from '../agent/printer.js'
23
import type { InvocationState, InvokeOptions, InvokableAgent, AgentStreamEvent } from '../types/agent.js'
34
import type { MultiAgentInput } from './multiagent.js'
45
import { dropStaleInterruptedResult } from './multiagent.js'
@@ -48,6 +49,12 @@ export interface NodeInputOptions {
4849
* orchestrators to enforce per-node timeouts or propagate external cancellation.
4950
*/
5051
cancelSignal?: AbortSignal
52+
53+
/**
54+
* Buffer the agent's printer output and flush it on completion so concurrent siblings
55+
* don't interleave on stdout.
56+
*/
57+
bufferOutput?: boolean
5158
}
5259

5360
/**
@@ -271,6 +278,17 @@ export class AgentNode extends Node {
271278
this._agent.loadSnapshot(nodeState.interruptedSnapshot)
272279
}
273280

281+
// Swap the agent's printer for a buffer so concurrent siblings don't interleave on stdout.
282+
const agentInternals =
283+
options?.bufferOutput && isAgent ? (this._agent as unknown as { _printer?: Printer }) : undefined
284+
const originalPrinter = agentInternals?._printer
285+
let buffered = ''
286+
if (agentInternals && originalPrinter) {
287+
agentInternals._printer = new AgentPrinter((text) => {
288+
buffered += text
289+
})
290+
}
291+
274292
try {
275293
const invokeOptions: InvokeOptions = {
276294
...(options?.structuredOutputSchema && { structuredOutputSchema: options.structuredOutputSchema }),
@@ -314,6 +332,10 @@ export class AgentNode extends Node {
314332
if (preRunSnapshot) {
315333
;(this._agent as Agent).loadSnapshot(preRunSnapshot)
316334
}
335+
if (agentInternals && originalPrinter) {
336+
agentInternals._printer = originalPrinter
337+
if (buffered.length > 0) originalPrinter.write(buffered)
338+
}
317339
}
318340
}
319341
}

0 commit comments

Comments
 (0)