Disable the agent todo tool and hide the Agenda UI in the desktop app (#13530)

* remove todo tool and Agenda UI, keep schedule-only tasks tool

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* chore: biome formatting fixes

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* restore agenda backend; disable todo kind behind a flag instead of deleting

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* keep agenda automation pump idle while the todo tool is disabled

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* remove todo tool and Agenda UI altogether (revert the disable-flag hybrid)

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* restore all agenda code to main state

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* disable agent todo tool and hide Agenda UI behind flags

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-08-24 19:35:51 -07:00
committed by GitHub
parent 6e09e81a79
commit 83b2588c9c
14 changed files with 455 additions and 99 deletions
@@ -0,0 +1,131 @@
// @vitest-environment jsdom
// Covers the shipped state of the Agenda feature: with AGENDA_UI_ENABLED
// false (the real flag value), the sidebar Agenda toggle and the welcome
// quick actions stay hidden and no agenda commands are issued. The
// feature-flag mock in agent-sidebar.test.tsx and welcome-chat.test.tsx
// forces the flag on to keep exercising the dormant UI.
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSidebar } from "@/components/agent-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import type { UseSessionHistoryResult } from "@/hooks/use-session-history";
const desktopMocks = vi.hoisted(() => ({
invoke: vi.fn(),
listAgendaTasks: vi.fn(),
getAgendaAutomationPolicy: vi.fn(),
subscribe: vi.fn(() => () => undefined),
subscribeTransportState: vi.fn(() => () => undefined),
}));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
desktopMocks.invoke.mockRejectedValue(new Error("not available in test"));
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
function makeSessionHistory(): UseSessionHistoryResult {
return {
deleteThread: vi.fn(),
forkThread: vi.fn(),
hasLoadedHistory: true,
isLoadingMore: false,
loadOlderSessions: vi.fn(),
loadMoreSessions: vi.fn(),
mayHaveMoreSessions: false,
openThread: vi.fn(),
pendingAction: null,
renameThread: vi.fn(),
threads: [],
unreadSessionIds: new Set<string>(),
} as unknown as UseSessionHistoryResult;
}
describe("Agenda UI hidden by default", () => {
it("renders the sidebar without the Agenda toggle and issues no agenda commands", async () => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
onHome={vi.fn()}
onNewThread={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory()}
setView={vi.fn()}
settingsSection="General"
view="chat"
workspaceRoot="/projects/current"
/>
</SidebarProvider>,
);
await Promise.resolve();
});
expect(container.querySelector('[aria-label="Show Agenda"]')).toBeNull();
expect(
container.querySelector('[aria-label="New Session"]'),
).not.toBeNull();
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
expect(desktopMocks.getAgendaAutomationPolicy).not.toHaveBeenCalled();
});
it("renders the welcome screen without agenda quick actions or agenda fetches", async () => {
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: "/projects/project-1",
workspaces: ["/projects/project-1"],
listWorkspaces: vi.fn(async () => ["/projects/project-1"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<WelcomeScreen
active
body={null}
composer={null}
gitBranch="main"
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onSwitchGitBranch={vi.fn(async () => true)}
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
expect(container.querySelector("[data-welcome-hero]")).not.toBeNull();
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
});
});
@@ -30,6 +30,10 @@ const desktopMocks = vi.hoisted(() => ({
}));
const { invoke } = desktopMocks;
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
// The Agenda UI ships hidden for now; these tests force the flag on so they
// keep guarding the dormant feature. agenda-ui-hidden.test.tsx covers the
// shipped (hidden) state.
vi.mock("@/lib/feature-flags", () => ({ AGENDA_UI_ENABLED: true }));
let container: HTMLDivElement;
let root: Root;
@@ -114,6 +114,7 @@ import {
productNameForVersion,
} from "@/lib/app-channel";
import { desktopClient } from "@/lib/desktop-client";
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
import {
ALL_SESSION_SOURCES,
@@ -326,9 +327,11 @@ export function AgentSidebar({
workspaceRoot: agendaWorkspaceRoot,
limit: 200,
},
view !== "settings",
AGENDA_UI_ENABLED && view !== "settings",
);
const agendaAutomation = useAgendaAutomation(
AGENDA_UI_ENABLED && view !== "settings",
);
const agendaAutomation = useAgendaAutomation(view !== "settings");
useEffect(() => {
if (view === "settings") {
@@ -795,31 +798,37 @@ export function AgentSidebar({
</div>
{!isCollapsed ? (
<div className="flex items-center gap-1">
<Button
aria-label={
sidebarContent === "agenda" ? "Show Sessions" : "Show Agenda"
}
aria-pressed={sidebarContent === "agenda"}
className={cn(
"relative size-8 shrink-0 justify-center px-0",
sidebarContent === "agenda" &&
"bg-surface-hover text-sidebar-foreground",
)}
onClick={toggleSidebarContent}
title={
sidebarContent === "agenda" ? "Show Sessions" : "Show Agenda"
}
type="button"
variant="sidebarItem"
>
<ClipboardList className="size-4" />
{hasNewTodoTasks ? (
<span
className="absolute right-1 top-1 size-1.5 rounded-full bg-primary"
data-testid="new-todo-indicator"
/>
) : null}
</Button>
{AGENDA_UI_ENABLED ? (
<Button
aria-label={
sidebarContent === "agenda"
? "Show Sessions"
: "Show Agenda"
}
aria-pressed={sidebarContent === "agenda"}
className={cn(
"relative size-8 shrink-0 justify-center px-0",
sidebarContent === "agenda" &&
"bg-surface-hover text-sidebar-foreground",
)}
onClick={toggleSidebarContent}
title={
sidebarContent === "agenda"
? "Show Sessions"
: "Show Agenda"
}
type="button"
variant="sidebarItem"
>
<ClipboardList className="size-4" />
{hasNewTodoTasks ? (
<span
className="absolute right-1 top-1 size-1.5 rounded-full bg-primary"
data-testid="new-todo-indicator"
/>
) : null}
</Button>
) : null}
<Button
aria-label="New Session"
className="size-8 shrink-0 justify-center px-0"
@@ -863,7 +872,7 @@ export function AgentSidebar({
onSelect={openSettingsSection}
/>
</div>
) : sidebarContent === "agenda" ? (
) : AGENDA_UI_ENABLED && sidebarContent === "agenda" ? (
<AgendaSection
automatic={
agendaAutomation.policy !== null &&
@@ -21,6 +21,10 @@ vi.mock("@/lib/desktop-client", () => ({
subscribeTransportState: vi.fn(() => () => undefined),
},
}));
// The Agenda UI ships hidden for now; these tests force the flag on so they
// keep guarding the dormant feature. agenda-ui-hidden.test.tsx covers the
// shipped (hidden) state.
vi.mock("@/lib/feature-flags", () => ({ AGENDA_UI_ENABLED: true }));
let container: HTMLDivElement;
let root: Root;
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
import { useWorkspace } from "@/contexts/workspace-context";
import { isAgendaTaskExpired, useAgendaTasks } from "@/hooks/use-agenda-tasks";
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils";
import { SessionContent } from "./session-content";
import { WelcomeHero } from "./welcome-hero";
@@ -49,7 +50,7 @@ export function WelcomeScreen({
statuses: ["pending_approval", "approved", "in_progress", "failed"],
limit: 8,
},
active && workspaceRoot.trim().length > 0,
AGENDA_UI_ENABLED && active && workspaceRoot.trim().length > 0,
);
const [runningTaskId, setRunningTaskId] = useState<string | null>(null);
const [reviewTask, setReviewTask] = useState<AgendaTaskRecord | null>(null);
@@ -168,7 +169,7 @@ export function WelcomeScreen({
{active ? composer : <SessionContent>{composer}</SessionContent>}
</div>
{active ? (
{active && AGENDA_UI_ENABLED ? (
<>
<AgentQuickActions
actions={actions}
@@ -0,0 +1,8 @@
/**
* The Agenda (Todo) UI is temporarily hidden while its UX is reworked, in
* lockstep with `AGENDA_TODO_TOOL_ENABLED` in the hub server transport, which
* disables the agent-facing todo kind of the `tasks` tool. All Agenda
* components, hooks, and sidecar plumbing stay in the codebase; flip this back
* to true (together with the hub flag) to restore the feature.
*/
export const AGENDA_UI_ENABLED = false;
+7
View File
@@ -558,6 +558,13 @@ agent-team task board. Shared, browser-safe contracts use `AgendaTaskRecord`
and `AgendaTaskRunRecord`; orchestration and persistence remain in
`@cline/core`.
> **Status:** the agent-facing `kind: "todo"` half of the `tasks` tool and the
> desktop Agenda UI are temporarily disabled while the Agenda UX is reworked
> (`AGENDA_TODO_TOOL_ENABLED` in `hub-server-transport.ts` and
> `AGENDA_UI_ENABLED` in the desktop webview). The backend described below —
> the manager, storage, `task.*` Hub commands, and desktop plumbing — stays
> fully wired, and the schedule kind remains active.
### Authority and persistence
- A Hub process owns one Agenda task manager. Its
@@ -92,7 +92,7 @@ const BASE_TOOL_CATALOG: readonly RuntimeToolCatalogEntry[] = [
{
id: "tasks",
description:
"Create and manage durable Todo items or explicitly requested one-time and recurring agent schedules.",
"Create and manage explicitly requested one-time and recurring agent schedules.",
headlessToolNames: ["tasks"],
unavailableClientTypes: ["cli", "vscode"],
},
@@ -327,6 +327,10 @@ describe("Hub agenda task vertical slice", () => {
}),
);
// While the Agenda todo tool and UI are disabled, the hub keeps the
// automation pump idle: a persisted auto_start policy must not
// approve or start eligible work even once an approval-capable
// client registers, because no surface remains to supervise it.
const automated = await transport.handleCommand({
version: "v1",
command: "task.create",
@@ -358,17 +362,6 @@ describe("Hub agenda task vertical slice", () => {
},
},
});
await vi.waitFor(async () => {
const waiting = await transport.handleCommand({
version: "v1",
command: "task.get",
clientId: "desktop",
payload: { taskId: automatedTask.taskId },
});
expect(waiting.payload?.task).toMatchObject({ status: "approved" });
});
expect(startSession).toHaveBeenCalledTimes(1);
await transport.handleCommand({
version: "v1",
command: "client.register",
@@ -380,16 +373,19 @@ describe("Hub agenda task vertical slice", () => {
capabilities: [{ name: "approval.respond" }],
},
});
await vi.waitFor(async () => {
const completed = await transport.handleCommand({
version: "v1",
command: "task.get",
clientId: "desktop",
payload: { taskId: automatedTask.taskId },
});
expect(completed.payload?.task).toMatchObject({ status: "completed" });
await new Promise((resolve) => setTimeout(resolve, 100));
const idle = await transport.handleCommand({
version: "v1",
command: "task.get",
clientId: "desktop",
payload: { taskId: automatedTask.taskId },
});
expect(startSession).toHaveBeenCalledTimes(2);
expect(idle.payload?.task).toMatchObject({
// User-created todos are approved on creation; automation must
// still never start them while it is disabled.
status: "approved",
});
expect(startSession).toHaveBeenCalledTimes(1);
} finally {
await transport.stop();
}
@@ -114,6 +114,18 @@ import {
isAgendaTaskCommand,
} from "./task-command-service";
/**
* The agent-facing `kind: "todo"` half of the `tasks` tool and the Agenda
* automation pump are temporarily disabled while the Agenda UX is reworked;
* the desktop Agenda UI is hidden for the same reason. Automation must stay
* off with the UI hidden: a previously persisted `auto_start`/`unattended`
* policy would otherwise keep starting eligible tasks with no surface left to
* inspect, pause, or cancel them. The Agenda backend (manager, `task.*` Hub
* commands, storage, persisted policies) stays fully wired so flipping this
* back on restores the feature.
*/
const AGENDA_TODO_TOOL_ENABLED = false;
const SETTINGS_TYPES = new Set<CoreSettingsType>([
"skills",
"workflows",
@@ -274,6 +286,7 @@ export class HubServerTransport implements NativeHubTransport {
};
this.tasks = new AgendaTaskManager({
...options.taskOptions,
automationEnabled: AGENDA_TODO_TOOL_ENABLED,
runtime: {
isInteractiveClientAvailable: () =>
[...this.clients.values()].some((client) =>
@@ -327,29 +340,33 @@ export class HubServerTransport implements NativeHubTransport {
this.scheduleCommands = new HubScheduleCommandService(this.schedules);
this.sessionTools.push(
createTasksTool({
todo: {
manager: this.tasks,
telemetry: options.telemetry,
resolveSessionDefaults: async (sessionId) => {
const session = await this.sessionHost.getSession(sessionId);
if (!session) return undefined;
const projectWorkspace = !isChatWorkspacePath(session.workspaceRoot)
? session.workspaceRoot
: undefined;
return {
workspaceRoot: projectWorkspace,
cwd: projectWorkspace ? session.cwd : undefined,
modelSelection: {
providerId: session.provider,
modelId: session.model,
todo: AGENDA_TODO_TOOL_ENABLED
? {
manager: this.tasks,
telemetry: options.telemetry,
resolveSessionDefaults: async (sessionId) => {
const session = await this.sessionHost.getSession(sessionId);
if (!session) return undefined;
const projectWorkspace = !isChatWorkspacePath(
session.workspaceRoot,
)
? session.workspaceRoot
: undefined;
return {
workspaceRoot: projectWorkspace,
cwd: projectWorkspace ? session.cwd : undefined,
modelSelection: {
providerId: session.provider,
modelId: session.model,
},
originTaskId:
typeof session.metadata?.agendaTaskId === "string"
? session.metadata.agendaTaskId
: undefined,
};
},
originTaskId:
typeof session.metadata?.agendaTaskId === "string"
? session.metadata.agendaTaskId
: undefined,
};
},
},
}
: undefined,
scheduled: {
schedules: this.schedules,
telemetry: options.telemetry,
@@ -372,7 +389,9 @@ export class HubServerTransport implements NativeHubTransport {
},
}) as AgentTool,
);
this.sessionExtensions.push(createTasksPromptExtension());
this.sessionExtensions.push(
createTasksPromptExtension({ todoEnabled: AGENDA_TODO_TOOL_ENABLED }),
);
this.settings = options.settingsService ?? new CoreSettingsService();
if (options.cronOptions) {
this.cronService = new CronService({
@@ -29,7 +29,10 @@ function future(days = 7): string {
function createHarness(
result: "completed" | "failed" = "completed",
options: { interactiveClientAvailable?: boolean } = {},
options: {
interactiveClientAvailable?: boolean;
automationEnabled?: boolean;
} = {},
) {
const root = mkdtempSync(join(tmpdir(), "cline-agenda-manager-"));
const events: string[] = [];
@@ -51,6 +54,7 @@ function createHarness(
dbPath: join(root, "tasks.db"),
globalSpecsDir: join(root, "specs"),
watchFiles: false,
automationEnabled: options.automationEnabled,
publish: (event) => events.push(event),
});
managers.push(manager);
@@ -628,6 +632,51 @@ describe("AgendaTaskManager", () => {
expect(runtime.startSession).toHaveBeenCalledTimes(1);
});
it("keeps a persisted automation policy idle when automation is disabled", async () => {
const { root, manager, runtime } = createHarness("completed", {
automationEnabled: false,
});
await manager.start();
await manager.setAutomationPolicy(
{
scopeKey: "global",
mode: "unattended",
applyToAgentCreated: true,
maxConcurrentRuns: 1,
maxChainDepth: 3,
maxStartsPerHour: 20,
},
{ kind: "user", clientId: "desktop" },
);
const task = await createPending(manager);
manager.notifyAutomationReadinessChanged();
await new Promise((resolve) => setTimeout(resolve, 100));
expect((await manager.getTask(task.taskId))?.status).toBe(
"pending_approval",
);
expect(runtime.startSession).not.toHaveBeenCalled();
await manager.dispose();
// A restarted Hub with the same store and the flag still off must not
// pick the persisted unattended policy back up either.
const restarted = new AgendaTaskManager({
runtime,
dbPath: join(root, "tasks.db"),
globalSpecsDir: join(root, "specs"),
watchFiles: false,
automationEnabled: false,
});
managers.push(restarted);
await restarted.start();
await new Promise((resolve) => setTimeout(resolve, 100));
expect((await restarted.getTask(task.taskId))?.status).toBe(
"pending_approval",
);
expect(runtime.startSession).not.toHaveBeenCalled();
});
it("only automates tasks in the policy workspace", async () => {
const { root, manager, runtime } = createHarness();
await manager.start();
@@ -80,6 +80,12 @@ export interface AgendaTaskManagerOptions {
globalSpecsDir?: string;
watcherDebounceMs?: number;
watchFiles?: boolean;
/**
* Set to false to keep the automation pump idle regardless of persisted
* automation policies. Policies stay stored untouched and manual approve/run
* commands keep working; nothing is auto-approved or auto-started.
*/
automationEnabled?: boolean;
logger?: BasicLogger;
publish?: (
event: AgendaTaskManagerEventName,
@@ -272,6 +278,7 @@ export class AgendaTaskManager implements AgendaTaskManagerApi {
private readonly backgroundRuns = new Set<Promise<void>>();
private maintenanceTimer?: ReturnType<typeof setInterval>;
private readonly queuedAutomationScopes = new Set<string>();
private readonly automationEnabled: boolean;
private automationPumping = false;
private automationPolicyGeneration = 0;
private started = false;
@@ -288,6 +295,7 @@ export class AgendaTaskManager implements AgendaTaskManagerApi {
options.watcherDebounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS,
);
this.watchFiles = options.watchFiles !== false;
this.automationEnabled = options.automationEnabled !== false;
this.logger = options.logger ?? noopBasicLogger;
this.publishEvent = options.publish ?? (() => {});
}
@@ -1222,7 +1230,7 @@ export class AgendaTaskManager implements AgendaTaskManagerApi {
}
private queueAutomation(scopeKey?: string): void {
if (this.disposed) return;
if (this.disposed || !this.automationEnabled) return;
if (scopeKey) {
this.queuedAutomationScopes.add(scopeKey);
} else {
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
createTasksPromptExtension,
createTasksTool,
SCHEDULED_TASKS_SYSTEM_PROMPT_RULE,
TASKS_SYSTEM_PROMPT_RULE,
} from "./task-tool";
@@ -121,4 +122,70 @@ describe("tasks agent tool", () => {
whenToolAvailable: "tasks",
});
});
describe("with the Todo kind disabled", () => {
function scheduledOnlyOptions() {
const manager = {
listTasks: vi.fn(async () => []),
};
const schedules = {
listSchedules: vi.fn(() => []),
};
return {
manager,
schedules,
tool: createTasksTool({
scheduled: {
schedules: schedules as never,
resolveSessionDefaults: async () => ({
workspaceRoot: process.cwd(),
interactive: true,
}),
},
}),
};
}
it("advertises only the scheduled kind", () => {
const { tool } = scheduledOnlyOptions();
const schema = tool.inputSchema as Record<string, unknown>;
expect(schema.type).toBe("object");
expect(schema.properties).toMatchObject({
kind: { enum: ["scheduled"] },
});
expect(schema.properties).not.toHaveProperty("title");
expect(tool.description).not.toContain("Todo");
});
it("routes scheduled operations and rejects todo requests", async () => {
const { manager, schedules, tool } = scheduledOnlyOptions();
await expect(
tool.execute({ kind: "scheduled", operation: "list" }, context),
).resolves.toMatchObject({ ok: true, kind: "scheduled", schedules: [] });
expect(schedules.listSchedules).toHaveBeenCalledOnce();
await expect(
tool.execute({ kind: "todo", operation: "list" } as never, context),
).resolves.toMatchObject({
ok: false,
kind: "todo",
error: { code: "invalid_tasks_input" },
});
expect(manager.listTasks).not.toHaveBeenCalled();
});
it("registers the schedule-only prompt rule", async () => {
const extension = createTasksPromptExtension({ todoEnabled: false });
const registerRule = vi.fn();
await extension.setup?.({ registerRule } as never, {});
expect(registerRule).toHaveBeenCalledWith({
id: "hub:task-guidance",
content: SCHEDULED_TASKS_SYSTEM_PROMPT_RULE,
whenToolAvailable: "tasks",
});
});
});
});
+76 -23
View File
@@ -27,6 +27,16 @@ Use the \`tasks\` tool to manage durable Todo items and explicitly requested sch
- Todo instructions and scheduled prompts must be self-contained. Include the goal, constraints, relevant project context, and expected output.
- Never approve or start a Todo yourself. Only mutate schedules from an interactive user session, and only update, pause, resume, delete, or run one immediately when the user asks.`;
/** Guidance used while the Todo kind is disabled and only schedules remain. */
export const SCHEDULED_TASKS_SYSTEM_PROMPT_RULE = `# Tasks
Use the \`tasks\` tool only when the user explicitly asks Cline to execute work once at a future time or on a recurrence. Always pass \`kind: "scheduled"\`.
- One-time schedules require an exact future ISO 8601 \`run_at\` with an offset or Z. Recurring schedules require a five-field \`cron_pattern\` and may include an IANA \`timezone\`.
- Never create a schedule proactively. Check existing schedules before creating a likely duplicate.
- Make the scheduled \`prompt\` self-contained because it runs in a new unattended session. Include the goal, constraints, relevant project context, and expected output.
- Only update, pause, resume, delete, or run a schedule immediately when the user asks for that action.`;
const TodoRequestSchema = TodoTaskInputSchema.extend({
kind: z.literal("todo"),
}).strict();
@@ -40,25 +50,39 @@ export const TasksToolInputSchema = z.discriminatedUnion("kind", [
ScheduledRequestSchema,
]);
const ScheduledOnlyTasksToolInputSchema = z.discriminatedUnion("kind", [
ScheduledRequestSchema,
]);
const TASKS_OPERATIONS = [
"create",
"update",
"list",
"get",
"pause",
"resume",
"delete",
"run_now",
] as const;
// Anthropic requires a plain object at the top level of a tool input schema and
// rejects the oneOf emitted by a discriminated union. Advertise the union of
// both domains' fields as one object, then retain strict domain validation with
// TasksToolInputSchema inside execute().
// the enabled domains' fields as one object, then retain strict domain
// validation inside execute().
const TasksToolProviderInputSchema = z
.object({
...ScheduledTaskInputSchema.shape,
...TodoTaskInputSchema.shape,
kind: z.enum(["todo", "scheduled"]),
operation: z.enum([
"create",
"update",
"list",
"get",
"pause",
"resume",
"delete",
"run_now",
]),
operation: z.enum(TASKS_OPERATIONS),
})
.strict();
const ScheduledOnlyTasksToolProviderInputSchema = z
.object({
...ScheduledTaskInputSchema.shape,
kind: z.enum(["scheduled"]),
operation: z.enum(TASKS_OPERATIONS),
})
.strict();
@@ -74,7 +98,12 @@ export type TasksToolResult =
};
export interface CreateTasksToolOptions {
todo: TodoTaskOperationOptions;
/**
* Omit to disable the Todo kind: the tool then advertises and accepts only
* `kind: "scheduled"` while the Agenda backend stays intact for hosts that
* re-enable it.
*/
todo?: TodoTaskOperationOptions;
scheduled: ScheduleTaskOperationOptions;
}
@@ -86,18 +115,27 @@ export interface CreateTasksToolOptions {
export function createTasksTool(
options: CreateTasksToolOptions,
): AgentTool<TasksToolInput, TasksToolResult> {
const todoOptions = options.todo;
return createTool<TasksToolInput, TasksToolResult>({
name: TASKS_TOOL_NAME,
description:
"Create and manage reviewed Todo items or explicitly requested scheduled agent work. " +
'Use kind "todo" for durable Agenda items that require user or automation approval. ' +
'Use kind "scheduled" for one-time or recurring autonomous execution. ' +
"Todo available_at is not an execution timer; schedules use run_at or cron_pattern.",
inputSchema: zodToJsonSchema(TasksToolProviderInputSchema),
description: todoOptions
? "Create and manage reviewed Todo items or explicitly requested scheduled agent work. " +
'Use kind "todo" for durable Agenda items that require user or automation approval. ' +
'Use kind "scheduled" for one-time or recurring autonomous execution. ' +
"Todo available_at is not an execution timer; schedules use run_at or cron_pattern."
: 'Create and manage explicitly requested scheduled agent work with kind "scheduled". ' +
"One-time schedules use a future ISO 8601 run_at; recurring schedules use a five-field cron_pattern.",
inputSchema: zodToJsonSchema(
todoOptions
? TasksToolProviderInputSchema
: ScheduledOnlyTasksToolProviderInputSchema,
),
retryable: false,
maxRetries: 0,
execute: async (rawInput, context) => {
const parsed = TasksToolInputSchema.safeParse(rawInput);
const parsed = (
todoOptions ? TasksToolInputSchema : ScheduledOnlyTasksToolInputSchema
).safeParse(rawInput);
if (!parsed.success) {
return {
ok: false,
@@ -116,8 +154,18 @@ export function createTasksTool(
}
if (parsed.data.kind === "todo") {
if (!todoOptions) {
return {
ok: false,
kind: "todo",
error: {
code: "invalid_tasks_input",
message: 'kind "todo" is not available in this session',
},
};
}
const result = await executeTodoTaskOperation(
options.todo,
todoOptions,
parsed.data,
context,
);
@@ -135,14 +183,19 @@ export function createTasksTool(
}
/** Adds selection and safety guidance only when the unified tool is enabled. */
export function createTasksPromptExtension(): AgentExtension {
export function createTasksPromptExtension(options?: {
todoEnabled?: boolean;
}): AgentExtension {
return {
name: "hub-task-guidance",
manifest: { capabilities: ["rules"] },
setup: (api) => {
api.registerRule({
id: "hub:task-guidance",
content: TASKS_SYSTEM_PROMPT_RULE,
content:
options?.todoEnabled === false
? SCHEDULED_TASKS_SYSTEM_PROMPT_RULE
: TASKS_SYSTEM_PROMPT_RULE,
whenToolAvailable: TASKS_TOOL_NAME,
});
},