11import { type Tool , tool } from "ai" ;
22import { z } from "zod" ;
33
4+ import { notCallableMessage } from "../runtime/runtime.js" ;
45import type { WorkspaceRuntimeValue } from "../runtime/types.js" ;
56
67// A finite JSON value: what a callable backend accepts as `input` and
@@ -40,6 +41,10 @@ export interface ExecRuntimeHandle extends Partial<AsyncIterable<ExecStreamEvent
4041 stderr : string ;
4142 value ?: unknown ;
4243 } > ;
44+ // Signal the running execution. The tool calls it when the model
45+ // turn aborts, so the backend stops rather than running on after
46+ // the tool stops iterating.
47+ kill ?( ) : Promise < void > ;
4348}
4449
4550export interface ExecWorkspaceLike {
@@ -188,14 +193,11 @@ export function createExecTool(options: ExecToolOptions): Tool<
188193 "Structured value handed to a callable backend's module. Only callable backends accept it; other backends reject it." ,
189194 ) ,
190195 } ) ,
191- execute : async function * ( { command, cwd, backend, env, input } ) {
196+ execute : async function * ( { command, cwd, backend, env, input } , { abortSignal } ) {
192197 const selectedBackend = backend ?? options . defaultBackend ;
193198 const base = { command, cwd : cwd ?? null , backend : selectedBackend } ;
194199 if ( input !== undefined && ! callableBackendIds . has ( selectedBackend ) ) {
195- yield {
196- ...base ,
197- error : `Backend ${ JSON . stringify ( selectedBackend ) } is not callable; it does not accept structured input.` ,
198- } ;
200+ yield { ...base , error : notCallableMessage ( selectedBackend ) } ;
199201 return ;
200202 }
201203 let handle : ExecRuntimeHandle ;
@@ -212,68 +214,84 @@ export function createExecTool(options: ExecToolOptions): Tool<
212214 return ;
213215 }
214216
215- // Stream stdout / stderr chunks as they arrive when the handle
216- // is iterable. Each chunk yields a fresh snapshot with the
217- // running output so the model sees progress before the run
218- // ends; the exit event settles the terminal snapshot.
219- if ( typeof handle [ Symbol . asyncIterator ] === "function" ) {
220- const stdout = new StreamBuffer ( streamMaxBytes ) ;
221- const stderr = new StreamBuffer ( streamMaxBytes ) ;
222- let exitCode : number | null = null ;
223- let value : unknown ;
224- let hasValue = false ;
225- // Coalesce running snapshots to at most one per interval. A
226- // chatty command would otherwise yield a full-buffer snapshot
227- // per chunk; the terminal snapshot below always fires.
228- let lastSnapshot = 0 ;
229- try {
230- for await ( const event of handle as AsyncIterable < ExecStreamEvent > ) {
231- if ( event . name === "stdout" ) stdout . push ( event . value ) ;
232- else if ( event . name === "stderr" ) stderr . push ( event . value ) ;
233- else {
234- exitCode = event . code ;
235- if ( "result" in event ) {
236- value = event . result ;
237- hasValue = true ;
217+ // Aborting the model turn kills the backend execution so it does
218+ // not run on unobserved after the tool stops iterating. The run
219+ // then emits its terminal event and the stream closes normally.
220+ const onAbort = ( ) => void handle . kill ?.( ) . catch ( ( ) => undefined ) ;
221+ if ( abortSignal ?. aborted ) onAbort ( ) ;
222+ else abortSignal ?. addEventListener ( "abort" , onAbort , { once : true } ) ;
223+ try {
224+ yield * runExecution ( ) ;
225+ } finally {
226+ abortSignal ?. removeEventListener ( "abort" , onAbort ) ;
227+ }
228+
229+ // Produce the run's snapshots. Streams the raw events when the
230+ // handle is iterable; otherwise drains the aggregate result.
231+ async function * runExecution ( ) : AsyncGenerator < ExecToolOutput > {
232+ // Stream stdout / stderr chunks as they arrive when the handle
233+ // is iterable. Each chunk yields a fresh snapshot with the
234+ // running output so the model sees progress before the run
235+ // ends; the exit event settles the terminal snapshot.
236+ if ( typeof handle [ Symbol . asyncIterator ] === "function" ) {
237+ const stdout = new StreamBuffer ( streamMaxBytes ) ;
238+ const stderr = new StreamBuffer ( streamMaxBytes ) ;
239+ let exitCode : number | null = null ;
240+ let value : unknown ;
241+ let hasValue = false ;
242+ // Coalesce running snapshots to at most one per interval. A
243+ // chatty command would otherwise yield a full-buffer snapshot
244+ // per chunk; the terminal snapshot below always fires.
245+ let lastSnapshot = 0 ;
246+ try {
247+ for await ( const event of handle as AsyncIterable < ExecStreamEvent > ) {
248+ if ( event . name === "stdout" ) stdout . push ( event . value ) ;
249+ else if ( event . name === "stderr" ) stderr . push ( event . value ) ;
250+ else {
251+ exitCode = event . code ;
252+ if ( "result" in event ) {
253+ value = event . result ;
254+ hasValue = true ;
255+ }
256+ continue ;
238257 }
239- continue ;
258+ const at = now ( ) ;
259+ if ( at - lastSnapshot < STREAM_COALESCE_MS ) continue ;
260+ lastSnapshot = at ;
261+ yield {
262+ ...base ,
263+ exitCode : null ,
264+ stdout : stdout . render ( maxBytes ) ,
265+ stderr : stderr . render ( maxBytes ) ,
266+ } ;
240267 }
241- const at = now ( ) ;
242- if ( at - lastSnapshot < STREAM_COALESCE_MS ) continue ;
243- lastSnapshot = at ;
244- yield {
245- ...base ,
246- exitCode : null ,
247- stdout : stdout . render ( maxBytes ) ,
248- stderr : stderr . render ( maxBytes ) ,
249- } ;
268+ } catch ( err ) {
269+ yield { ...base , error : errorMessage ( err ) } ;
270+ return ;
250271 }
251- } catch ( err ) {
252- yield { ...base , error : errorMessage ( err ) } ;
272+ yield {
273+ ...base ,
274+ exitCode,
275+ stdout : stdout . render ( maxBytes ) ,
276+ stderr : stderr . render ( maxBytes ) ,
277+ ...( hasValue ? { result : value } : { } ) ,
278+ } ;
253279 return ;
254280 }
255- yield {
256- ...base ,
257- exitCode,
258- stdout : stdout . render ( maxBytes ) ,
259- stderr : stderr . render ( maxBytes ) ,
260- ...( hasValue ? { result : value } : { } ) ,
261- } ;
262- return ;
263- }
264281
265- // Non-streaming handle: drain the aggregate result.
266- try {
267- const result = await handle . result ( ) ;
268- yield {
269- ...base ,
270- exitCode : result . exitCode ,
271- stdout : truncate ( result . stdout , maxBytes ) ,
272- stderr : truncate ( result . stderr , maxBytes ) ,
273- ...( result . value === undefined ? { } : { result : result . value } ) ,
274- } ;
275- } catch ( err ) {
276- yield { ...base , error : errorMessage ( err ) } ;
282+ // Non-streaming handle: drain the aggregate result.
283+ try {
284+ const result = await handle . result ( ) ;
285+ yield {
286+ ...base ,
287+ exitCode : result . exitCode ,
288+ stdout : truncate ( result . stdout , maxBytes ) ,
289+ stderr : truncate ( result . stderr , maxBytes ) ,
290+ ...( result . value === undefined ? { } : { result : result . value } ) ,
291+ } ;
292+ } catch ( err ) {
293+ yield { ...base , error : errorMessage ( err ) } ;
294+ }
277295 }
278296 } ,
279297 } ) ;
0 commit comments