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
17 changes: 17 additions & 0 deletions src/agents/__tests__/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
22 changes: 19 additions & 3 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<ChildRunContext, 'signal'> & { signal: AbortSignal }

/** The host `createAgentRuntime` takes here: the core's, with each child's `signal` an `AbortSignal`. */
export type JsAgentHost = Omit<AgentHost, 'childModel' | 'childTools' | 'execChildTool' | 'childSystemPrompt' | 'createSignal'> & {
childModel?: ((ctx: JsChildRunContext) => CallModel | null | undefined) | null
childTools: (ctx: JsChildRunContext) => ReturnType<AgentHost['childTools']>
execChildTool: (call: ToolCall, ctx: JsChildRunContext) => Promise<ToolRun>
childSystemPrompt: (ctx: JsChildRunContext) => string
/** Each child's stop signal; default an `AbortController`'s. */
createSignal?: (() => { signal: AbortSignal; stop: () => void }) | null
}
9 changes: 9 additions & 0 deletions tish/agents/runtime.tish
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading