Skip to content
Merged
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
37 changes: 37 additions & 0 deletions src/__fixtures__/agent-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Test fixtures and helpers for Agent testing.
* This module provides utilities for testing Agent-related implementations.
*/

import type { Agent } from '../agent/agent.js'
import type { Message } from '../types/messages.js'
import { AgentState } from '../agent/state.js'
import type { JSONValue } from '../types/json.js'

/**
* Data for creating a mock Agent.
*/
export interface MockAgentData {
/**
* Messages for the agent.
*/
messages?: Message[]
/**
* Initial state for the agent.
*/
state?: Record<string, JSONValue>
}

/**
* Helper to create a mock Agent for testing.
* Provides minimal Agent interface with messages and state.
*
* @param data - Optional mock agent data
* @returns Mock Agent object
*/
export function createMockAgent(data?: MockAgentData): Agent {
return {
messages: data?.messages ?? [],
state: new AgentState(data?.state ?? {}),
} as unknown as Agent
}
1 change: 1 addition & 0 deletions src/__fixtures__/tool-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function createMockContext(
toolUse,
agent: {
state: new AgentState(agentState),
messages: [],
},
}
}
Expand Down
34 changes: 34 additions & 0 deletions src/agent/__tests__/agent.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BeforeToolCallEvent,
MessageAddedEvent,
ModelStreamEventHook,
type HookRegistry,
} from '../../hooks/index.js'
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
import { MockHookProvider } from '../../__fixtures__/mock-hook-provider.js'
Expand Down Expand Up @@ -301,4 +302,37 @@ describe('Agent Hooks Integration', () => {
)
})
})

