Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SlidingWindowConversationManager } from '../../conversation-manager/sli
import { SummarizingConversationManager } from '../../conversation-manager/summarizing-conversation-manager.js'
import { ContextOffloader } from '../../vended-plugins/context-offloader/plugin.js'
import { InMemoryStorage as LegacyInMemoryStorage } from '../../vended-plugins/context-offloader/storage.js'
import { InMemoryStorage } from '../../storage/in-memory-storage.js'
import { NAMESPACED } from '../../storage/storage.js'
import type { ConversationManager } from '../../conversation-manager/conversation-manager.js'

function internals(agent: Agent): any {
Expand Down Expand Up @@ -65,7 +65,7 @@ describe('Agent contextManager', () => {
expect(offloader).toBeDefined()
expect(offloader._maxResultTokens).toBe(1500)
expect(offloader._previewTokens).toBe(750)
expect(offloader._storage).toBeInstanceOf(InMemoryStorage)
expect(NAMESPACED in offloader._storage).toBe(true)
})
})

Expand Down
14 changes: 11 additions & 3 deletions strands-ts/src/storage/local-file-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,16 @@ export class LocalFileStorage implements Storage {
return walk(dir, keyPrefix)
}

/** Returns a prefixed view of this storage without mutating the original. */
namespace(prefix: string): Storage {
return namespace(this, prefix)
/**
* Returns a prefixed view of this storage without mutating the original.
* The returned view preserves `forSandbox` for single-level namespacing;
* nested `.namespace()` calls on the view do not carry sandbox routing.
*/
namespace(prefix: string): Storage & { forSandbox(sandbox: Sandbox): Storage } {
const view = namespace(this, prefix)
return {
...view,
forSandbox: (sandbox: Sandbox): Storage => namespace(this.forSandbox(sandbox), prefix),
Comment thread
lizradway marked this conversation as resolved.
Resolved
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ describe('ContextOffloader', () => {

// Only one key per block — no sidecar .contenttype key
const keys = await unifiedStorage.list('')
expect(keys).toEqual(['tool-123_0'])
expect(keys).toEqual(['offloader/tool-123_0'])

const tools = plugin.getTools()
const retrievalTool = tools[0]!
Expand All @@ -771,4 +771,160 @@ describe('ContextOffloader', () => {
expect(result).toBe('hello world '.repeat(100))
})
})

describe('auto-namespacing', () => {
it('auto-scopes a raw unified Storage under offloader/', async () => {
const { InMemoryStorage: UnifiedInMemoryStorage } = await import('../../../storage/in-memory-storage.js')
const unifiedStorage = new UnifiedInMemoryStorage()

const plugin = new ContextOffloader({
storage: unifiedStorage,
maxResultTokens: 10,
previewTokens: 5,
})
const agent = createMockAgent()
plugin.initAgent(agent)

const event = makeEvent([new TextBlock('x'.repeat(1000))])
await invokeTrackedHook(agent, event)

const keys = await unifiedStorage.list('')
expect(keys).toEqual(['offloader/tool-123_0'])
})

it('does not double-namespace an already-namespaced Storage', async () => {
const { InMemoryStorage: UnifiedInMemoryStorage } = await import('../../../storage/in-memory-storage.js')
const { namespace } = await import('../../../storage/storage.js')
const unifiedStorage = new UnifiedInMemoryStorage()
const customScoped = namespace(unifiedStorage, 'custom')

const plugin = new ContextOffloader({
storage: customScoped,
maxResultTokens: 10,
previewTokens: 5,
})
const agent = createMockAgent()
plugin.initAgent(agent)

const event = makeEvent([new TextBlock('x'.repeat(1000))])
await invokeTrackedHook(agent, event)

const keys = await unifiedStorage.list('')
expect(keys).toEqual(['custom/tool-123_0'])
})

it('does not namespace legacy OffloaderStorage', async () => {
const storage = new InMemoryStorage()
const plugin = new ContextOffloader({
storage,
maxResultTokens: 10,
previewTokens: 5,
})
const agent = createMockAgent()
plugin.initAgent(agent)

const event = makeEvent([new TextBlock('x'.repeat(1000))])
await invokeTrackedHook(agent, event)

const preview = (event.result.content[0] as TextBlock).text
expect(preview).not.toContain('offloader/')
})
})

describe('LocalFileStorage sandbox routing', () => {
it('auto-routes LocalFileStorage through sandbox and namespaces under offloader/', async () => {
const { LocalFileStorage } = await import('../../../storage/local-file-storage.js')
const written: Array<{ path: string; data: Uint8Array }> = []
const mockSandbox = {
writeFile: vi.fn(async (path: string, data: Uint8Array) => {
written.push({ path, data })
}),
readFile: vi.fn(async (path: string) => {
const entry = written.find((entry) => entry.path === path)
if (!entry) throw Object.assign(new Error('not found'), { code: 'ENOENT' })
return entry.data
}),
removeFile: vi.fn(),
listFiles: vi.fn(async () => []),
executeStreaming: vi.fn(),
executeCodeStreaming: vi.fn(),
getTools: vi.fn(() => []),
}

const storage = new LocalFileStorage('./artifacts')
const plugin = new ContextOffloader({
storage,
maxResultTokens: 10,
previewTokens: 5,
})
const agent = createMockAgent({ extra: { model: mockModel, sandbox: mockSandbox } as never })
plugin.initAgent(agent)

const result = new ToolResultBlock({
toolUseId: 'tool-123',
status: 'success',
content: [new TextBlock('large content '.repeat(100))],
})
const event = new AfterToolCallEvent({
agent,
toolUse: { name: 'some_tool', toolUseId: 'tool-123', input: {} },
tool: undefined,
result,
invocationState: {},
})
await invokeTrackedHook(agent, event)

expect(mockSandbox.writeFile).toHaveBeenCalled()
const writtenPath = mockSandbox.writeFile.mock.calls[0]![0] as string
expect(writtenPath).toContain('offloader/')
})

it('routes pre-namespaced LocalFileStorage through sandbox with user prefix', async () => {
const { LocalFileStorage } = await import('../../../storage/local-file-storage.js')
const written: Array<{ path: string; data: Uint8Array }> = []
const mockSandbox = {
writeFile: vi.fn(async (path: string, data: Uint8Array) => {
written.push({ path, data })
}),
readFile: vi.fn(async (path: string) => {
const entry = written.find((entry) => entry.path === path)
if (!entry) throw Object.assign(new Error('not found'), { code: 'ENOENT' })
return entry.data
}),
removeFile: vi.fn(),
listFiles: vi.fn(async () => []),
executeStreaming: vi.fn(),
executeCodeStreaming: vi.fn(),
getTools: vi.fn(() => []),
}

const storage = new LocalFileStorage('./artifacts').namespace('custom')
const plugin = new ContextOffloader({
storage,
maxResultTokens: 10,
previewTokens: 5,
})
const agent = createMockAgent({ extra: { model: mockModel, sandbox: mockSandbox } as never })
plugin.initAgent(agent)

const result = new ToolResultBlock({
toolUseId: 'tool-123',
status: 'success',
content: [new TextBlock('large content '.repeat(100))],
})
const event = new AfterToolCallEvent({
agent,
toolUse: { name: 'some_tool', toolUseId: 'tool-123', input: {} },
tool: undefined,
result,
invocationState: {},
})
await invokeTrackedHook(agent, event)

expect(mockSandbox.writeFile).toHaveBeenCalled()
const writtenPath = mockSandbox.writeFile.mock.calls[0]![0] as string
expect(writtenPath).toContain('custom/')
expect(writtenPath).not.toContain('offloader/')
})
})
})
41 changes: 32 additions & 9 deletions strands-ts/src/vended-plugins/context-offloader/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Plugin } from '../../plugins/plugin.js'
import type { Tool, ToolContext } from '../../tools/tool.js'
import type { LocalAgent } from '../../types/agent.js'
import type { Sandbox } from '../../sandbox/base.js'
import { AfterToolCallEvent, BeforeModelCallEvent } from '../../hooks/events.js'
import { TextBlock, JsonBlock, ToolResultBlock, Message } from '../../types/messages.js'
import type { ToolResultContent } from '../../types/messages.js'
Expand All @@ -11,7 +12,7 @@ import { z } from 'zod'
import { logger } from '../../logging/logger.js'
import type { JSONValue } from '../../types/json.js'
import { FileStorage, InMemoryStorage as LegacyInMemoryStorage, type Storage as OffloaderStorage } from './storage.js'
import type { Storage } from '../../storage/storage.js'
import { NAMESPACED, namespace, type Storage } from '../../storage/storage.js'
import { isSearchableContent, searchContent } from './search.js'

