mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 405e0124ac | |||
| 6643f71872 | |||
| 21b2b42e5e |
@@ -38,6 +38,7 @@ These commands are available within the interactive REPL.
|
|||||||
| `/mcp reload` | Restart and reload MCP servers |
|
| `/mcp reload` | Restart and reload MCP servers |
|
||||||
| `/extensions reload` | Reload all active extensions |
|
| `/extensions reload` | Reload all active extensions |
|
||||||
| `/help` | Show help for all commands |
|
| `/help` | Show help for all commands |
|
||||||
|
| `/loop` | Schedule a recurring task |
|
||||||
| `/quit` | Exit the interactive session |
|
| `/quit` | Exit the interactive session |
|
||||||
|
|
||||||
## CLI Options
|
## CLI Options
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Scheduled tasks
|
||||||
|
|
||||||
|
Scheduled tasks let you run prompts repeatedly, poll for status, or set one-time
|
||||||
|
reminders within a Gemini CLI session. You can use them to check the status of a
|
||||||
|
deployment, monitor a long-running build, or remind yourself to perform a task
|
||||||
|
later in your workflow.
|
||||||
|
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
> [!NOTE]
|
||||||
|
> Scheduled tasks are session-scoped. They only run while your Gemini CLI
|
||||||
|
> session is open and are cleared when you exit the CLI. For persistent
|
||||||
|
> scheduling, use your operating system's native scheduling tools (like `cron`
|
||||||
|
> or Task Scheduler).
|
||||||
|
|
||||||
|
## Schedule a recurring prompt with /loop
|
||||||
|
|
||||||
|
The `/loop` command is the fastest way to schedule a recurring prompt. You can
|
||||||
|
provide an optional interval and a prompt, and Gemini CLI schedules the task to
|
||||||
|
run in the background.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/loop 5m check if the deployment finished
|
||||||
|
```
|
||||||
|
|
||||||
|
If you don't provide an interval, the command defaults to 10 minutes.
|
||||||
|
|
||||||
|
### Interval syntax
|
||||||
|
|
||||||
|
Intervals use a simple numeric value followed by a unit character. Supported
|
||||||
|
units are `s` for seconds, `m` for minutes, `h` for hours, and `d` for days.
|
||||||
|
|
||||||
|
| Form | Example | Interval |
|
||||||
|
| :------------ | :-------------------------- | :--------------- |
|
||||||
|
| Leading token | `/loop 30m check the build` | Every 30 minutes |
|
||||||
|
| No interval | `/loop check the build` | Every 10 minutes |
|
||||||
|
|
||||||
|
### Loop over another command
|
||||||
|
|
||||||
|
A scheduled prompt can be a slash command or a skill invocation. This lets you
|
||||||
|
automate existing workflows.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/loop 20m /git:status
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemini CLI executes the command as if you had typed it yourself.
|
||||||
|
|
||||||
|
## Set a one-time reminder
|
||||||
|
|
||||||
|
To set a single reminder, describe what you want in natural language. Gemini CLI
|
||||||
|
uses its internal scheduling tools to set a one-time task that removes itself
|
||||||
|
after firing.
|
||||||
|
|
||||||
|
```text
|
||||||
|
In 15 minutes, remind me to push my changes
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Remind me in 1h to check the integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manage scheduled tasks
|
||||||
|
|
||||||
|
You can ask Gemini CLI to list or cancel your active tasks using natural
|
||||||
|
language.
|
||||||
|
|
||||||
|
```text
|
||||||
|
What scheduled tasks do I have?
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cancel the deployment check task
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood, the agent uses these tools to manage your schedule:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
| :---------------------- | :------------------------------------------------- |
|
||||||
|
| `schedule_task` | Schedules a new recurring or one-time task. |
|
||||||
|
| `list_scheduled_tasks` | Lists all active tasks with their IDs and prompts. |
|
||||||
|
| `cancel_scheduled_task` | Cancels a specific task using its ID. |
|
||||||
|
|
||||||
|
## How scheduled tasks run
|
||||||
|
|
||||||
|
Scheduled tasks trigger only when Gemini CLI is idle.
|
||||||
|
|
||||||
|
- **Non-disruptive:** If a task becomes due while the agent is busy generating a
|
||||||
|
response or executing a tool, the prompt is queued.
|
||||||
|
- **Sequential:** The queued prompt executes immediately after the current turn
|
||||||
|
finishes.
|
||||||
|
- **Shared context:** Scheduled tasks run within your active session and have
|
||||||
|
access to the full conversation history and any files you have added to the
|
||||||
|
context.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Session-scoped scheduling has the following constraints:
|
||||||
|
|
||||||
|
- **Active session required:** Tasks only fire while Gemini CLI is running.
|
||||||
|
Closing your terminal or exiting the session cancels all tasks.
|
||||||
|
- **Shared context window:** Every task execution adds to your session's history
|
||||||
|
and consumes tokens in the context window. High-frequency loops may trigger
|
||||||
|
[context compression](./token-caching.md) sooner.
|
||||||
|
- **No persistence:** Restarting Gemini CLI clears all tasks.
|
||||||
|
- **Simple intervals:** Only simple time intervals are supported (e.g., `5m`).
|
||||||
|
Complex cron expressions (e.g., `0 9 * * 1-5`) are not supported.
|
||||||
@@ -98,6 +98,7 @@
|
|||||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||||
|
{ "label": "Scheduled tasks", "slug": "docs/cli/scheduled-tasks" },
|
||||||
{
|
{
|
||||||
"label": "Git worktrees",
|
"label": "Git worktrees",
|
||||||
"badge": "🔬",
|
"badge": "🔬",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { docsCommand } from '../ui/commands/docsCommand.js';
|
|||||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||||
|
import { loopCommand } from '../ui/commands/loopCommand.js';
|
||||||
import { footerCommand } from '../ui/commands/footerCommand.js';
|
import { footerCommand } from '../ui/commands/footerCommand.js';
|
||||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||||
@@ -155,6 +156,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
]
|
]
|
||||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||||
helpCommand,
|
helpCommand,
|
||||||
|
loopCommand,
|
||||||
footerCommand,
|
footerCommand,
|
||||||
shortcutsCommand,
|
shortcutsCommand,
|
||||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ import {
|
|||||||
logBillingEvent,
|
logBillingEvent,
|
||||||
ApiKeyUpdatedEvent,
|
ApiKeyUpdatedEvent,
|
||||||
type InjectionSource,
|
type InjectionSource,
|
||||||
|
cronSchedulerService,
|
||||||
|
type ScheduledTask,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { validateAuthMethod } from '../config/auth.js';
|
import { validateAuthMethod } from '../config/auth.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -1198,6 +1200,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
const { isMcpReady } = useMcpStatus(config);
|
const { isMcpReady } = useMcpStatus(config);
|
||||||
|
|
||||||
|
const [scheduledTasks, setScheduledTasks] = useState<ScheduledTask[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTasks = () => {
|
||||||
|
setScheduledTasks(cronSchedulerService.listTasks());
|
||||||
|
};
|
||||||
|
updateTasks();
|
||||||
|
// Poor man's sync: polling for list updates since it's mostly local
|
||||||
|
const interval = setInterval(updateTasks, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messageQueue,
|
messageQueue,
|
||||||
addMessage,
|
addMessage,
|
||||||
@@ -1211,6 +1225,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isMcpReady,
|
isMcpReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTaskDue = (task: ScheduledTask) => {
|
||||||
|
addMessage(task.prompt);
|
||||||
|
};
|
||||||
|
cronSchedulerService.on('task_due', handleTaskDue);
|
||||||
|
return () => {
|
||||||
|
cronSchedulerService.off('task_due', handleTaskDue);
|
||||||
|
};
|
||||||
|
}, [addMessage]);
|
||||||
|
|
||||||
cancelHandlerRef.current = useCallback(
|
cancelHandlerRef.current = useCallback(
|
||||||
(shouldRestorePrompt: boolean = true) => {
|
(shouldRestorePrompt: boolean = true) => {
|
||||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||||
@@ -2325,6 +2349,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
terminalBackgroundColor: config.getTerminalBackground(),
|
terminalBackgroundColor: config.getTerminalBackground(),
|
||||||
settingsNonce,
|
settingsNonce,
|
||||||
backgroundTasks,
|
backgroundTasks,
|
||||||
|
scheduledTasks,
|
||||||
activeBackgroundTaskPid,
|
activeBackgroundTaskPid,
|
||||||
backgroundTaskHeight,
|
backgroundTaskHeight,
|
||||||
isBackgroundTaskListOpen,
|
isBackgroundTaskListOpen,
|
||||||
@@ -2452,6 +2477,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
settingsNonce,
|
settingsNonce,
|
||||||
backgroundTaskHeight,
|
backgroundTaskHeight,
|
||||||
isBackgroundTaskListOpen,
|
isBackgroundTaskListOpen,
|
||||||
|
scheduledTasks,
|
||||||
activeBackgroundTaskPid,
|
activeBackgroundTaskPid,
|
||||||
backgroundTasks,
|
backgroundTasks,
|
||||||
adminSettingsChanged,
|
adminSettingsChanged,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { loopCommand } from './loopCommand.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
cronSchedulerService: {
|
||||||
|
scheduleTask: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loopCommand', () => {
|
||||||
|
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockContext = createMockCommandContext();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should print an error if no args are provided', async () => {
|
||||||
|
mockContext.invocation!.args = ' ';
|
||||||
|
await loopCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: expect.stringContaining('Please provide a prompt'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to 10m if no interval is provided', async () => {
|
||||||
|
mockContext.invocation!.args = 'check the build';
|
||||||
|
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('abc12345');
|
||||||
|
|
||||||
|
await loopCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||||
|
'10m',
|
||||||
|
'check the build',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
text: expect.stringContaining('every 10m'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse leading interval', async () => {
|
||||||
|
mockContext.invocation!.args = '5m check the build';
|
||||||
|
vi.mocked(cronSchedulerService.scheduleTask).mockReturnValue('def56789');
|
||||||
|
|
||||||
|
await loopCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(cronSchedulerService.scheduleTask).toHaveBeenCalledWith(
|
||||||
|
'5m',
|
||||||
|
'check the build',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scheduling errors', async () => {
|
||||||
|
mockContext.invocation!.args = 'invalid check the build';
|
||||||
|
vi.mocked(cronSchedulerService.scheduleTask).mockImplementation(() => {
|
||||||
|
throw new Error('Invalid format');
|
||||||
|
});
|
||||||
|
|
||||||
|
await loopCommand.action!(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
text: expect.stringContaining(
|
||||||
|
'Failed to schedule task: Invalid format',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CommandKind, type SlashCommand } from './types.js';
|
||||||
|
import { cronSchedulerService } from '@google/gemini-cli-core';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
|
||||||
|
export const loopCommand: SlashCommand = {
|
||||||
|
name: 'loop',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
description: 'Schedules a repeating prompt (e.g., /loop 5m check the build)',
|
||||||
|
autoExecute: true,
|
||||||
|
action: async (context) => {
|
||||||
|
const args = context.invocation?.args?.trim() || '';
|
||||||
|
if (!args) {
|
||||||
|
context.ui.addItem({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Please provide a prompt to loop. Example: /loop 5m check the build',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 10 minutes if no interval is provided
|
||||||
|
let intervalString = '10m';
|
||||||
|
|
||||||
|
// Check if the first word is an interval
|
||||||
|
const match = args.match(/^(\d+[smhd])\s+(.*)/i);
|
||||||
|
let prompt = args;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
intervalString = match[1].toLowerCase();
|
||||||
|
prompt = match[2].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = cronSchedulerService.scheduleTask(
|
||||||
|
intervalString,
|
||||||
|
prompt,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
context.ui.addItem({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Scheduled recurring task \`${id}\` to run \`${prompt}\` every ${intervalString}.`,
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
context.ui.addItem({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Failed to schedule task: ${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -149,6 +149,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -169,6 +170,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -189,6 +191,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -209,6 +212,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -229,6 +233,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={100}
|
width={100}
|
||||||
height={30}
|
height={30}
|
||||||
@@ -252,6 +257,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -272,6 +278,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -303,6 +310,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -335,6 +343,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell1.pid}
|
activePid={shell1.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -360,6 +369,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={shell2.pid}
|
activePid={shell2.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
@@ -391,6 +401,7 @@ describe('<BackgroundTaskDisplay />', () => {
|
|||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
|
scheduledTasks={[]}
|
||||||
activePid={exitedShell.pid}
|
activePid={exitedShell.pid}
|
||||||
width={width}
|
width={width}
|
||||||
height={24}
|
height={24}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type AnsiOutput,
|
type AnsiOutput,
|
||||||
type AnsiLine,
|
type AnsiLine,
|
||||||
type AnsiToken,
|
type AnsiToken,
|
||||||
|
type ScheduledTask,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||||
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||||
@@ -36,6 +37,7 @@ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
|||||||
|
|
||||||
interface BackgroundTaskDisplayProps {
|
interface BackgroundTaskDisplayProps {
|
||||||
shells: Map<number, BackgroundTask>;
|
shells: Map<number, BackgroundTask>;
|
||||||
|
scheduledTasks: ScheduledTask[];
|
||||||
activePid: number;
|
activePid: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -63,6 +65,7 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
|||||||
|
|
||||||
export const BackgroundTaskDisplay = ({
|
export const BackgroundTaskDisplay = ({
|
||||||
shells,
|
shells,
|
||||||
|
scheduledTasks,
|
||||||
activePid,
|
activePid,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -335,60 +338,74 @@ export const BackgroundTaskDisplay = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1} width="100%">
|
<Box flexGrow={1} width="100%">
|
||||||
<RadioButtonSelect
|
{shells.size > 0 && (
|
||||||
items={items}
|
<RadioButtonSelect
|
||||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
items={items}
|
||||||
onSelect={(pid) => {
|
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||||
setActiveBackgroundTaskPid(pid);
|
onSelect={(pid) => {
|
||||||
setIsBackgroundTaskListOpen(false);
|
setActiveBackgroundTaskPid(pid);
|
||||||
}}
|
setIsBackgroundTaskListOpen(false);
|
||||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
}}
|
||||||
isFocused={isFocused}
|
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||||
maxItemsToShow={Math.max(
|
isFocused={isFocused}
|
||||||
1,
|
maxItemsToShow={Math.max(
|
||||||
height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT,
|
1,
|
||||||
)}
|
height -
|
||||||
renderItem={(
|
TOTAL_OVERHEAD_HEIGHT -
|
||||||
item,
|
PROCESS_LIST_HEADER_HEIGHT -
|
||||||
{ isSelected: _isSelected, titleColor: _titleColor },
|
(scheduledTasks.length > 0 ? scheduledTasks.length + 2 : 0),
|
||||||
) => {
|
)}
|
||||||
// Custom render to handle exit code coloring if needed,
|
renderItem={(
|
||||||
// or just use default. The default RadioButtonSelect renderer
|
item,
|
||||||
// handles standard label.
|
{ isSelected: _isSelected, titleColor: _titleColor },
|
||||||
// But we want to color exit code differently?
|
) => {
|
||||||
// The previous implementation colored exit code green/red.
|
const shell = shells.get(item.value);
|
||||||
// Let's reimplement that.
|
if (!shell) return <Text>{item.label}</Text>;
|
||||||
|
|
||||||
// We need access to shell details here.
|
const truncatedCommand = formatShellCommandForDisplay(
|
||||||
// We can put shell details in the item or lookup.
|
shell.command,
|
||||||
// Lookup from shells map.
|
maxCommandLength,
|
||||||
const shell = shells.get(item.value);
|
);
|
||||||
if (!shell) return <Text>{item.label}</Text>;
|
|
||||||
|
|
||||||
const truncatedCommand = formatShellCommandForDisplay(
|
return (
|
||||||
shell.command,
|
<Text>
|
||||||
maxCommandLength,
|
{truncatedCommand} (PID: {shell.pid})
|
||||||
);
|
{shell.status === 'exited' ? (
|
||||||
|
<Text
|
||||||
return (
|
color={
|
||||||
<Text>
|
shell.exitCode === 0
|
||||||
{truncatedCommand} (PID: {shell.pid})
|
? theme.status.success
|
||||||
{shell.status === 'exited' ? (
|
: theme.status.error
|
||||||
<Text
|
}
|
||||||
color={
|
>
|
||||||
shell.exitCode === 0
|
{' '}
|
||||||
? theme.status.success
|
(Exit Code: {shell.exitCode})
|
||||||
: theme.status.error
|
</Text>
|
||||||
}
|
) : null}
|
||||||
>
|
</Text>
|
||||||
{' '}
|
);
|
||||||
(Exit Code: {shell.exitCode})
|
}}
|
||||||
</Text>
|
/>
|
||||||
) : null}
|
)}
|
||||||
|
{scheduledTasks.length > 0 && (
|
||||||
|
<Box flexDirection="column" marginTop={shells.size > 0 ? 1 : 0}>
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderBottom={false}
|
||||||
|
borderLeft={false}
|
||||||
|
borderRight={false}
|
||||||
|
paddingTop={1}
|
||||||
|
marginBottom={1}
|
||||||
|
>
|
||||||
|
<Text bold>Scheduled Loops</Text>
|
||||||
|
</Box>
|
||||||
|
{scheduledTasks.map((task) => (
|
||||||
|
<Text key={task.id}>
|
||||||
|
{` ● [${task.id}] every ${task.intervalMs! / 1000}s: "${task.prompt}"`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
))}
|
||||||
}}
|
</Box>
|
||||||
/>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
AgentDefinition,
|
AgentDefinition,
|
||||||
FolderDiscoveryResults,
|
FolderDiscoveryResults,
|
||||||
PolicyUpdateConfirmationRequest,
|
PolicyUpdateConfirmationRequest,
|
||||||
|
ScheduledTask,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { type TransientMessageType } from '../../utils/events.js';
|
import { type TransientMessageType } from '../../utils/events.js';
|
||||||
import type { DOMElement } from 'ink';
|
import type { DOMElement } from 'ink';
|
||||||
@@ -216,6 +217,7 @@ export interface UIState {
|
|||||||
terminalBackgroundColor: TerminalBackgroundColor;
|
terminalBackgroundColor: TerminalBackgroundColor;
|
||||||
settingsNonce: number;
|
settingsNonce: number;
|
||||||
backgroundTasks: Map<number, BackgroundTask>;
|
backgroundTasks: Map<number, BackgroundTask>;
|
||||||
|
scheduledTasks: ScheduledTask[];
|
||||||
activeBackgroundTaskPid: number | null;
|
activeBackgroundTaskPid: number | null;
|
||||||
backgroundTaskHeight: number;
|
backgroundTaskHeight: number;
|
||||||
isBackgroundTaskListOpen: boolean;
|
isBackgroundTaskListOpen: boolean;
|
||||||
|
|||||||
@@ -40,14 +40,15 @@ export const DefaultAppLayout: React.FC = () => {
|
|||||||
<MainContent />
|
<MainContent />
|
||||||
|
|
||||||
{uiState.isBackgroundTaskVisible &&
|
{uiState.isBackgroundTaskVisible &&
|
||||||
uiState.backgroundTasks.size > 0 &&
|
(uiState.backgroundTasks.size > 0 ||
|
||||||
uiState.activeBackgroundTaskPid &&
|
uiState.scheduledTasks.length > 0) &&
|
||||||
uiState.backgroundTaskHeight > 0 &&
|
uiState.backgroundTaskHeight > 0 &&
|
||||||
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
||||||
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
|
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
|
||||||
<BackgroundTaskDisplay
|
<BackgroundTaskDisplay
|
||||||
shells={uiState.backgroundTasks}
|
shells={uiState.backgroundTasks}
|
||||||
activePid={uiState.activeBackgroundTaskPid}
|
scheduledTasks={uiState.scheduledTasks}
|
||||||
|
activePid={uiState.activeBackgroundTaskPid ?? 0}
|
||||||
width={uiState.terminalWidth}
|
width={uiState.terminalWidth}
|
||||||
height={uiState.backgroundTaskHeight}
|
height={uiState.backgroundTaskHeight}
|
||||||
isFocused={
|
isFocused={
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
|||||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||||
import { ideContextStore } from '../ide/ideContext.js';
|
import { ideContextStore } from '../ide/ideContext.js';
|
||||||
import { WriteTodosTool } from '../tools/write-todos.js';
|
import { WriteTodosTool } from '../tools/write-todos.js';
|
||||||
|
import {
|
||||||
|
ScheduleTaskTool,
|
||||||
|
ListTasksTool,
|
||||||
|
CancelTaskTool,
|
||||||
|
} from '../tools/cronTools.js';
|
||||||
import {
|
import {
|
||||||
StandardFileSystemService,
|
StandardFileSystemService,
|
||||||
type FileSystemService,
|
type FileSystemService,
|
||||||
@@ -3439,6 +3444,15 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
maybeRegister(AskUserTool, () =>
|
maybeRegister(AskUserTool, () =>
|
||||||
registry.registerTool(new AskUserTool(this.messageBus)),
|
registry.registerTool(new AskUserTool(this.messageBus)),
|
||||||
);
|
);
|
||||||
|
maybeRegister(ScheduleTaskTool, () =>
|
||||||
|
registry.registerTool(new ScheduleTaskTool(this.messageBus)),
|
||||||
|
);
|
||||||
|
maybeRegister(ListTasksTool, () =>
|
||||||
|
registry.registerTool(new ListTasksTool(this.messageBus)),
|
||||||
|
);
|
||||||
|
maybeRegister(CancelTaskTool, () =>
|
||||||
|
registry.registerTool(new CancelTaskTool(this.messageBus)),
|
||||||
|
);
|
||||||
if (this.getUseWriteTodos()) {
|
if (this.getUseWriteTodos()) {
|
||||||
maybeRegister(WriteTodosTool, () =>
|
maybeRegister(WriteTodosTool, () =>
|
||||||
registry.registerTool(new WriteTodosTool(this.messageBus)),
|
registry.registerTool(new WriteTodosTool(this.messageBus)),
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export * from './services/sessionSummaryUtils.js';
|
|||||||
export * from './services/contextManager.js';
|
export * from './services/contextManager.js';
|
||||||
export * from './services/trackerService.js';
|
export * from './services/trackerService.js';
|
||||||
export * from './services/trackerTypes.js';
|
export * from './services/trackerTypes.js';
|
||||||
|
export * from './services/cronSchedulerService.js';
|
||||||
export * from './services/keychainService.js';
|
export * from './services/keychainService.js';
|
||||||
export * from './services/keychainTypes.js';
|
export * from './services/keychainTypes.js';
|
||||||
export * from './skills/skillManager.js';
|
export * from './skills/skillManager.js';
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
CronSchedulerService,
|
||||||
|
type ScheduledTask,
|
||||||
|
} from './cronSchedulerService.js';
|
||||||
|
|
||||||
|
describe('CronSchedulerService', () => {
|
||||||
|
let service: CronSchedulerService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
service = new CronSchedulerService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse intervals and schedule a task', () => {
|
||||||
|
const id = service.scheduleTask('5m', 'test prompt');
|
||||||
|
const tasks = service.listTasks();
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
expect(tasks[0].id).toBe(id);
|
||||||
|
expect(tasks[0].prompt).toBe('test prompt');
|
||||||
|
expect(tasks[0].intervalMs).toBe(5 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid interval', () => {
|
||||||
|
expect(() => service.scheduleTask('invalid', 'test prompt')).toThrow(
|
||||||
|
/Invalid interval format/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit event when task is due', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
service.on('task_due', callback);
|
||||||
|
|
||||||
|
service.scheduleTask('10s', 'test prompt');
|
||||||
|
|
||||||
|
// Advance 9 seconds, shouldn't fire
|
||||||
|
vi.advanceTimersByTime(9000);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance 1 more second, should fire
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const taskArg = callback.mock.calls[0][0] as ScheduledTask;
|
||||||
|
expect(taskArg.prompt).toBe('test prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle recurring tasks correctly', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
service.on('task_due', callback);
|
||||||
|
|
||||||
|
service.scheduleTask('10s', 'test prompt', true);
|
||||||
|
|
||||||
|
// First run
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Second run
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle one-shot tasks correctly', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
service.on('task_due', callback);
|
||||||
|
|
||||||
|
service.scheduleTask('10s', 'test prompt', false);
|
||||||
|
|
||||||
|
// First run
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(service.listTasks()).toHaveLength(0); // Task should be removed
|
||||||
|
|
||||||
|
// Advance again, shouldn't fire
|
||||||
|
vi.advanceTimersByTime(10000);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel a task', () => {
|
||||||
|
const id = service.scheduleTask('10s', 'test prompt');
|
||||||
|
expect(service.listTasks()).toHaveLength(1);
|
||||||
|
|
||||||
|
service.cancelTask(id);
|
||||||
|
expect(service.listTasks()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
export interface ScheduledTask {
|
||||||
|
id: string;
|
||||||
|
intervalMs: number | null; // null if one-shot (though this basic implementation focuses on recurring)
|
||||||
|
prompt: string;
|
||||||
|
createdAt: number;
|
||||||
|
nextRunAt: number;
|
||||||
|
isRecurring: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CronSchedulerService extends EventEmitter {
|
||||||
|
private tasks: Map<string, ScheduledTask> = new Map();
|
||||||
|
private tickInterval: NodeJS.Timeout | null = null;
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the background interval that checks for due tasks.
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
if (this.isRunning) return;
|
||||||
|
this.isRunning = true;
|
||||||
|
this.tickInterval = setInterval(() => this.tick(), 1000);
|
||||||
|
// Don't prevent process exit if only the scheduler is running
|
||||||
|
this.tickInterval.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the background interval.
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
this.isRunning = false;
|
||||||
|
if (this.tickInterval) {
|
||||||
|
clearInterval(this.tickInterval);
|
||||||
|
this.tickInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a new task.
|
||||||
|
* @param intervalString An interval string like "5m", "10s", "2h".
|
||||||
|
* @param prompt The prompt to execute.
|
||||||
|
* @param isRecurring Whether it's a recurring task or one-shot.
|
||||||
|
* @returns The generated task ID.
|
||||||
|
*/
|
||||||
|
scheduleTask(
|
||||||
|
intervalString: string,
|
||||||
|
prompt: string,
|
||||||
|
isRecurring: boolean = true,
|
||||||
|
): string {
|
||||||
|
const intervalMs = this.parseInterval(intervalString);
|
||||||
|
if (!intervalMs) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid interval format: ${intervalString}. Supported formats: 10s, 5m, 2h.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Math.random().toString(36).substring(2, 10); // 8-character ID
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const task: ScheduledTask = {
|
||||||
|
id,
|
||||||
|
intervalMs,
|
||||||
|
prompt,
|
||||||
|
createdAt: now,
|
||||||
|
nextRunAt: now + intervalMs,
|
||||||
|
isRecurring,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tasks.set(id, task);
|
||||||
|
|
||||||
|
if (!this.isRunning) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all active scheduled tasks.
|
||||||
|
*/
|
||||||
|
listTasks(): ScheduledTask[] {
|
||||||
|
return Array.from(this.tasks.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels a scheduled task by ID.
|
||||||
|
*/
|
||||||
|
cancelTask(id: string): boolean {
|
||||||
|
return this.tasks.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, task] of this.tasks.entries()) {
|
||||||
|
if (now >= task.nextRunAt) {
|
||||||
|
// Emit the event so the REPL can pick it up
|
||||||
|
this.emit('task_due', task);
|
||||||
|
|
||||||
|
if (task.isRecurring && task.intervalMs) {
|
||||||
|
// Calculate next run time
|
||||||
|
task.nextRunAt = now + task.intervalMs;
|
||||||
|
} else {
|
||||||
|
// One-shot, remove it
|
||||||
|
this.tasks.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-stop if no tasks
|
||||||
|
if (this.tasks.size === 0) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses strings like "10s", "5m", "2h", "1d" into milliseconds.
|
||||||
|
*/
|
||||||
|
private parseInterval(interval: string): number | null {
|
||||||
|
const match = interval.match(/^(\d+)([smhd])$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 's':
|
||||||
|
return value * 1000;
|
||||||
|
case 'm':
|
||||||
|
return value * 60 * 1000;
|
||||||
|
case 'h':
|
||||||
|
return value * 60 * 60 * 1000;
|
||||||
|
case 'd':
|
||||||
|
return value * 24 * 60 * 60 * 1000;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const cronSchedulerService = new CronSchedulerService();
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BaseDeclarativeTool,
|
||||||
|
BaseToolInvocation,
|
||||||
|
Kind,
|
||||||
|
type ToolResult,
|
||||||
|
} from './tools.js';
|
||||||
|
import { cronSchedulerService } from '../services/cronSchedulerService.js';
|
||||||
|
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||||
|
import { Type, type FunctionDeclaration } from '@google/genai';
|
||||||
|
|
||||||
|
// --- Declarations ---
|
||||||
|
|
||||||
|
export const SCHEDULE_TASK_TOOL_NAME = 'schedule_task';
|
||||||
|
export const LIST_TASKS_TOOL_NAME = 'list_scheduled_tasks';
|
||||||
|
export const CANCEL_TASK_TOOL_NAME = 'cancel_scheduled_task';
|
||||||
|
|
||||||
|
export const SCHEDULE_TASK_DECLARATION: FunctionDeclaration = {
|
||||||
|
name: SCHEDULE_TASK_TOOL_NAME,
|
||||||
|
description: 'Schedules a prompt to run after a specified interval.',
|
||||||
|
parameters: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
interval: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'Interval string like "10s", "5m", "1h".',
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'The prompt to run when the task triggers.',
|
||||||
|
},
|
||||||
|
recurring: {
|
||||||
|
type: Type.BOOLEAN,
|
||||||
|
description: 'Whether the task should run repeatedly.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['interval', 'prompt'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIST_TASKS_DECLARATION: FunctionDeclaration = {
|
||||||
|
name: LIST_TASKS_TOOL_NAME,
|
||||||
|
description: 'Lists all currently scheduled tasks.',
|
||||||
|
parameters: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CANCEL_TASK_DECLARATION: FunctionDeclaration = {
|
||||||
|
name: CANCEL_TASK_TOOL_NAME,
|
||||||
|
description: 'Cancels a scheduled task by ID.',
|
||||||
|
parameters: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'The ID of the task to cancel.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Invocations ---
|
||||||
|
|
||||||
|
interface ScheduleTaskParams {
|
||||||
|
interval: string;
|
||||||
|
prompt: string;
|
||||||
|
recurring?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScheduleTaskInvocation extends BaseToolInvocation<
|
||||||
|
ScheduleTaskParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
params: ScheduleTaskParams,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
toolName: string,
|
||||||
|
) {
|
||||||
|
super(params, messageBus, toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getDescription(): string {
|
||||||
|
return `Schedule task: ${this.params.prompt} (Interval: ${this.params.interval})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ToolResult> {
|
||||||
|
try {
|
||||||
|
const isRecurring = this.params.recurring !== false;
|
||||||
|
const id = cronSchedulerService.scheduleTask(
|
||||||
|
this.params.interval,
|
||||||
|
this.params.prompt,
|
||||||
|
isRecurring,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
llmContent: `Task scheduled successfully. ID: ${id}`,
|
||||||
|
returnDisplay: `Task scheduled successfully. ID: ${id}`,
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return {
|
||||||
|
llmContent: `Error scheduling task: ${message}`,
|
||||||
|
returnDisplay: `Error scheduling task: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getConfirmationDetails() {
|
||||||
|
return false as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListTasksInvocation extends BaseToolInvocation<object, ToolResult> {
|
||||||
|
constructor(params: object, messageBus: MessageBus, toolName: string) {
|
||||||
|
super(params, messageBus, toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getDescription(): string {
|
||||||
|
return 'List scheduled tasks';
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ToolResult> {
|
||||||
|
const tasks = cronSchedulerService.listTasks();
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return {
|
||||||
|
llmContent: 'No scheduled tasks.',
|
||||||
|
returnDisplay: 'No scheduled tasks.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const lines = tasks.map(
|
||||||
|
(t) =>
|
||||||
|
`- ID: ${t.id}, Interval: ${t.intervalMs}ms, Prompt: "${t.prompt}", Recurring: ${t.isRecurring}`,
|
||||||
|
);
|
||||||
|
return { llmContent: lines.join('\n'), returnDisplay: lines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getConfirmationDetails() {
|
||||||
|
return false as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CancelTaskParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CancelTaskInvocation extends BaseToolInvocation<
|
||||||
|
CancelTaskParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(
|
||||||
|
params: CancelTaskParams,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
toolName: string,
|
||||||
|
) {
|
||||||
|
super(params, messageBus, toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getDescription(): string {
|
||||||
|
return `Cancel task ID: ${this.params.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<ToolResult> {
|
||||||
|
const success = cronSchedulerService.cancelTask(this.params.id);
|
||||||
|
if (success) {
|
||||||
|
return {
|
||||||
|
llmContent: `Task ${this.params.id} cancelled successfully.`,
|
||||||
|
returnDisplay: `Task ${this.params.id} cancelled successfully.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
llmContent: `Task ${this.params.id} not found.`,
|
||||||
|
returnDisplay: `Task ${this.params.id} not found.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getConfirmationDetails() {
|
||||||
|
return false as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tools ---
|
||||||
|
|
||||||
|
export class ScheduleTaskTool extends BaseDeclarativeTool<
|
||||||
|
ScheduleTaskParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(messageBus: MessageBus) {
|
||||||
|
super(
|
||||||
|
SCHEDULE_TASK_TOOL_NAME,
|
||||||
|
'ScheduleTask',
|
||||||
|
SCHEDULE_TASK_DECLARATION.description ?? '',
|
||||||
|
Kind.Other,
|
||||||
|
SCHEDULE_TASK_DECLARATION.parameters,
|
||||||
|
messageBus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: ScheduleTaskParams,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
): ScheduleTaskInvocation {
|
||||||
|
return new ScheduleTaskInvocation(params, messageBus, this.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListTasksTool extends BaseDeclarativeTool<object, ToolResult> {
|
||||||
|
constructor(messageBus: MessageBus) {
|
||||||
|
super(
|
||||||
|
LIST_TASKS_TOOL_NAME,
|
||||||
|
'ListTasks',
|
||||||
|
LIST_TASKS_DECLARATION.description ?? '',
|
||||||
|
Kind.Other,
|
||||||
|
LIST_TASKS_DECLARATION.parameters,
|
||||||
|
messageBus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: object,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
): ListTasksInvocation {
|
||||||
|
return new ListTasksInvocation(params, messageBus, this.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelTaskTool extends BaseDeclarativeTool<
|
||||||
|
CancelTaskParams,
|
||||||
|
ToolResult
|
||||||
|
> {
|
||||||
|
constructor(messageBus: MessageBus) {
|
||||||
|
super(
|
||||||
|
CANCEL_TASK_TOOL_NAME,
|
||||||
|
'CancelTask',
|
||||||
|
CANCEL_TASK_DECLARATION.description ?? '',
|
||||||
|
Kind.Other,
|
||||||
|
CANCEL_TASK_DECLARATION.parameters,
|
||||||
|
messageBus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createInvocation(
|
||||||
|
params: CancelTaskParams,
|
||||||
|
messageBus: MessageBus,
|
||||||
|
): CancelTaskInvocation {
|
||||||
|
return new CancelTaskInvocation(params, messageBus, this.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user