From a60881c9e300b601582704b76e88d29437baccf5 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Tue, 13 Oct 2026 20:50:38 -0700 Subject: [PATCH] fix(agents): the parent's stop cancels a child at once, and JS hosts get AbortSignal-typed contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tish runtime checked the parent's signal only between a child's rounds, so a child in a long model call or tool kept going after the parent turn's Stop (SpawnRequest.signal says it stops the child). A platform signal now also stops the child's control at once — cancelling its request in flight — while a native host keeps the between-round check. createAgentRuntime here takes a JsAgentHost whose callbacks see ctx.signal as the AbortSignal it is. --- src/agents/__tests__/agents.test.ts | 17 +++++++++++++++++ src/agents/index.ts | 22 +++++++++++++++++++--- tish/agents/runtime.tish | 9 +++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/agents/__tests__/agents.test.ts b/src/agents/__tests__/agents.test.ts index 3ad0405..94f8ef0 100644 --- a/src/agents/__tests__/agents.test.ts +++ b/src/agents/__tests__/agents.test.ts @@ -262,3 +262,20 @@ describe('stop signals in a JS host', () => { release() }) }) + +describe("the parent's stop", () => { + it('stops a child in the middle of its model call when the parent signal aborts', async () => { + const h = host(() => []) + h.callModel = (_m, _t, _h, signal) => new Promise((_res, rej) => { + (signal as AbortSignal).addEventListener('abort', () => rej(new Error('aborted'))) + }) + const rt = createAgentRuntime(h) + const turn = new AbortController() + const r = await rt.spawn({ profileId: 'explorer', prompt: 'look', background: true, signal: turn.signal }) + const id = (r as { id: string }).id + await new Promise(res => setTimeout(res, 5)) + turn.abort() + const s = await rt.wait(id, { timeoutMs: 1000 }) + expect(s?.status).not.toBe('running') + }) +}) diff --git a/src/agents/index.ts b/src/agents/index.ts index c61148b..d04fe30 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -4,7 +4,8 @@ // source natively. This file adds what only a JavaScript host has: IndexedDB, and the platform's `fetch`. import { createHyperyModel as createCoreModel, type HyperyModelOptions as CoreModelOptions } from './core/hypery-model.js' -import { createAgentRuntime as createCoreRuntime, type AgentHost, type AgentRuntime } from './core/runtime.js' +import { createAgentRuntime as createCoreRuntime, type AgentHost, type AgentRuntime, type ChildRunContext } from './core/runtime.js' +import type { ToolCall, ToolRun } from './core/types.js' import type { CallModel } from './core/types.js' export * from './core/index.js' @@ -23,12 +24,27 @@ export function createHyperyModel(opts: HyperyModelOptions): CallModel { * default: tools and the model call can hand it to `fetch` or listen for `abort`, and stopping a child cancels its * requests in flight. A host's own `createSignal` wins. */ -export function createAgentRuntime(host: AgentHost): AgentRuntime { +export function createAgentRuntime(host: JsAgentHost): AgentRuntime { return createCoreRuntime({ ...host, createSignal: host.createSignal ?? (() => { const controller = new AbortController() return { signal: controller.signal, stop: () => controller.abort() } }), - }) + // The core types a child's signal as anything with `aborted`; here every one comes from `createSignal`, whose + // signals are AbortSignals — so the host's callbacks may rely on that. + } as unknown as AgentHost) +} + +/** A child's context in a JavaScript host: its `signal` is an `AbortSignal`. */ +export type JsChildRunContext = Omit & { signal: AbortSignal } + +/** The host `createAgentRuntime` takes here: the core's, with each child's `signal` an `AbortSignal`. */ +export type JsAgentHost = Omit & { + childModel?: ((ctx: JsChildRunContext) => CallModel | null | undefined) | null + childTools: (ctx: JsChildRunContext) => ReturnType + execChildTool: (call: ToolCall, ctx: JsChildRunContext) => Promise + childSystemPrompt: (ctx: JsChildRunContext) => string + /** Each child's stop signal; default an `AbortController`'s. */ + createSignal?: (() => { signal: AbortSignal; stop: () => void }) | null } diff --git a/tish/agents/runtime.tish b/tish/agents/runtime.tish index cdce888..5b1d34c 100644 --- a/tish/agents/runtime.tish +++ b/tish/agents/runtime.tish @@ -261,6 +261,15 @@ export fn createAgentRuntime(host: AgentHost): AgentRuntime { let run = async fn(session: AgentSession, profile: AgentProfile, prompt: string, forceReadOnly: boolean, parentSignal: StopSignal?, fresh: boolean): any { let control = typeof host.createSignal === "function" ? host.createSignal() : plainStopControl() controls.set(session.id, control) + // The parent's Stop stops this child. Its loop checks the parent between rounds on every target; a platform + // signal (a JS AbortSignal) also stops it at once, so a model call or tool in flight is cancelled too. + if (parentSignal !== null) { + if (parentSignal.aborted === true) { + control.stop() + } else if (typeof parentSignal.addEventListener === "function") { + parentSignal.addEventListener("abort", fn() { control.stop() }) + } + } let ctx = { session: session, profile: profile, readOnly: forceReadOnly || profile.readOnly === true, signal: control.signal } let tools = host.childTools(ctx).concat([REPORT_SPEC]) let messages = fresh