diff --git a/src/agent/__tests__/agent.test.ts b/src/agent/__tests__/agent.test.ts index a07ecfa2..6a0d1da4 100644 --- a/src/agent/__tests__/agent.test.ts +++ b/src/agent/__tests__/agent.test.ts @@ -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')) @@ -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 () => { diff --git a/src/agent/__tests__/printer.test.ts b/src/agent/__tests__/printer.test.ts index f2c40c9c..02dc6ee8 100644 --- a/src/agent/__tests__/printer.test.ts +++ b/src/agent/__tests__/printer.test.ts @@ -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')) @@ -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')) @@ -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')) @@ -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')) @@ -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')) @@ -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')) @@ -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')) diff --git a/src/agent/agent.ts b/src/agent/agent.ts index cbfa95d7..c0a37e2b 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -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' @@ -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 @@ -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 @@ -425,7 +422,6 @@ export class Agent implements LocalAgent, InvokableAgent { await this._hooksRegistry.invokeCallbacks(event) } - this._printer?.processEvent(event) yield event result = await streamGenerator.next() } @@ -433,7 +429,6 @@ export class Agent implements LocalAgent, InvokableAgent { // 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 diff --git a/src/agent/printer.ts b/src/agent/printer.ts index dc7cfd97..9af6eb2e 100644 --- a/src/agent/printer.ts +++ b/src/agent/printer.ts @@ -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. @@ -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 @@ -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) } /** @@ -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') } }