Skip to content
Open
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
40 changes: 40 additions & 0 deletions .changeset/think-scheduled-tasks-root-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@cloudflare/think": minor
---

Arm declared scheduled tasks on the root agent only, so a Think agent that also has sub-agents no longer runs each occurrence once per live sub-agent.

`_reconcileDeclaredScheduledTasks()` ran on every instance of the class with no root/facet guard. It resolves tasks from `getScheduledTasks()` — normally a static code declaration, so it returns the _same_ tasks on every instance — and keyed the arming on `stableHash(this.selfPath)`, which is the instance's own owner. Every live facet therefore armed a full private copy of the schedule, and each slot dispatched once per facet on top of the root:

```sql
SELECT owner_path, COUNT(*) FROM cf_agents_schedules
WHERE callback = '_runDeclaredScheduledTask' GROUP BY owner_path;
-- one full set for the root, plus one per sub-agent
```

Nothing surfaced this. The write-time duplicate warning only fires for non-idempotent `schedule()` calls in `onStart`, and declared tasks always pass `idempotent: true`; the alarm-time warning needs ten one-shot rows for a single callback, and these are spread across distinct owners. The docs made it worse — the "Scheduled responses" section of the sub-agents guide showed a static `getScheduledTasks()` on an agent with sub-agents, which is exactly the multiplying shape.

Declared tasks now arm on the root only. A new `getScheduledTasksScope()` hook returns `"root"` by default; return `"all"` to restore per-facet arming:

```typescript
export class PerUserAgent extends Think<Env> {
getScheduledTasksScope() {
return "all" as const;
}

async getScheduledTasks() {
const reminder = await this.getReminderForThisUser();
return reminder ? { reminder } : {};
}
}
```

That opt-in is the right choice only when `getScheduledTasks()` genuinely varies per sub-agent, since each one then owns an independent schedule.

**Migration.** Nothing to do for sub-agents that already armed rows.

Duplicate _executions_ stop immediately. `_runDeclaredScheduledTask` carries the same guard, so a dispatch into a root-scoped sub-agent returns before it runs the action and before the `finally` that arms the next occurrence. Declared tasks are armed as one-shot schedules, so the pending occurrence is consumed and deleted by the alarm loop and the recurrence dies out — no restart required.

The leftover rows are then cleaned up whenever the sub-agent next starts: a root-scoped facet reconciles against an empty task set, so the existing prune pass cancels each underlying Agent schedule and deletes the ledger row. Note this is keyed on the _sub-agent_ starting, not the root — evicting a parent does not evict its facets, and `subAgent()` only replays `onStart` for a facet that is not already running.

A one-time warning naming `getScheduledTasksScope()` is logged whenever a sub-agent declares tasks it will not arm. That covers both populations: agents upgrading with rows to prune, and agents declaring a sub-agent task for the first time under the new default, which would otherwise be inert with nothing in the ledger to notice.
22 changes: 22 additions & 0 deletions docs/think/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,7 @@ path.
| `getSystemPrompt()` | `"You are a helpful assistant."` | System prompt (fallback when no context blocks) |
| `getTools()` | `{}` | AI SDK `ToolSet` for the agentic loop |
| `getScheduledTasks()` | `{}` | Code-declared recurring prompts or handlers |
| `getScheduledTasksScope()` | `"root"` | Which instances arm the declared tasks — `"root"` (top-level agent only) or `"all"` (sub-agents too) |
| `getDefaultTimezone()` | `undefined` | Default timezone for wall-clock scheduled tasks |
| `getMessengers()` | `{}` | Messenger ingress and delivery declarations — see [Messengers](./messengers.md) |
| `getActions()` | `{}` | Server actions (idempotency, approvals, authorization) compiled into tools — see [Actions](./actions.md) |
Expand Down Expand Up @@ -1193,6 +1194,27 @@ work such as creating a Workflow run or writing a run ledger. Delivery is
at-least-once; use `idempotencyKey` or `occurrenceKey` for your own durable
idempotency.

Declared tasks are armed on the **root agent only**. Because
`getScheduledTasks()` is normally a static declaration, it returns the same
tasks on every instance of the class, so arming it on sub-agents as well would
dispatch each occurrence once per live sub-agent on top of the root. Override
`getScheduledTasksScope()` to return `"all"` when a class genuinely declares
different tasks per sub-agent — each sub-agent then owns an independent
schedule.

```typescript
export class PerUserAgent extends Think<Env> {
getScheduledTasksScope() {
return "all" as const;
}

async getScheduledTasks(): Promise<ThinkScheduledTasks> {
const reminder = await this.getReminderForThisUser();
return reminder ? { reminder } : {};
}
}
```