describe('AfterModelCallEvent retryModelCall', () => {
it('retries model call when hook sets retryModelCall', async () => {
let callCount = 0
const retryHook = {
registerCallbacks: (registry: HookRegistry) => {
registry.addCallback(AfterModelCallEvent, (event: AfterModelCallEvent) => {
callCount++
if (callCount === 1 && event.error) {
event.retryModelCall = true
}
})
},
}

const model = new MockMessageModel()
.addTurn(new Error('First attempt failed'))
.addTurn({ type: 'textBlock', text: 'Success after retry' })

const agent = new Agent({ model, hooks: [retryHook] })
const result = await agent.invoke('Test')

expect(result.lastMessage.content[0]).toEqual({ type: 'textBlock', text: 'Success after retry' })
expect(callCount).toBe(2)
})

it('does not retry when retryModelCall is not set', async () => {
const model = new MockMessageModel().addTurn(new Error('Failure'))
const agent = new Agent({ model })

await expect(agent.invoke('Test')).rejects.toThrow('Failure')
})
})
})
25 changes: 12 additions & 13 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ import {
ToolResultBlock,
type ToolUseBlock,
} from '../index.js'
import { normalizeError, ConcurrentInvocationError, MaxTokensError, ContextWindowOverflowError } from '../errors.js'
import { normalizeError, ConcurrentInvocationError, MaxTokensError } from '../errors.js'
import type { BaseModelConfig, Model, StreamOptions } from '../models/model.js'
import { ToolRegistry } from '../registry/tool-registry.js'
import { AgentState } from './state.js'
import type { AgentData } from '../types/agent.js'
import { AgentPrinter, getDefaultAppender, type Printer } from './printer.js'
import type { ConversationManager } from '../conversation-manager/conversation-manager.js'
import type { HookProvider } from '../hooks/types.js'
import { SlidingWindowConversationManager } from '../conversation-manager/sliding-window-conversation-manager.js'
import { HookRegistryImplementation } from '../hooks/registry.js'
import type { HookProvider } from '../hooks/types.js'
import {
AfterInvocationEvent,
AfterModelCallEvent,
Expand Down Expand Up @@ -74,7 +73,7 @@ export type AgentConfig = {
* Conversation manager for handling message history and context overflow.
* Defaults to SlidingWindowConversationManager with windowSize of 40.
*/
conversationManager?: ConversationManager
conversationManager?: HookProvider
/**
* Hook providers to register with the agent.
* Hooks enable observing and extending agent behavior.
Expand Down Expand Up @@ -107,7 +106,7 @@ export class Agent implements AgentData {
/**
* Conversation manager for handling message history and context overflow.
*/
public readonly conversationManager: ConversationManager
public readonly conversationManager: HookProvider

private _isInvoking: boolean = false
private _printer?: Printer
Expand Down Expand Up @@ -140,10 +139,12 @@ export class Agent implements AgentData {

this.state = new AgentState(config?.state)

// Initialize conversation manager
this.conversationManager = config?.conversationManager ?? new SlidingWindowConversationManager({ windowSize: 40 })

// Initialize hooks
// Initialize hooks and register conversation manager hooks
this.hooks = new HookRegistryImplementation()
this.hooks.addHook(this.conversationManager)
this.hooks.addAllHooks(config?.hooks ?? [])

// Create printer if printer is enabled (default: true)
Expand Down Expand Up @@ -283,8 +284,6 @@ export class Agent implements AgentData {
// Continue loop
}
} finally {
this.conversationManager.applyManagement(this)

// Invoke AfterInvocationEvent hook
await this.hooks.invokeCallbacks(new AfterInvocationEvent({ agent: this }))

Expand Down Expand Up @@ -362,14 +361,14 @@ export class Agent implements AgentData {
const modelError = normalizeError(error)

// Invoke AfterModelCallEvent hook even on error
await this.hooks.invokeCallbacks(new AfterModelCallEvent({ agent: this, error: modelError }))
const event = await this.hooks.invokeCallbacks(new AfterModelCallEvent({ agent: this, error: modelError }))

if (error instanceof ContextWindowOverflowError) {
// Reduce context and retry
this.conversationManager.reduceContext(this, error)
// Check if hooks request a retry (e.g., after reducing context)
if (event.retryModelCall) {
return yield* this.invokeModel(args)
}
// Re-throw other errors

// Re-throw error
throw error
}
}
Expand Down
11 changes: 0 additions & 11 deletions src/conversation-manager/__tests__/conversation-manager.test.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,53 +1,42 @@
import { describe, it, expect } from 'vitest'
import { NullConversationManager } from '../null-conversation-manager.js'
import { ContextWindowOverflowError, Message, TextBlock } from '../../index.js'
import type { Agent } from '../../agent/agent.js'
import { Message, TextBlock } from '../../index.js'
import { HookRegistryImplementation } from '../../hooks/registry.js'
import { AfterInvocationEvent, AfterModelCallEvent } from '../../hooks/events.js'
import { ContextWindowOverflowError } from '../../errors.js'
import { createMockAgent } from '../../__fixtures__/agent-helpers.js'

describe('NullConversationManager', () => {
describe('applyManagement', () => {
it('does not modify messages array', () => {
describe('behavior', () => {
it('does not modify conversation history', async () => {
const manager = new NullConversationManager()
const messages = [
new Message({ role: 'user', content: [new TextBlock('Hello')] }),
new Message({ role: 'assistant', content: [new TextBlock('Hi there')] }),
]
const mockAgent = { messages } as unknown as Agent
const mockAgent = createMockAgent({ messages })

manager.applyManagement(mockAgent)
const registry = new HookRegistryImplementation()
manager.registerCallbacks(registry)

await registry.invokeCallbacks(new AfterInvocationEvent({ agent: mockAgent }))

expect(mockAgent.messages).toHaveLength(2)
expect(mockAgent.messages[0]!.content[0]).toEqual({ type: 'textBlock', text: 'Hello' })
expect(mockAgent.messages[1]!.content[0]).toEqual({ type: 'textBlock', text: 'Hi there' })
})
})

describe('reduceContext', () => {
it('re-throws provided error', () => {
const manager = new NullConversationManager()
const mockAgent = { messages: [] } as unknown as Agent
const testError = new Error('Test error')

expect(() => {
manager.reduceContext(mockAgent, testError)
}).toThrow(testError)
})

it('throws ContextWindowOverflowError when no error provided', () => {
it('does not set retryModelCall on context overflow', async () => {
const manager = new NullConversationManager()
const mockAgent = { messages: [] } as unknown as Agent
const mockAgent = createMockAgent()
const error = new ContextWindowOverflowError('Context overflow')

expect(() => {
manager.reduceContext(mockAgent)
}).toThrow(ContextWindowOverflowError)
})
const registry = new HookRegistryImplementation()
manager.registerCallbacks(registry)

it('throws ContextWindowOverflowError with correct message when no error provided', () => {
const manager = new NullConversationManager()
const mockAgent = { messages: [] } as unknown as Agent
const event = await registry.invokeCallbacks(new AfterModelCallEvent({ agent: mockAgent, error }))

expect(() => {
manager.reduceContext(mockAgent)
}).toThrow('Context window overflowed!')
expect(event.retryModelCall).toBeUndefined()
})
})
})
Loading
Loading