function isOffloaderStorage(storage: Storage | OffloaderStorage): storage is OffloaderStorage {
Expand Down Expand Up @@ -156,6 +157,11 @@ export interface ContextOffloaderConfig {
* - A unified `Storage` (from `@strands-agents/sdk/storage`) — each offloaded content block
* occupies exactly one key (content-type is framed into the stored bytes).
* - A legacy offloader `Storage` (deprecated, from this module).
*
* When a unified `Storage` is provided, the plugin auto-scopes keys under `offloader/`
* unless the storage is already namespaced. If the resolved storage exposes a
* `forSandbox(sandbox)` method (as `LocalFileStorage` and its namespace views do),
* the plugin auto-routes I/O through the agent's sandbox.
*/
storage: Storage | OffloaderStorage
/** Token threshold above which tool results are offloaded. Defaults to 2,500. */
Expand Down Expand Up @@ -202,6 +208,7 @@ export class ContextOffloader implements Plugin {

private static readonly _DEFAULT_EVICT_AFTER_CYCLES = 20

private readonly _sandboxableStorage: { forSandbox(sandbox: Sandbox): Storage } | undefined
private readonly _storage: Storage | OffloaderStorage
private readonly _maxResultTokens: number
private readonly _previewTokens: number
Expand All @@ -225,7 +232,19 @@ export class ContextOffloader implements Plugin {
throw new Error('evictAfterCycles must be a positive integer')
}

this._storage = config.storage
if (isOffloaderStorage(config.storage)) {
this._storage = config.storage
} else if (NAMESPACED in config.storage) {
this._storage = config.storage
} else if (config.storage.namespace) {
this._storage = config.storage.namespace('offloader')
} else {
this._storage = namespace(config.storage, 'offloader')
}
this._sandboxableStorage =
!isOffloaderStorage(this._storage) && 'forSandbox' in this._storage
Comment thread
lizradway marked this conversation as resolved.
Resolved
? (this._storage as { forSandbox(sandbox: Sandbox): Storage })
: undefined
this._maxResultTokens = maxResultTokens
this._previewTokens = previewTokens
this._includeRetrievalTool = config.includeRetrievalTool ?? true
Expand All @@ -244,12 +263,12 @@ export class ContextOffloader implements Plugin {
if (this._storage instanceof LegacyInMemoryStorage) {
this._storage._evict(cycleCount)
} else if (this._evictAfterCycles !== null) {
this._evict(cycleCount)
this._evict(cycleCount, agent)
}
})
}

private _evict(currentCycle: number): void {
private _evict(currentCycle: number, agent: LocalAgent): void {
const threshold = currentCycle - this._evictAfterCycles!
const toEvict: string[] = []
for (const [key, storedCycle] of this._keyStoredAt) {
Expand All @@ -258,7 +277,7 @@ export class ContextOffloader implements Plugin {
}
}
if (toEvict.length === 0) return
const storage = this._storage as Storage
const storage = this._storageForAgent(agent) as Storage
for (const key of toEvict) {
this._keyStoredAt.delete(key)
storage.delete(key).catch(() => {})
Expand All @@ -273,20 +292,24 @@ export class ContextOffloader implements Plugin {
}

private _storageForAgent(agent: LocalAgent): Storage | OffloaderStorage {
if (!(this._storage instanceof FileStorage)) return this._storage!
if (!this._sandboxableStorage && !(this._storage instanceof FileStorage)) return this._storage

let storage = this._storageByAgent.get(agent)
if (!storage) {
storage = this._storage.forSandbox(agent.sandbox)
if (this._sandboxableStorage) {
storage = this._sandboxableStorage.forSandbox(agent.sandbox)
Comment thread
lizradway marked this conversation as resolved.
Resolved
} else {
storage = (this._storage as FileStorage).forSandbox(agent.sandbox)
}
this._storageByAgent.set(agent, storage)
}
return storage
}

private _storageForToolContext(context?: ToolContext): Storage | OffloaderStorage {
if (!(this._storage instanceof FileStorage)) return this._storage!
if (!this._sandboxableStorage && !(this._storage instanceof FileStorage)) return this._storage
if (!context) {
throw new Error('FileStorage retrieval requires a tool execution context.')
throw new Error('File-based storage retrieval requires a tool execution context.')
}
return this._storageForAgent(context.agent)
}
Expand Down
Loading