Static declarations reconcile on startup. If `getScheduledTasks()` reads
product-owned data that can change while the Durable Object is live, call
`internal_reconcileScheduledTasks()` after updating that data. During
Expand Down
39 changes: 37 additions & 2 deletions docs/think/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,11 @@ await this.saveMessages((current) => [

### Scheduled responses

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

```typescript
export class MyAgent extends Think<Env> {
Expand All @@ -224,13 +228,44 @@ export class MyAgent extends Think<Env> {
dailyReport: {
schedule: "every day at 09:00",
timezone: "UTC",
prompt: "Generate the daily report."
handler: async () => {
for (const { name } of this.listSubAgents(ChatAgent)) {
const chat = await this.subAgent(ChatAgent, name);
await chat.submitMessages([
{
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: "Generate the daily report." }]
}
]);
}
}
}
};
}
}
```

If a class genuinely declares _different_ tasks per sub-agent — for example when
`getScheduledTasks()` reads per-sub-agent state — opt in with
`getScheduledTasksScope()` and each sub-agent owns an independent schedule:

```typescript
export class PerUserAgent extends Think<Env> {
getScheduledTasksScope() {
return "all" as const;
}

async getScheduledTasks() {
const reminder = await this.getReminderForThisUser();
return reminder ? { reminder } : {};
}
}
```

A sub-agent that declares tasks without opting in logs a warning naming this
hook, so an inert schedule is never silent.

### Chaining from onChatResponse

Start a follow-up turn after the current one completes:
Expand Down
9 changes: 9 additions & 0 deletions packages/think/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ Script execution requires a Worker Loader binding:
| `getTools()` | `{}` | AI SDK `ToolSet` for the agentic loop |
| `getMessengers()` | `{}` | Messenger ingress and delivery declarations |
| `getScheduledTasks()` | `{}` | Code-declared recurring prompts |
| `getScheduledTasksScope()` | `"root"` | Which instances arm declared tasks — `"root"` or `"all"` (sub-agents too) |
| `getDefaultTimezone()` | `undefined` | Default timezone for wall-clock schedules |
| `maxSteps` | `10` | Max tool-call rounds per turn (property) |
| `sendReasoning` | `true` | Send reasoning chunks to chat clients |
Expand Down Expand Up @@ -517,6 +518,14 @@ work such as creating a Workflow run or writing a run ledger. Delivery is
at-least-once; use `idempotencyKey` or `occurrenceKey` for your own durable
idempotency.

Declared tasks are armed on the **root agent only**. Because
`getScheduledTasks()` is normally a static declaration, it returns the same
tasks on every instance of the class, so arming it on sub-agents as well would
dispatch each occurrence once per live sub-agent on top of the root. Override
`getScheduledTasksScope()` to return `"all"` when a class genuinely declares
different tasks per sub-agent — each sub-agent then owns an independent
schedule.

Static declarations reconcile on startup. If `getScheduledTasks()` reads
product-owned data that can change while the Durable Object is live, call
`internal_reconcileScheduledTasks()` after updating that data. During
Expand Down
50 changes: 50 additions & 0 deletions packages/think/src/tests/agents/think-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6108,6 +6108,13 @@ export class ThinkScheduledTasksTestAgent extends ThinkProgrammaticTestAgent {
return this.ctx.storage.get<string>("scheduledTasksDefaultTimezone");
}

override async getScheduledTasksScope(): Promise<"root" | "all"> {
return (
(await this.ctx.storage.get<"root" | "all">("scheduledTasksScope")) ??
"root"
);
}

override async getScheduledTasks(): Promise<ThinkScheduledTasks> {
const config =
(await this.ctx.storage.get<Record<string, ScheduledTaskConfigForTest>>(
Expand Down Expand Up @@ -6182,6 +6189,10 @@ export class ThinkScheduledTasksTestAgent extends ThinkProgrammaticTestAgent {
await this.ctx.storage.put("scheduledTasksDefaultTimezone", timezone);
}

async setScheduledTasksScopeForTest(scope: "root" | "all"): Promise<void> {
await this.ctx.storage.put("scheduledTasksScope", scope);
}

async reconcileScheduledTasksForTest(): Promise<void> {
await this.internal_reconcileScheduledTasks();
}
Expand Down Expand Up @@ -6344,6 +6355,45 @@ export class ThinkScheduledTasksTestAgent extends ThinkProgrammaticTestAgent {
await child.setDefaultTimezoneForTest(timezone);
}

async setChildScheduledTasksScopeForTest(
name: string,
scope: "root" | "all"
): Promise<void> {
const child = await this.subAgent(ThinkScheduledTasksTestAgent, name);
await child.setScheduledTasksScopeForTest(scope);
}

/**
* Force the child facet to restart, so the next `subAgent()` call replays
* its `onStart` — including the declared-task reconcile step. Storage is
* left intact, unlike `deleteSubAgent`.
*/
async restartChildForTest(name: string): Promise<void> {
this.abortSubAgent(ThinkScheduledTasksTestAgent, name, "restart-for-test");
}

async runChildDeclaredPayloadForTest(
name: string,
payload: DeclaredScheduledTaskPayloadForTest
): Promise<void> {
const child = await this.subAgent(ThinkScheduledTasksTestAgent, name);
await child.runDeclaredPayloadForTest(payload);
}

async getChildFirstDeclaredPayloadForTest(
name: string
): Promise<DeclaredScheduledTaskPayloadForTest> {
const child = await this.subAgent(ThinkScheduledTasksTestAgent, name);
return child.getFirstDeclaredPayloadForTest();
}

async listChildScheduledTaskHandlerEventsForTest(
name: string
): Promise<ScheduledTaskHandlerEventForTest[]> {
const child = await this.subAgent(ThinkScheduledTasksTestAgent, name);
return child.listScheduledTaskHandlerEventsForTest();
}

async reconcileChildScheduledTasksForTest(name: string): Promise<void> {
const child = await this.subAgent(ThinkScheduledTasksTestAgent, name);
await child.reconcileScheduledTasksForTest();
Expand Down
Loading
Loading