fix(think): arm declared scheduled tasks on the root agent only (#2056) · cloudflare/agents@20142e5 · GitHub
Skip to content

Commit 20142e5

Browse files
AntoniTokAntoni Tthreepointonecursoragent
authored
fix(think): arm declared scheduled tasks on the root agent only (#2056)
* fix(think): arm declared scheduled tasks on the root agent only Declared scheduled tasks were armed on every instance of a Think class, including sub-agents. `getScheduledTasks()` is normally a static declaration, so it returns the same tasks on every instance and an agent with N live sub-agents armed N+1 copies of one schedule, running each occurrence N+1 times. Add `getScheduledTasksScope()`, defaulting to "root". Sub-agents only arm when a class opts into "all". Unarmed sub-agents reconcile against an empty task set so the existing prune pass cancels their schedules and clears their rows, and `_runDeclaredScheduledTask` carries the same guard so a dispatch racing the prune cannot re-arm the occurrence being retired. Fixes #1877 * chore: format Co-authored-by: Cursor <cursoragent@cursor.com> * fix(think): warn about disarmed facet tasks only after the prune succeeds; align sub-agent doc example Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Antoni T <atokarski@cloudflare.com> Co-authored-by: Sunil Pai <18808+threepointone@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 040db5c commit 20142e5

8 files changed

Lines changed: 412 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions

‎docs/think/index.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ path.
400400
| `getSystemPrompt()` | `"You are a helpful assistant."` | System prompt (fallback when no context blocks) |
401401
| `getTools()` | `{}` | AI SDK `ToolSet` for the agentic loop |
402402
| `getScheduledTasks()` | `{}` | Code-declared recurring prompts or handlers |
403+
| `getScheduledTasksScope()` | `"root"` | Which instances arm the declared tasks — `"root"` (top-level agent only) or `"all"` (sub-agents too) |
403404
| `getDefaultTimezone()` | `undefined` | Default timezone for wall-clock scheduled tasks |
404405
| `getMessengers()` | `{}` | Messenger ingress and delivery declarations — see [Messengers](./messengers.md) |
405406
| `getActions()` | `{}` | Server actions (idempotency, approvals, authorization) compiled into tools — see [Actions](./actions.md) |
@@ -817,6 +818,27 @@ work such as creating a Workflow run or writing a run ledger. Delivery is
817818
at-least-once; use `idempotencyKey` or `occurrenceKey` for your own durable
818819
idempotency.
819820

821+
Declared tasks are armed on the **root agent only**. Because
822+
`getScheduledTasks()` is normally a static declaration, it returns the same
823+
tasks on every instance of the class, so arming it on sub-agents as well would
824+
dispatch each occurrence once per live sub-agent on top of the root. Override
825+
`getScheduledTasksScope()` to return `"all"` when a class genuinely declares
826+
different tasks per sub-agent — each sub-agent then owns an independent
827+
schedule.
828+
829+
```typescript
830+
export class PerUserAgent extends Think<Env> {
831+
getScheduledTasksScope() {
832+
return "all" as const;
833+
}
834+
835+
async getScheduledTasks(): Promise<ThinkScheduledTasks> {
836+
const reminder = await this.getReminderForThisUser();
837+
return reminder ? { reminder } : {};
838+
}
839+
}
840+
```
841+
820842
Static declarations reconcile on startup. If `getScheduledTasks()` reads
821843
product-owned data that can change while the Durable Object is live, call
822844
`internal_reconcileScheduledTasks()` after updating that data. During

‎docs/think/sub-agents.md‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,11 @@ await this.saveMessages((current) => [
215215

216216
### Scheduled responses
217217

218-
Trigger a recurring prompt turn with `getScheduledTasks()`:
218+
Declared scheduled tasks are armed on the **root agent only**.
219+
`getScheduledTasks()` is usually a static declaration, so it returns the same
220+
tasks on every instance of the class — arming it on sub-agents too would fire
221+
each occurrence once per live sub-agent on top of the root. Declare the task on
222+
the root and let it fan out to sub-agents itself:
219223

220224
```typescript
221225
export class MyAgent extends Think<Env> {
@@ -228,13 +232,44 @@ export class MyAgent extends Think<Env> {
228232
dailyReport: {
229233
schedule: "every day at 09:00",
230234
timezone: "UTC",
231-
prompt: "Generate the daily report."
235+
handler: async () => {
236+
for (const { name } of this.listSubAgents(ChildAgent)) {
237+
const chat = await this.subAgent(ChildAgent, name);
238+
await chat.submitMessages([
239+
{
240+
id: crypto.randomUUID(),
241+
role: "user",
242+
parts: [{ type: "text", text: "Generate the daily report." }]
243+
}
244+
]);
245+
}
246+
}
232247
}
233248
};
234249
}
235250
}
236251
```
237252

253+
If a class genuinely declares _different_ tasks per sub-agent — for example when
254+
`getScheduledTasks()` reads per-sub-agent state — opt in with
255+
`getScheduledTasksScope()` and each sub-agent owns an independent schedule:
256+
257+
```typescript
258+
export class PerUserAgent extends Think<Env> {
259+
getScheduledTasksScope() {
260+
return "all" as const;
261+
}
262+
263+
async getScheduledTasks() {
264+
const reminder = await this.getReminderForThisUser();
265+
return reminder ? { reminder } : {};
266+
}
267+
}
268+
```
269+
270+
A sub-agent that declares tasks without opting in logs a warning naming this
271+
hook, so an inert schedule is never silent.
272+
238273
### Chaining from onChatResponse
239274

240275
Start a follow-up turn after the current one completes:

‎packages/think/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ Script execution requires a Worker Loader binding:
364364
| `getTools()` | `{}` | AI SDK `ToolSet` for the agentic loop |
365365
| `getMessengers()` | `{}` | Messenger ingress and delivery declarations |
366366
| `getScheduledTasks()` | `{}` | Code-declared recurring prompts |
367+
| `getScheduledTasksScope()` | `"root"` | Which instances arm declared tasks — `"root"` or `"all"` (sub-agents too) |
367368
| `getDefaultTimezone()` | `undefined` | Default timezone for wall-clock schedules |
368369
| `maxSteps` | `10` | Max tool-call rounds per turn (property) |
369370
| `sendReasoning` | `true` | Send reasoning chunks to chat clients |
@@ -546,6 +547,14 @@ work such as creating a Workflow run or writing a run ledger. Delivery is
546547
at-least-once; use `idempotencyKey` or `occurrenceKey` for your own durable
547548
idempotency.
548549

550+
Declared tasks are armed on the **root agent only**. Because
551+
`getScheduledTasks()` is normally a static declaration, it returns the same
552+
tasks on every instance of the class, so arming it on sub-agents as well would
553+
dispatch each occurrence once per live sub-agent on top of the root. Override
554+
`getScheduledTasksScope()` to return `"all"` when a class genuinely declares
555+
different tasks per sub-agent — each sub-agent then owns an independent
556+
schedule.
557+
549558
Static declarations reconcile on startup. If `getScheduledTasks()` reads
550559
product-owned data that can change while the Durable Object is live, call
551560
`internal_reconcileScheduledTasks()` after updating that data. During

‎packages/think/src/tests/agents/think-session.ts‎

Lines changed: 50 additions & 0 deletions

0 commit comments

Comments
 (0)