Cancelled because a sibling task failed: one tool error stops the others
On @openai/agents 0.18.0, the tool calls of one turn share a cancellation signal. One fatal failure aborts every other tool in the turn with SiblingCancellationError, and a tool that ignores details.signal keeps running, so its side effects land after the run has already thrown.
On this page
Quick answer (October 2026). On @openai/agents 0.18.0, when the model emits more than one tool call in a single turn, those calls run under one shared cancellation signal. If any one of them fails in a way the SDK treats as fatal, every other tool call in that turn gets its details.signal aborted with the reason SiblingCancellationError: Cancelled because a sibling task failed. A tool that does not read details.signal keeps running anyway, so its side effects still land, its return value is thrown away, and the model never sees it. The word sibling appears zero times in the Agents, Running Agents and Tools guides.
The nasty part is which failures count as fatal. An ordinary throw inside a tool does not: the SDK's default error handler turns it into a model-visible string and the run carries on. But set errorFunction: null, or let a tool timeout escalate with timeoutBehavior: 'raise_exception', and the same throw becomes fatal. One option on one tool changes what happens to every other tool beside it.
What this is measured on
@openai/agents0.18.0,openai7.23.0,zod4.1.13, Node 24.8.0.- A local fake Chat Completions endpoint, so no provider is contacted and no API key of any kind is used. The server is stateless: it answers from the request body alone, so every arm below starts from identical server behaviour.
Install:
npm init -y
npm install --save-exact @openai/agents@0.18.0 openai@7.23.0 zod@4.1.13
The server always answers the first request of a turn with two tool calls, and answers the second request (the one carrying the tool results) with a plain message.
// A stateless fake OpenAI Chat Completions endpoint.
// It answers from the request body alone, so every arm below starts from
// identical server behaviour and no provider is ever contacted.
const http = require('http');
const PORT = 8731;
const server = http.createServer(function (req, res) {
let raw = '';
req.on('data', function (chunk) { raw += chunk; });
req.on('end', function () {
let body = {};
try { body = JSON.parse(raw || '{}'); } catch (err) { body = {}; }
// Second request of the turn: the tool results came back, so answer.
const hasToolResult = (body.messages || []).some(function (m) { return m.role === 'tool'; });
const message = hasToolResult
? { role: 'assistant', content: 'done' }
: {
role: 'assistant',
content: null,
tool_calls: [
{ id: 'call_charge', type: 'function',
function: { name: 'charge_card', arguments: JSON.stringify({ amount: 4200 }) } },
{ id: 'call_tax', type: 'function',
function: { name: 'lookup_tax', arguments: JSON.stringify({ region: 'ES' }) } },
],
};
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
id: 'chatcmpl-local',
object: 'chat.completion',
created: 1,
model: body.model || 'local-model',
choices: [{ index: 0, message: message, finish_reason: hasToolResult ? 'stop' : 'tool_calls' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}));
});
});
server.listen(PORT, '127.0.0.1', function () {
console.log('capture server listening on ' + PORT);
});
Run it in its own terminal:
node capture-server.js
The harness
Two tools. charge_card is the expensive one and it is never the tool that fails. lookup_tax fails, in three different shapes, one per arm. charge_card records the moment the money moves, and optionally listens for details.signal.
const { Agent, run, tool, OpenAIChatCompletionsModel, setTracingDisabled } = require('@openai/agents');
const OpenAI = require('openai').default;
const { z } = require('zod');
setTracingDisabled(true);
const ARM = process.argv[2]; // caught | rethrown | timeout
const SLOW_LISTENS = process.argv[3] === 'listens';
const sideEffects = [];
let observedAbort = 'none';
let modelRequests = 0;
function countingFetch(url, init) {
modelRequests += 1;
return fetch(url, init);
}
function sleepUnlessAborted(ms, signal) {
return new Promise(function (resolve, reject) {
const id = setTimeout(resolve, ms);
if (!signal) return;
signal.addEventListener('abort', function () {
clearTimeout(id);
reject(signal.reason);
}, { once: true });
});
}
// The expensive, side-effecting tool. It is never the tool that fails.
const chargeCard = tool({
name: 'charge_card',
description: 'Charge the customer card.',
parameters: z.object({ amount: z.number() }),
async execute(input, context, details) {
const signal = details && details.signal;
if (signal) {
signal.addEventListener('abort', function () {
observedAbort = signal.reason.constructor.name + ': ' + signal.reason.message;
}, { once: true });
}
try {
await sleepUnlessAborted(300, SLOW_LISTENS ? signal : undefined);
} catch (err) {
sideEffects.push('charge_card ABORTED');
throw err;
}
sideEffects.push('charge_card CHARGED ' + input.amount); // the money moves here
return { ok: true };
},
});
// The tool that fails. Three shapes of failure, one per arm.
function failingTool() {
if (ARM === 'timeout') {
return tool({
name: 'lookup_tax',
description: 'Look up the tax rate.',
parameters: z.object({ region: z.string() }),
timeoutMs: 50,
timeoutBehavior: 'raise_exception',
async execute() {
await new Promise(function (resolve) { setTimeout(resolve, 5000); });
return 'never reached';
},
});
}
const options = {
name: 'lookup_tax',
description: 'Look up the tax rate.',
parameters: z.object({ region: z.string() }),
async execute() { throw new Error('tax service unreachable'); },
};
if (ARM === 'rethrown') {
options.errorFunction = null; // the one word that changes everything
}
return tool(options);
}
const agent = new Agent({
name: 'Checkout',
instructions: 'Charge the card and look up the tax.',
model: new OpenAIChatCompletionsModel(
new OpenAI({
baseURL: 'http://127.0.0.1:8731',
apiKey: 'not-a-real-key',
fetch: countingFetch,
}),
'local-model',
),
tools: [chargeCard, failingTool()],
});
function header() {
console.log('arm=' + ARM + ' charge_card ' + (SLOW_LISTENS ? 'listens for' : 'ignores') + ' the signal');
}
run(agent, 'check out').then(
function (result) {
header();
console.log(' run outcome : RESOLVED, finalOutput ' + JSON.stringify(result.finalOutput));
},
function (err) {
header();
console.log(' run outcome : THREW ' + err.constructor.name + ': ' + err.message);
},
).finally(function () {
// Wait past the slow tool's own duration, so an orphan has time to land.
setTimeout(function () {
console.log(' model requests: ' + modelRequests);
console.log(' sibling signal: ' + observedAbort);
console.log(' side effects : ' + JSON.stringify(sideEffects));
process.exit(0);
}, 600);
});
Arm 1, the control: an ordinary throw is not fatal
lookup_tax throws, with no errorFunction override. This is what almost every tool in almost every codebase looks like.
node sibling.js caught ignores
arm=caught charge_card ignores the signal
run outcome : RESOLVED, finalOutput "done"
model requests: 2
sibling signal: none
side effects : ["charge_card CHARGED 4200"]
The run resolves. There were two model requests, meaning the model received both tool results and produced a final answer. No sibling signal fired at all. The card was charged, which is correct, because nothing went wrong that should have stopped it.
This control is what makes the next arm attributable to one option rather than to tool failure in general.
Arm 2: errorFunction null makes the same throw fatal
Identical tool, identical throw, one extra line: errorFunction: null.
node sibling.js rethrown ignores
arm=rethrown charge_card ignores the signal
run outcome : THREW ToolCallError: Failed to run function tools: Error: tax service unreachable
model requests: 1
sibling signal: SiblingCancellationError: Cancelled because a sibling task failed.
side effects : ["charge_card CHARGED 4200"]
Three things changed at once.
- The run threw instead of resolving.
charge_cardreceived an abort ondetails.signal, with the reasonSiblingCancellationError: Cancelled because a sibling task failed.It never asked to be cancelled and nothing about it failed.- The card was still charged.
charge_cardignores the signal, so it ran to completion regardless.
Model requests also dropped from two to one. The model never saw the charge succeed. From the caller's point of view the run failed with tax service unreachable. From the payment processor's point of view, 4200 was taken.
That is the whole problem inside one five-line block: a run outcome that says THREW, and three lines below it a side effects line showing the charge went through.
Arm 3: a timeout on the other tool does exactly the same thing
An independent trigger, with no errorFunction anywhere. lookup_tax gets timeoutMs: 50 and timeoutBehavior: 'raise_exception', and sleeps for five seconds.
node sibling.js timeout ignores
arm=timeout charge_card ignores the signal
run outcome : THREW ToolTimeoutError: Tool 'lookup_tax' timed out after 50ms.
model requests: 1
sibling signal: SiblingCancellationError: Cancelled because a sibling task failed.
side effects : ["charge_card CHARGED 4200"]
Same sibling cancellation, same orphaned charge. Two unrelated-looking configuration choices, both of them on the failing tool, produce the same outcome on the tool sitting beside it, which mentions neither.
Arm 4: the fix, and it does work
The escape hatch is real. charge_card is handed details.signal as the third argument of execute. If it actually reads it, the abort lands where it should.
node sibling.js rethrown listens
arm=rethrown charge_card listens for the signal
run outcome : THREW ToolCallError: Failed to run function tools: Error: tax service unreachable
model requests: 1
sibling signal: SiblingCancellationError: Cancelled because a sibling task failed.
side effects : ["charge_card ABORTED"]
No charge. The run still throws the original tax error, which is right, and the expensive work stopped.
Note what did not change: the thrown error is still ToolCallError carrying tax service unreachable. The SiblingCancellationError never reaches the caller. It exists only inside the cancelled tool, on a signal most tools never read. If you are reading logs, the cancellation is invisible.
What the SDK remembers about the sibling that succeeded
Worth checking, because if the successful charge were recorded somewhere you could at least reconcile it later. Here charge_card finishes in 50 ms, well before the failure is noticed.
// What does the SDK itself remember about the sibling that succeeded?
const { Agent, run, tool, OpenAIChatCompletionsModel, setTracingDisabled } = require('@openai/agents');
const OpenAI = require('openai').default;
const { z } = require('zod');
setTracingDisabled(true);
const chargeCard = tool({
name: 'charge_card',
description: 'Charge the customer card.',
parameters: z.object({ amount: z.number() }),
async execute(input) {
await new Promise(function (resolve) { setTimeout(resolve, 50); });
return { ok: true, charged: input.amount }; // completes before the failure is noticed
},
});
const lookupTax = tool({
name: 'lookup_tax',
description: 'Look up the tax rate.',
parameters: z.object({ region: z.string() }),
errorFunction: null,
async execute() { throw new Error('tax service unreachable'); },
});
const agent = new Agent({
name: 'Checkout',
instructions: 'Charge the card and look up the tax.',
model: new OpenAIChatCompletionsModel(
new OpenAI({ baseURL: 'http://127.0.0.1:8731', apiKey: 'not-a-real-key' }),
'local-model',
),
tools: [chargeCard, lookupTax],
});
run(agent, 'check out').catch(function (err) {
const snapshot = JSON.parse(JSON.stringify(err.state));
const text = JSON.stringify(snapshot);
console.log('err.state present : ' + Boolean(err.state));
console.log('generatedItems : ' + snapshot.generatedItems.length);
console.log('completedToolInvocations : ' + JSON.stringify(snapshot.completedToolInvocations));
console.log('state mentions the charge : ' + (text.indexOf('"charged"') !== -1));
console.log('state mentions the request : ' + (text.indexOf('charge_card') !== -1));
setTimeout(function () { process.exit(0); }, 300);
});
node state.js
err.state present : true
generatedItems : 0
completedToolInvocations : []
state mentions the charge : false
state mentions the request : true
err.state exists, and it is empty where it matters. generatedItems is 0 and completedToolInvocations is an empty array. The state mentions charge_card only because it retains the model's request to charge. There is no record anywhere that the charge completed. The SDK's own account of the run says that tool never finished.
So there is no reconciliation surface. If you want to know the charge happened, your tool has to have written that down itself before the run died.
Where this lives in the source
Shipped in @openai/agents-core/dist/runner/siblingCancellation.js, from the openai-agents-js repository. The exported function is runWithSiblingCancellation, and the runner calls it from toolExecution.js for the set of tool runs in a turn.
The mechanism is small. It creates one AbortController, combines its signal with the parent signal once, and passes that same combined signal to every task in the turn. When the first task fails, cancelSiblings() aborts the controller, so every task sharing the signal is aborted at once. The reason object comes from createSiblingCancellationError() in utils/abortSignals.js, and its message is the string you saw above:
abortSignals.js, verbatim: Cancelled because a sibling task failed.
I measured the consequence with one sibling. The source shows it is one shared signal handed to every task, so the count does not change the mechanism.
Two details are worth knowing because they are not observable from outside.
There is an early return for a single task. The function starts by checking whether it has at most one task, and if so it skips the sibling controller entirely and runs the task against the parent signal. (Described rather than quoted: the comparison operator on that line is an angle bracket, which this site does not publish.) So none of this machinery exists on a turn where the model happened to call exactly one tool. Your tool's behaviour depends on how many tools the model decided to call beside it.
It waits for everyone. The tasks are collected with Promise.allSettled, and only then is the first failure rethrown. A sibling that ignores the signal is therefore not merely unobserved, it is actively waited for, and then its result is discarded.
Why errorFunction null is the sharp edge
The default error handler is what keeps an ordinary throw non-fatal. Remove it and the raw error propagates, which is exactly the fatal condition above.
You can remove it explicitly, as arm 2 does. You can also remove it implicitly: in tool.js, when no errorFunction is supplied, the SDK selects null if the tool has an outputSchema and the default handler otherwise. The Tools guide says the same thing in prose.
OpenAI Agents SDK, Tools guide, verbatim: When outputSchema is set, the result must satisfy that schema. The default handler is disabled so the original error is rethrown.
That sentence is about the tool you are configuring. It does not mention that disabling the handler also changes what happens to the other tools in the turn, because nothing in the guide mentions siblings at all.
Note that outputSchema is Responses-API only. On Chat Completions the SDK rejects it outright with Function tool outputSchema is only supported with the Responses API., which is why the arms above use errorFunction: null. Same fatal path, and that one works on both APIs.
What the docs do and do not say
Being precise, because two thirds of this is documented.
Documented. The split default is stated plainly.
Tools guide, verbatim: The default is error_as_result without outputSchema and raise_exception with it.
Also documented. That a timeout aborts the signal, and that the abort is cooperative.
Tools guide, verbatim: Timeouts abort details.signal, so long-running tools can stop promptly when they listen for cancellation.
That sentence is the one that saves you, and "when they listen for cancellation" is doing all the work. A tool that does not listen is protected by nothing.
Not documented. That a sibling failure is also a cause of that abort. Measured across the three guides with nav, header, sidebar and footer stripped:
Scroll to see more
| Guide | visible chars | occurrences of sibling |
|---|---|---|
| Tools | 36,888 | 0 |
| Running Agents | 32,311 | 0 |
| Agents | 18,844 | 0 |
In the Tools guide specifically, the word signal appears exactly twice in the body, and the literal details.signal exactly once. The two mentions are the parameter table for execute, where signal is listed among the things details carries, and the timeout sentence above. A reader with no timeouts configured has therefore been given no reason to think the signal can ever fire.
What to actually do
- If a tool has side effects, read
details.signal. It is the third argument ofexecute, afterinputandcontext, and it is an ordinary AbortSignal. Arm 4 is the whole fix. It also protects you from timeouts and from caller-side aborts, so it is worth doing regardless of siblings. - Treat
errorFunction: nullas a blast-radius decision, not a per-tool one. It converts that tool's failures into run-fatal failures, and run-fatal failures cancel the turn. - Same for
timeoutBehavior: 'raise_exception'. It is right when a timeout genuinely means the run is over. It is wrong when you only want the model told so it can carry on, and the difference shows up one tool over. - Make side-effecting tools idempotent, or keep your own ledger. The SDK keeps no record that a cancelled turn's successful tool completed, so a retry has no way to know.
- If a tool cannot be made interruptible, keep it out of the same turn as something fragile. On its own turn it takes the single-task path, where no sibling controller is created at all.
The habit generalises. The Vercel AI SDK hands tools an abort signal too: its tool calling documentation destructures abortSignal out of the second argument of execute and annotates the run-level option, verbatim, as signal that will be forwarded to tools. I did not measure whether that SDK propagates one tool's failure to its neighbours the way this one does, so do not read the comparison as a verdict on it; sibling is 0 on that page too, which tells you nothing either way. The point is only that reading the signal is cheap and portable. If you are wiring cancellation end to end rather than only inside the SDK, the pattern in cancelling an in-flight tool call from the browser is the other half of the same problem.
Graded negatives, so you do not repeat them
Things I expected and did not find.
- I expected an unhandled promise rejection. The timeout path uses
Promise.race, and the losing promise rejecting later looked like a crash waiting to happen. It is not:Promise.raceattaches handlers to every input promise, so a later rejection is ignored rather than unhandled. No arm above crashed the process. - I expected
outputSchemato be the cleanest demonstration. It is Responses-API only and errors out on Chat Completions, so it could not carry the harness. It remains a real implicit trigger, just one you can only reach on the Responses API. - I expected the successful sibling to appear in
err.state. It does not.generatedItems0,completedToolInvocationsempty. - I expected the thrown error to mention the cancellation. It never does. The run throws the first failure, and the
SiblingCancellationErrorstays inside the cancelled tool.
Reproducing the coverage check
"Nobody documents this" is a claim, not an observation, so here is the check. A Google search for the exact message Cancelled because a sibling task failed, in quotes, returns a single organic result, and it belongs to Prefect, an unrelated Python workflow tool that happens to use the same English sentence. Nothing discusses this SDK. The same engine does index this package's API strings, since a search combining timeoutBehavior with raise_exception returns the SDK's own generated reference page, so the near-zero is a real absence and not a blind spot in the instrument.
Written by
Sofia NievesSofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.
Frequently asked questions
What does "Cancelled because a sibling task failed" mean in the OpenAI Agents SDK?
It is the reason attached to details.signal when another tool call in the same turn failed fatally. The SDK runs the tool calls of one turn under a single shared AbortController, and the first fatal failure aborts that controller, so every other tool in the turn is cancelled at once. The message comes from createSiblingCancellationError in @openai/agents-core/dist/utils/abortSignals.js.
Does a tool that throws normally cancel the other tool calls?
No. That is the control arm in this article. With the default error handler in place, a throw is converted into a model-visible string, the run resolves, no sibling signal fires, and the other tools finish normally. The measured run shows two model requests and no cancellation.
Which tool failures are fatal enough to cancel the siblings?
Two were measured here. Setting errorFunction to null removes the default handler, so the raw error propagates. And timeoutBehavior set to raise_exception escalates a timeout into a thrown ToolTimeoutError. Both produced the same sibling cancellation on @openai/agents 0.18.0.
If my tool is cancelled, does its work actually stop?
Only if the tool reads the signal. The cancellation is cooperative. In the measured run a tool that ignores details.signal ran to completion and its side effect landed after the run had already thrown, while the identical tool reading the signal aborted and left no side effect behind.
Where is details.signal in a tool execute function?
It is the third argument, after input and context. The shape is ToolExecuteFunction, which takes input, an optional RunContext and an optional details object carrying the signal. Most examples omit the third argument entirely, which is why the signal is easy to miss.
Does the run result record that the cancelled turn's other tool succeeded?
No. On the thrown error, err.state exists but generatedItems is 0 and completedToolInvocations is an empty array. The state retains the model's request to call the tool and nothing about the tool completing, so there is no reconciliation surface and side-effecting tools need their own record.
Does this happen when the model calls only one tool?
No. runWithSiblingCancellation returns early when it has at most one task, running it against the parent signal and creating no sibling controller. So the behaviour depends on how many tools the model chose to call in that turn.
Is this documented?
Partly. The split timeout default and the fact that timeouts abort details.signal are both stated in the Tools guide. The sibling cause is not: the word sibling appears zero times across the Agents, Running Agents and Tools guides, and the only cause of an abort those guides name is a timeout.
Related tutorials
tool_search finds nothing unless the path is the exact tool name
Measured on @openai/agents 0.18.0: the built-in client-side tool_search loader matches paths against tool names by exact string equality, and a miss returns zero tools with no error and no warning. The failure arrives one turn later as an error that blames the model.
@openai/agents adds reasoning.effort and text.verbosity by matching your model name
The SDK chooses reasoning.effort and text.verbosity from how your model name is spelled, not from what the model supports. Measured on the wire on 0.18.0, including what it does to a custom gateway deployment name.
Cancel an In-Flight Claude Tool Call From the Browser
Wire AbortController from the browser into the Claude stream and your tools, cancel a run even after a reload, and clean up partial side effects safely.