diff --git a/.changeset/open-streets-switch.md b/.changeset/open-streets-switch.md new file mode 100644 index 00000000..837a9a20 --- /dev/null +++ b/.changeset/open-streets-switch.md @@ -0,0 +1,5 @@ +--- +'@openai/agents-core': patch +--- + +fix: Enable creating and disposing Computer per request ref: #663 diff --git a/examples/tools/computer-use.ts b/examples/tools/computer-use.ts index c2435d97..f1d7b3f7 100644 --- a/examples/tools/computer-use.ts +++ b/examples/tools/computer-use.ts @@ -1,7 +1,9 @@ import { chromium, Browser, Page } from 'playwright'; -import { Agent, run, withTrace, Computer, computerTool } from '@openai/agents'; +import { Agent, run, withTrace, computerTool, Computer } from '@openai/agents'; -async function main() { +async function singletonComputer() { + // If your app never runs multiple computer using agents at the same time, + // you can create a singleton computer and use it in all your agents. const computer = await new LocalPlaywrightComputer().init(); try { const agent = new Agent({ @@ -20,6 +22,36 @@ async function main() { } } +async function computerPerRequest() { + // If your app runs multiple computer using agents at the same time, + // you can create a computer per request. + const agent = new Agent({ + name: 'Browser user', + model: 'computer-use-preview', + instructions: 'You are a helpful agent.', + tools: [ + computerTool({ + // initialize a new computer for each run and dispose it after the run is complete + computer: { + create: async ({ runContext }) => { + console.log('Initializing computer for run context:', runContext); + return await new LocalPlaywrightComputer().init(); + }, + dispose: async ({ runContext, computer }) => { + console.log('Disposing of computer for run context:', runContext); + await computer.dispose(); + }, + }, + }), + ], + modelSettings: { truncation: 'auto' }, + }); + await withTrace('CUA Example', async () => { + const result = await run(agent, "What's the weather in Tokyo?"); + console.log(`\nFinal response:\n${result.finalOutput}`); + }); +} + // --- CUA KEY TO PLAYWRIGHT KEY MAP --- const CUA_KEY_TO_PLAYWRIGHT_KEY: Record = { @@ -186,7 +218,18 @@ class LocalPlaywrightComputer implements Computer { } } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +const mode = (process.argv[2] ?? '').toLowerCase(); + +if (mode === 'singleton') { + // Choose singleton mode for cases where concurrent runs are not expected. + singletonComputer().catch((error) => { + console.error(error); + process.exit(1); + }); +} else { + // Default to per-request mode to avoid sharing state across runs. + computerPerRequest().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/agents-core/src/model.ts b/packages/agents-core/src/model.ts index bcfe65dd..3cb021fa 100644 --- a/packages/agents-core/src/model.ts +++ b/packages/agents-core/src/model.ts @@ -7,6 +7,7 @@ import { ShellTool, ApplyPatchTool, } from './tool'; +import { Computer } from './computer'; import { Handoff } from './handoff'; import { AgentInputItem, @@ -178,8 +179,8 @@ export type SerializedFunctionTool = { export type SerializedComputerTool = { type: ComputerTool['type']; name: ComputerTool['name']; - environment: ComputerTool['computer']['environment']; - dimensions: ComputerTool['computer']['dimensions']; + environment: Computer['environment']; + dimensions: Computer['dimensions']; }; export type SerializedShellTool = { diff --git a/packages/agents-core/src/run.ts b/packages/agents-core/src/run.ts index 6a886aa6..f6ffb74a 100644 --- a/packages/agents-core/src/run.ts +++ b/packages/agents-core/src/run.ts @@ -50,7 +50,12 @@ import { prepareInputItemsWithSession, } from './runImplementation'; import { RunItem } from './items'; -import { Tool } from './tool'; +import { + ComputerTool, + Tool, + resolveComputer, + disposeResolvedComputers, +} from './tool'; import { getOrCreateTrace, addErrorToCurrentSpan, @@ -953,6 +958,13 @@ export class Runner extends RunHooks> { } throw err; } finally { + if (state._currentStep?.type !== 'next_step_interruption') { + try { + await disposeResolvedComputers({ runContext: state._context }); + } catch (error) { + logger.warn(`Failed to dispose computers after run: ${error}`); + } + } if (state._currentAgentSpan) { if (state._currentStep?.type !== 'next_step_interruption') { // don't end the span if the run was interrupted @@ -1333,6 +1345,13 @@ export class Runner extends RunHooks> { } throw error; } finally { + if (result.state._currentStep?.type !== 'next_step_interruption') { + try { + await disposeResolvedComputers({ runContext: result.state._context }); + } catch (error) { + logger.warn(`Failed to dispose computers after run: ${error}`); + } + } if (result.state._currentAgentSpan) { if (result.state._currentStep?.type !== 'next_step_interruption') { result.state._currentAgentSpan.end(); @@ -2103,6 +2122,18 @@ async function prepareAgentArtifacts< const handoffs = await state._currentAgent.getEnabledHandoffs(state._context); const tools = await state._currentAgent.getAllTools(state._context); + const computerTools = tools.filter( + (tool) => tool.type === 'computer', + ) as ComputerTool[]; + + if (computerTools.length > 0) { + await Promise.all( + computerTools.map(async (tool) => { + await resolveComputer({ tool, runContext: state._context }); + }), + ); + } + if (!state._currentAgentSpan) { const handoffNames = handoffs.map((h) => h.agentName); state._currentAgentSpan = createAgentSpan({ diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index bd66582e..c51b00df 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -33,6 +33,7 @@ import { HostedMCPTool, ShellTool, ApplyPatchTool, + resolveComputer, } from './tool'; import type { ShellResult } from './shell'; import { AgentInputItem, UnknownContext } from './types'; @@ -76,7 +77,7 @@ type ToolRunFunction = { // Holds a pending computer-use action so we can dispatch to the configured computer tool. type ToolRunComputer = { toolCall: protocol.ComputerUseCallItem; - computer: ComputerTool; + computer: ComputerTool; }; // Captures a shell invocation emitted by the model. @@ -235,7 +236,9 @@ export function processModelResponse( const functionMap = new Map( tools.filter((t) => t.type === 'function').map((t) => [t.name, t]), ); - const computerTool = tools.find((t) => t.type === 'computer'); + const computerTool = tools.find( + (t): t is ComputerTool => t.type === 'computer', + ); const shellTool = tools.find((t): t is ShellTool => t.type === 'shell'); const applyPatchTool = tools.find( (t): t is ApplyPatchTool => t.type === 'apply_patch', @@ -1360,12 +1363,25 @@ export async function executeFunctionToolCalls( // Emit agent_tool_end even on error to maintain consistent event lifecycle const errorResult = String(error); - runner.emit('agent_tool_end', state._context, agent, toolRun.tool, errorResult, { - toolCall: toolRun.toolCall, - }); - agent.emit('agent_tool_end', state._context, toolRun.tool, errorResult, { - toolCall: toolRun.toolCall, - }); + runner.emit( + 'agent_tool_end', + state._context, + agent, + toolRun.tool, + errorResult, + { + toolCall: toolRun.toolCall, + }, + ); + agent.emit( + 'agent_tool_end', + state._context, + toolRun.tool, + errorResult, + { + toolCall: toolRun.toolCall, + }, + ); throw error; } @@ -1753,7 +1769,6 @@ export async function executeComputerActions( const _logger = customLogger ?? logger; const results: RunItem[] = []; for (const action of actions) { - const computer = action.computer.computer; const toolCall = action.toolCall; // Hooks: on_tool_start (global + agent) @@ -1767,6 +1782,10 @@ export async function executeComputerActions( // Run the action and get screenshot let output: string; try { + const computer = await resolveComputer({ + tool: action.computer, + runContext, + }); output = await _runComputerActionAndScreenshot(computer, toolCall); } catch (err) { _logger.error('Failed to execute computer action:', err); diff --git a/packages/agents-core/src/tool.ts b/packages/agents-core/src/tool.ts index 248ae94e..89e31332 100644 --- a/packages/agents-core/src/tool.ts +++ b/packages/agents-core/src/tool.ts @@ -131,13 +131,94 @@ export type FunctionTool< isEnabled: ToolEnabledFunction; }; +/** + * Arguments provided to computer initializers. + */ +export type ComputerInitializerArgs = { + runContext: RunContext; +}; + +/** + * A function that initializes a computer for the current run. + */ +type BivariantComputerCreate< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = { + // Use the conventional "bivarianceHack" pattern so user callbacks can accept + // narrower Computer types without contravariant parameter errors in TS. + // See: https://www.typescriptlang.org/docs/handbook/type-compatibility.html#function-parameter-bivariance + bivarianceHack: ( + args: ComputerInitializerArgs, + ) => TComputer | Promise; +}['bivarianceHack']; + +// Keep initializer/disposer bivariant so user code can specify narrower Computer types without +// forcing contravariant function argument types downstream. +export type ComputerCreate< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = BivariantComputerCreate; + +/** + * Optional cleanup invoked after a run finishes when the computer was created via an initializer. + */ +type BivariantComputerDispose< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = { + // Apply the same bivariance pattern to cleanup callbacks for consistent ergonomics. + bivarianceHack: ( + args: ComputerInitializerArgs & { computer: TComputer }, + ) => void | Promise; +}['bivarianceHack']; + +export type ComputerDispose< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = BivariantComputerDispose; + +/** + * Initializes a computer for the current run and optionally tears it down after the run. + */ +export type ComputerProvider< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = { + create: ComputerCreate; + dispose?: ComputerDispose; +}; + +type ComputerInitializer< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = ComputerCreate | ComputerProvider; + +export type ComputerConfig< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = Computer | ComputerInitializer; + +function isComputerProvider( + candidate: unknown, +): candidate is ComputerProvider { + return ( + !!candidate && + typeof candidate === 'object' && + typeof (candidate as { create?: unknown }).create === 'function' + ); +} + /** * Exposes a computer to the model as a tool to be called * * @param Context The context of the tool * @param Result The result of the tool */ -export type ComputerTool = { +export type ComputerTool< + Context = UnknownContext, + TComputer extends Computer = Computer, +> = { type: 'computer'; /** * The name of the tool. @@ -147,23 +228,202 @@ export type ComputerTool = { /** * The computer to use. */ - computer: Computer; + computer: ComputerConfig; }; /** - * Exposes a computer to the agent as a tool to be called + * Exposes a computer to the agent as a tool to be called. * * @param options Additional configuration for the computer tool like specifying the location of your agent * @returns a computer tool definition */ -export function computerTool( - options: Partial> & { computer: Computer }, -): ComputerTool { - return { +export function computerTool< + Context = UnknownContext, + TComputer extends Computer = Computer, +>(options: { + name?: string; + computer: ComputerConfig; +}): ComputerTool { + if (!options.computer) { + throw new UserError( + 'computerTool requires a computer instance or an initializer function.', + ); + } + + const tool: ComputerTool = { type: 'computer', name: options.name ?? 'computer_use_preview', computer: options.computer, }; + + if ( + typeof options.computer === 'function' || + isComputerProvider(options.computer) + ) { + computerInitializerMap.set( + tool as AnyComputerTool, + options.computer as ComputerInitializer, + ); + } + + return tool; +} + +type ResolvedComputer = { + computer: Computer; + dispose?: ComputerDispose; +}; + +type AnyComputerTool = ComputerTool; + +// Keeps per-tool cache of computer instances keyed by RunContext so each run gets its own instance. +const computerCache = new WeakMap< + AnyComputerTool, + WeakMap, ResolvedComputer> +>(); +// Tracks the initializer so we do not overwrite the callable on the tool when we memoize the resolved instance. +const computerInitializerMap = new WeakMap< + AnyComputerTool, + ComputerInitializer +>(); +// Allows cleanup routines to find all resolved computer instances for a given run context. +const computersByRunContext = new WeakMap< + RunContext, + Map> +>(); + +function getComputerInitializer( + tool: ComputerTool, +): ComputerInitializer | undefined { + const initializer = computerInitializerMap.get(tool as AnyComputerTool); + if (initializer) { + return initializer as ComputerInitializer; + } + if ( + typeof tool.computer === 'function' || + isComputerProvider(tool.computer) + ) { + return tool.computer as ComputerInitializer; + } + return undefined; +} + +function trackResolvedComputer( + tool: ComputerTool, + runContext: RunContext, + resolved: ResolvedComputer, +) { + let resolvedByRun = computersByRunContext.get(runContext); + if (!resolvedByRun) { + resolvedByRun = new Map(); + computersByRunContext.set(runContext, resolvedByRun); + } + resolvedByRun.set(tool as AnyComputerTool, resolved); +} + +/** + * Returns a computer instance for the provided run context. Caches per run to avoid sharing across runs. + * @internal + */ +export async function resolveComputer< + Context, + TComputer extends Computer = Computer, +>(args: { + tool: ComputerTool; + runContext: RunContext; +}): Promise { + const { tool, runContext } = args; + // Cache instances per RunContext so a single Computer is not shared across simultaneous runs. + const toolKey = tool as AnyComputerTool; + let perContext = computerCache.get(toolKey); + if (!perContext) { + perContext = new WeakMap(); + computerCache.set(toolKey, perContext); + } + + const cached = perContext.get(runContext); + if (cached) { + trackResolvedComputer(tool, runContext, cached); + return cached.computer as TComputer; + } + + const initializerConfig = getComputerInitializer(tool); + const lifecycle = + initializerConfig && isComputerProvider(initializerConfig) + ? initializerConfig + : isComputerProvider(tool.computer) + ? (tool.computer as ComputerProvider) + : undefined; + const initializer = + typeof initializerConfig === 'function' + ? initializerConfig + : (lifecycle?.create ?? + (typeof tool.computer === 'function' + ? (tool.computer as ComputerCreate) + : undefined)); + const disposer = lifecycle?.dispose; + + const computer = + initializer && typeof initializer === 'function' + ? await initializer({ runContext }) + : (tool.computer as Computer); + + if (!computer) { + throw new UserError( + 'The computer tool did not provide a computer instance.', + ); + } + + const resolved: ResolvedComputer = { + computer, + dispose: disposer, + }; + perContext.set(runContext, resolved); + trackResolvedComputer(tool, runContext, resolved); + tool.computer = computer as ComputerConfig; + return computer as TComputer; +} + +/** + * Disposes any computer instances created for the provided run context. + * @internal + */ +export async function disposeResolvedComputers({ + runContext, +}: { + runContext: RunContext; +}): Promise { + const resolvedByRun = computersByRunContext.get(runContext); + if (!resolvedByRun) { + return; + } + computersByRunContext.delete(runContext); + + const disposers: Array<() => Promise> = []; + + for (const [tool, resolved] of resolvedByRun.entries()) { + const perContext = computerCache.get(tool); + perContext?.delete(runContext); + + const storedInitializer = getComputerInitializer(tool); + if (storedInitializer) { + tool.computer = storedInitializer; + } + + if (resolved.dispose) { + disposers.push(async () => { + await resolved.dispose?.({ runContext, computer: resolved.computer }); + }); + } + } + + for (const dispose of disposers) { + try { + await dispose(); + } catch (error) { + logger.warn(`Failed to dispose computer for run context: ${error}`); + } + } } export type ShellTool = { @@ -428,7 +688,7 @@ export type HostedTool = { */ export type Tool = | FunctionTool - | ComputerTool + | ComputerTool | ShellTool | ApplyPatchTool | HostedTool; diff --git a/packages/agents-core/src/utils/serialize.ts b/packages/agents-core/src/utils/serialize.ts index 7ad5ccff..e395b225 100644 --- a/packages/agents-core/src/utils/serialize.ts +++ b/packages/agents-core/src/utils/serialize.ts @@ -3,6 +3,17 @@ import { Handoff } from '../handoff'; import { Tool } from '../tool'; import { AgentOutputType } from '../agent'; import { SerializedHandoff, SerializedTool } from '../model'; +import { UserError } from '../errors'; +import type { Computer } from '../computer'; + +function isComputerInstance(value: unknown): value is Computer { + return ( + !!value && + typeof value === 'object' && + 'environment' in (value as Record) && + 'dimensions' in (value as Record) + ); +} export function serializeTool(tool: Tool): SerializedTool { if (tool.type === 'function') { @@ -15,6 +26,12 @@ export function serializeTool(tool: Tool): SerializedTool { }; } if (tool.type === 'computer') { + // When a computer is created lazily via an initializer, serializeTool can be called before initialization (e.g., manual serialize without running the agent). + if (!isComputerInstance(tool.computer)) { + throw new UserError( + 'Computer tool is not initialized for serialization. Call resolveComputer({ tool, runContext }) first (for example, when building a model payload outside Runner.run).', + ); + } return { type: 'computer', name: tool.name, diff --git a/packages/agents-core/test/run.test.ts b/packages/agents-core/test/run.test.ts index 3ec44d1c..12bf8a96 100644 --- a/packages/agents-core/test/run.test.ts +++ b/packages/agents-core/test/run.test.ts @@ -40,7 +40,7 @@ import { RunContext } from '../src/runContext'; import { RunState } from '../src/runState'; import * as protocol from '../src/types/protocol'; import { Usage } from '../src/usage'; -import { tool, hostedMcpTool } from '../src/tool'; +import { tool, hostedMcpTool, computerTool } from '../src/tool'; import { FakeModel, fakeModelMessage, @@ -48,7 +48,9 @@ import { FakeTracingExporter, TEST_MODEL_MESSAGE, TEST_MODEL_RESPONSE_BASIC, + TEST_MODEL_FUNCTION_CALL, TEST_TOOL, + FakeComputer, } from './stubs'; import { Model, @@ -315,6 +317,91 @@ describe('Runner.run', () => { expect(runnerEndEvents[0].agent).toBe(agent); expect(runnerEndEvents[0].output).toBe('Hello World'); }); + + it('disposes computer lifecycle initializers after a completed run', async () => { + const createdComputer = new FakeComputer(); + const create = vi.fn(async () => createdComputer); + const dispose = vi.fn(async () => {}); + const computer = computerTool({ + computer: { create, dispose }, + }); + const model = new FakeModel([ + { output: [fakeModelMessage('done')], usage: new Usage() }, + ]); + const agent = new Agent({ + name: 'ComputerAgent', + model, + tools: [computer], + }); + + const result = await run(agent, 'hello'); + + expect(result.finalOutput).toBe('done'); + expect(create).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledWith({ + runContext: result.state._context, + computer: createdComputer, + }); + }); + + it('defers disposal while a run is interrupted and cleans up after resuming', async () => { + const createdComputer = new FakeComputer(); + const create = vi.fn(async () => createdComputer); + const dispose = vi.fn(async () => {}); + const computer = computerTool({ + computer: { create, dispose }, + }); + + const approvalTool = tool({ + name: 'needsApproval', + description: 'requires approval', + parameters: z.object({}).strict(), + execute: async () => 'ok', + needsApproval: true, + }); + + const functionCall: protocol.FunctionCallItem = { + ...TEST_MODEL_FUNCTION_CALL, + name: 'needsApproval', + callId: 'call-1', + arguments: '{}', + }; + const model = new FakeModel([ + { + output: [functionCall, fakeModelMessage('pending')], + usage: new Usage(), + }, + { output: [fakeModelMessage('all done')], usage: new Usage() }, + ]); + + const agent = new Agent({ + name: 'ApprovalAgent', + model, + tools: [computer, approvalTool], + }); + + const firstRun = await run(agent, 'hello'); + expect(firstRun.interruptions).toHaveLength(1); + expect(dispose).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledTimes(1); + + const approval = firstRun.interruptions?.[0]; + if (!approval) { + throw new Error('Expected an approval interruption'); + } + firstRun.state.approve(approval); + + const finalRun = await run(agent, firstRun.state); + + expect(finalRun.finalOutput).toBe('all done'); + expect(create).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledWith({ + runContext: finalRun.state._context, + computer: createdComputer, + }); + }); }); describe('additional scenarios', () => { diff --git a/packages/agents-core/test/runImplementation.test.ts b/packages/agents-core/test/runImplementation.test.ts index 68ffb987..856c0047 100644 --- a/packages/agents-core/test/runImplementation.test.ts +++ b/packages/agents-core/test/runImplementation.test.ts @@ -45,6 +45,7 @@ import { applyPatchTool, shellTool, hostedMcpTool, + resolveComputer, } from '../src/tool'; import { handoff } from '../src/handoff'; import { ModelBehaviorError, UserError } from '../src/errors'; @@ -1912,6 +1913,46 @@ describe('executeComputerActions', () => { expect(result.every((r) => r instanceof ToolCallOutputItem)).toBe(true); }); + it('initializes computer with run context using a computer initializer', async () => { + const initializer = vi.fn(async () => makeComputer()); + const tool = computerTool({ computer: initializer }); + const toolCall: protocol.ComputerUseCallItem = { + id: 'id1', + type: 'computer_call', + callId: 'id1', + status: 'completed', + action: { type: 'screenshot' }, + }; + + const ctxA = new RunContext(); + const ctxB = new RunContext(); + const runner = new Runner({ tracingDisabled: true }); + + await executeComputerActions( + new Agent({ name: 'Comp' }), + [{ toolCall, computer: tool }], + runner, + ctxA, + ); + await executeComputerActions( + new Agent({ name: 'Comp' }), + [{ toolCall, computer: tool }], + runner, + ctxA, + ); + await executeComputerActions( + new Agent({ name: 'Comp' }), + [{ toolCall, computer: tool }], + runner, + ctxB, + ); + + expect(initializer).toHaveBeenCalledTimes(2); + const compA = await resolveComputer({ tool, runContext: ctxA }); + const compB = await resolveComputer({ tool, runContext: ctxB }); + expect(compA).not.toBe(compB); + }); + it('throws if computer lacks screenshot', async () => { const comp: any = { environment: 'mac', diff --git a/packages/agents-core/test/tool.test.ts b/packages/agents-core/test/tool.test.ts index 55f6a926..bf663421 100644 --- a/packages/agents-core/test/tool.test.ts +++ b/packages/agents-core/test/tool.test.ts @@ -5,6 +5,8 @@ import { hostedMcpTool, shellTool, tool, + resolveComputer, + disposeResolvedComputers, } from '../src/tool'; import { z } from 'zod'; import { Computer } from '../src'; @@ -42,6 +44,108 @@ describe('Tool', () => { expect(t.name).toBe('computer_use_preview'); }); + it('computerTool initializes computer per run context when an initializer is provided', async () => { + const initializer = vi.fn( + async (): Promise => ({ + environment: 'mac' as const, + dimensions: [1, 1], + screenshot: async () => 'img', + click: async () => {}, + doubleClick: async () => {}, + drag: async () => {}, + keypress: async () => {}, + move: async () => {}, + scroll: async () => {}, + type: async () => {}, + wait: async () => {}, + }), + ); + const t = computerTool({ name: 'comp', computer: initializer }); + + const ctxA = new RunContext(); + const ctxB = new RunContext(); + + const compA1 = await resolveComputer({ tool: t, runContext: ctxA }); + const compA2 = await resolveComputer({ tool: t, runContext: ctxA }); + const compB1 = await resolveComputer({ tool: t, runContext: ctxB }); + + expect(initializer).toHaveBeenCalledTimes(2); + expect(compA1).toBe(compA2); + expect(compA1).not.toBe(compB1); + expect(t.computer).toBe(compB1); + }); + + it('resolveComputer reuses provided static instance without invoking initializer logic', async () => { + const staticComp = { + environment: 'mac' as const, + dimensions: [1, 1] as [number, number], + screenshot: async () => 'img', + click: async () => {}, + doubleClick: async () => {}, + drag: async () => {}, + keypress: async () => {}, + move: async () => {}, + scroll: async () => {}, + type: async () => {}, + wait: async () => {}, + }; + const initSpy = vi.fn(); + const t = computerTool({ computer: staticComp }); + const ctx = new RunContext(); + + const first = await resolveComputer({ tool: t, runContext: ctx }); + const second = await resolveComputer({ tool: t, runContext: ctx }); + + expect(first).toBe(staticComp); + expect(second).toBe(staticComp); + expect(initSpy).not.toHaveBeenCalled(); + }); + + it('supports lifecycle initializers with dispose per run context', async () => { + let counter = 0; + const makeComputer = (label: string) => + ({ + environment: 'mac' as const, + dimensions: [1, 1] as [number, number], + screenshot: async () => 'img', + click: async () => {}, + doubleClick: async () => {}, + drag: async () => {}, + keypress: async () => {}, + move: async () => {}, + scroll: async () => {}, + type: async () => {}, + wait: async () => {}, + label, + }) as Computer & { label: string }; + + const dispose = vi.fn(async () => {}); + const initializer = vi.fn(async () => { + counter += 1; + return makeComputer(`computer-${counter}`); + }); + + const t = computerTool({ + computer: { + create: initializer, + dispose, + }, + }); + const ctx = new RunContext(); + + const first = await resolveComputer({ tool: t, runContext: ctx }); + expect(initializer).toHaveBeenCalledTimes(1); + expect(dispose).not.toHaveBeenCalled(); + + await disposeResolvedComputers({ runContext: ctx }); + + const second = await resolveComputer({ tool: t, runContext: ctx }); + expect(initializer).toHaveBeenCalledTimes(2); + expect(dispose).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledWith({ runContext: ctx, computer: first }); + expect(second).not.toBe(first); + }); + it('shellTool assigns default name', () => { const shell = new FakeShell(); const t = shellTool({ shell }); diff --git a/packages/agents-core/test/utils/serialize.test.ts b/packages/agents-core/test/utils/serialize.test.ts index e31d9d42..58174c05 100644 --- a/packages/agents-core/test/utils/serialize.test.ts +++ b/packages/agents-core/test/utils/serialize.test.ts @@ -38,6 +38,20 @@ describe('serialize utilities', () => { }); }); + it('throws when computer tool has not been initialized yet', () => { + const t: any = { + type: 'computer', + name: 'comp', + computer: async () => ({ + environment: 'node', + dimensions: { width: 1, height: 2 }, + }), + }; + expect(() => serializeTool(t)).toThrow( + /resolveComputer\(\{ tool, runContext \}\)/, + ); + }); + it('serializes shell tools', () => { const t: any = { type: 'shell',