Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 53 additions & 11 deletions src/agent/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,9 +469,8 @@ describe('Agent', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

// Create agent with custom printer for testing
const agent = new Agent({ model, printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
// Create agent with custom printer plugin for testing
const agent = new Agent({ model, printer: false, plugins: [new AgentPrinter(mockAppender)] })

await collectGenerator(agent.stream('Test'))

Expand All @@ -480,20 +479,63 @@ describe('Agent', () => {
expect(allOutput).toContain('Hello world')
})

it('does not create printer when printer is false', () => {
it('does not create printer when printer is false', async () => {
const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' })
const agent = new Agent({ model, printer: false })

expect(agent).toBeDefined()
expect((agent as any)._printer).toBeUndefined()
// Capture any output that would happen if printer was active.
// Mock both process.stdout (Node) and console.log (browser) since the printer uses whichever is available.
const outputs: string[] = []
// @ts-expect-error -- process is typed as always-defined in Node types, but this check is needed for browser compatibility
const stdoutSpy = globalThis.process?.stdout?.write
? vi.spyOn(process.stdout, 'write').mockImplementation((text) => {
outputs.push(String(text))
return true
})
: null
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((text) => {
outputs.push(String(text))
})

try {
const agent = new Agent({ model, printer: false })
await collectGenerator(agent.stream('Test'))

// With printer disabled, no text should be output to stdout
expect(outputs.filter((o) => o.includes('Hello'))).toEqual([])
} finally {
stdoutSpy?.mockRestore()
consoleSpy.mockRestore()
}
})

it('defaults to printer=true when not specified', () => {
it('defaults to printer=true when not specified', async () => {
const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' })
const agent = new Agent({ model })

expect(agent).toBeDefined()
expect((agent as any)._printer).toBeDefined()
// Capture output to verify printer is active by default.
// Mock both process.stdout (Node) and console.log (browser) since the printer uses whichever is available.
const outputs: string[] = []
// @ts-expect-error -- process is typed as always-defined in Node types, but this check is needed for browser compatibility
const stdoutSpy = globalThis.process?.stdout?.write
? vi.spyOn(process.stdout, 'write').mockImplementation((text) => {
outputs.push(String(text))
return true
})
: null
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((text) => {
outputs.push(String(text))
})

try {
const agent = new Agent({ model })
await collectGenerator(agent.stream('Test'))

// With default printer enabled, text should be output
const allOutput = outputs.join('')
expect(allOutput).toContain('Hello')
} finally {
stdoutSpy?.mockRestore()
consoleSpy.mockRestore()
}
})

it('agent works correctly with printer disabled', async () => {
Expand Down
26 changes: 12 additions & 14 deletions src/agent/__tests__/printer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ describe('AgentPrinter', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

const agent = new Agent({ model, printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
const agent = new Agent({ model, printer: false, plugins: [new AgentPrinter(mockAppender)] })

await collectGenerator(agent.stream('Test'))

Expand All @@ -29,8 +28,7 @@ describe('AgentPrinter', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

const agent = new Agent({ model, printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
const agent = new Agent({ model, printer: false, plugins: [new AgentPrinter(mockAppender)] })

await collectGenerator(agent.stream('Test'))

Expand All @@ -47,8 +45,7 @@ describe('AgentPrinter', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

const agent = new Agent({ model, printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
const agent = new Agent({ model, printer: false, plugins: [new AgentPrinter(mockAppender)] })

await collectGenerator(agent.stream('Test'))

Expand All @@ -65,8 +62,7 @@ describe('AgentPrinter', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

const agent = new Agent({ model, printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
const agent = new Agent({ model, printer: false, plugins: [new AgentPrinter(mockAppender)] })

await collectGenerator(agent.stream('Test'))

Expand Down Expand Up @@ -98,8 +94,7 @@ describe('AgentPrinter', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

const agent = new Agent({ model, tools: [tool], printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
const agent = new Agent({ model, tools: [tool], printer: false, plugins: [new AgentPrinter(mockAppender)] })

await collectGenerator(agent.stream('Test'))

Expand All @@ -125,8 +120,7 @@ describe('AgentPrinter', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

const agent = new Agent({ model, tools: [tool], printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
const agent = new Agent({ model, tools: [tool], printer: false, plugins: [new AgentPrinter(mockAppender)] })

await collectGenerator(agent.stream('Test'))

Expand Down Expand Up @@ -174,8 +168,12 @@ describe('AgentPrinter', () => {
const outputs: string[] = []
const mockAppender = (text: string) => outputs.push(text)

const agent = new Agent({ model, tools: [calcTool, validatorTool], printer: false })
;(agent as any)._printer = new AgentPrinter(mockAppender)
const agent = new Agent({
model,
tools: [calcTool, validatorTool],
printer: false,
plugins: [new AgentPrinter(mockAppender)],
})

await collectGenerator(agent.stream('Test'))

Expand Down
15 changes: 5 additions & 10 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type { BaseModelConfig, StreamAggregatedResult, StreamOptions } from '../
import { isModelStreamEvent } from '../models/streaming.js'
import { ToolRegistry } from '../registry/tool-registry.js'
import { StateStore } from '../state-store.js'
import { AgentPrinter, getDefaultAppender, type Printer } from './printer.js'
import { AgentPrinter, getDefaultAppender } from './printer.js'
import type { Plugin } from '../plugins/plugin.js'
import { PluginRegistry } from '../plugins/registry.js'
import { SlidingWindowConversationManager } from '../conversation-manager/sliding-window-conversation-manager.js'
Expand Down Expand Up @@ -209,7 +209,6 @@ export class Agent implements LocalAgent, InvokableAgent {
private _mcpClients: McpClient[]
private _initialized: boolean
private _isInvoking: boolean = false
private _printer?: Printer
private _structuredOutputSchema?: z.ZodSchema | undefined
/** Tracer instance for creating and managing OpenTelemetry spans. */
private _tracer: Tracer
Expand Down Expand Up @@ -242,23 +241,21 @@ export class Agent implements LocalAgent, InvokableAgent {
// Initialize hooks registry
this._hooksRegistry = new HookRegistryImplementation()

// Create printer plugin if enabled (default: true)
const printerPlugin = (config?.printer ?? true) ? new AgentPrinter(getDefaultAppender()) : null

// Initialize plugin registry with all plugins to be initialized during initialize()
this._pluginRegistry = new PluginRegistry([
this._conversationManager,
...(config?.plugins ?? []),
...(config?.sessionManager ? [config.sessionManager] : []),
...(printerPlugin ? [printerPlugin] : []),
])

if (config?.systemPrompt !== undefined) {
this.systemPrompt = systemPromptFromData(config.systemPrompt)
}

// Create printer if printer is enabled (default: true)
const printer = config?.printer ?? true
if (printer) {
this._printer = new AgentPrinter(getDefaultAppender())
}

// Store structured output schema
this._structuredOutputSchema = config?.structuredOutputSchema

Expand Down Expand Up @@ -425,15 +422,13 @@ export class Agent implements LocalAgent, InvokableAgent {
await this._hooksRegistry.invokeCallbacks(event)
}

this._printer?.processEvent(event)
yield event
result = await streamGenerator.next()
}

// Yield final result as last event
const agentResultEvent = new AgentResultEvent({ agent: this, result: result.value })
await this._hooksRegistry.invokeCallbacks(agentResultEvent)
this._printer?.processEvent(agentResultEvent)
yield agentResultEvent

return result.value
Expand Down
77 changes: 31 additions & 46 deletions src/agent/printer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { AgentStreamEvent } from '../types/agent.js'
import type {
ModelStreamEvent,
ModelContentBlockDeltaEventData,
ModelContentBlockStartEventData,
} from '../models/streaming.js'
import type { ToolResultEvent } from '../hooks/events.js'
import type { ToolResultBlock } from '../types/messages.js'
import type { Plugin } from '../plugins/plugin.js'
import type { LocalAgent } from '../types/agent.js'
import { ModelStreamUpdateEvent, ToolResultEvent } from '../hooks/events.js'

/**
* Creates a default appender function for the current environment.
Expand All @@ -21,28 +23,15 @@ export function getDefaultAppender(): (text: string) => void {
}

/**
* Interface for printing agent activity to a destination.
* Implementations can output to stdout, console, HTML elements, etc.
*/
export interface Printer {
/**
* Write content to the output destination.
* @param content - The content to write
*/
write(content: string): void

/**
* Process a streaming event from the agent.
* @param event - The event to process
*/
processEvent(event: AgentStreamEvent): void
}

/**
* Default implementation of the Printer interface.
* Plugin for printing agent activity to a destination.
* Outputs text, reasoning, and tool execution activity to the configured appender.
*
* As a Plugin, it registers callbacks for:
* - ModelStreamUpdateEvent: Handles streaming text and reasoning output
* - ToolResultEvent: Handles tool completion status output
*/
export class AgentPrinter implements Printer {
export class AgentPrinter implements Plugin {
readonly name = 'strands:printer'
private readonly _appender: (text: string) => void
private _inReasoningBlock: boolean = false
private _toolCount: number = 0
Expand All @@ -57,32 +46,28 @@ export class AgentPrinter implements Printer {
}

/**
* Write content to the output destination.
* @param content - The content to write
* Initialize the printer plugin by registering event callbacks.
* Called automatically when the agent initializes.
* @param agent - The agent to register callbacks with
*/
public write(content: string): void {
this._appender(content)
public initAgent(agent: LocalAgent): void {
// Register callback for model stream events (text and reasoning)
agent.addHook(ModelStreamUpdateEvent, (event: ModelStreamUpdateEvent) => {
this.handleModelStreamEvent(event.event)
})

// Register callback for tool result events
agent.addHook(ToolResultEvent, (event: ToolResultEvent) => {
this.handleToolResult(event.result)
})
}

/**
* Process a streaming event from the agent.
* Handles text deltas, reasoning content, and tool execution events.
* @param event - The event to process
* Write content to the output destination.
* @param content - The content to write
*/
public processEvent(event: AgentStreamEvent): void {
switch (event.type) {
case 'modelStreamUpdateEvent':
this.handleModelStreamEvent(event.event)
break

case 'toolResultEvent':
this.handleToolResult(event)
break

// Ignore other event types
default:
break
}
public write(content: string): void {
this._appender(content)
}

/**
Expand Down Expand Up @@ -189,10 +174,10 @@ export class AgentPrinter implements Printer {
* Handle tool result events.
* Outputs completion status.
*/
private handleToolResult(event: ToolResultEvent): void {
if (event.result.status === 'success') {
private handleToolResult(result: ToolResultBlock): void {
if (result.status === 'success') {
this.write('✓ Tool completed\n')
} else if (event.result.status === 'error') {
} else if (result.status === 'error') {
this.write('✗ Tool failed\n')
}
}
Expand Down