From c447a42d070d15f8aef63dafd69da95bc6e92377 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:21:13 +0530 Subject: [PATCH] feat: add background agents with scheduling support - Add background task scheduling system with cron-based triggers - Add background-task-detail component for viewing agent status - Add agent schedule repo and state management - Update sidebar to show background agents section - Remove old workflow-authoring and workflow-run-ops skills - Add IPC handlers for agent schedule operations Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/ipc.ts | 36 ++ apps/x/apps/main/src/main.ts | 4 + apps/x/apps/renderer/src/App.tsx | 129 +++- .../src/components/background-task-detail.tsx | 175 ++++++ .../src/components/sidebar-content.tsx | 81 ++- apps/x/packages/core/package.json | 3 +- .../packages/core/src/agent-schedule/repo.ts | 43 ++ .../core/src/agent-schedule/runner.ts | 335 +++++++++++ .../core/src/agent-schedule/state-repo.ts | 64 ++ .../skills/background-agents/skill.ts | 555 ++++++++++++++++++ .../src/application/assistant/skills/index.ts | 17 +- .../skills/workflow-authoring/skill.ts | 384 ------------ .../skills/workflow-run-ops/skill.ts | 95 --- .../x/packages/core/src/config/initConfigs.ts | 6 + apps/x/packages/core/src/di/container.ts | 4 + .../shared/src/agent-schedule-state.ts | 17 + apps/x/packages/shared/src/agent-schedule.ts | 44 ++ apps/x/packages/shared/src/index.ts | 2 + apps/x/packages/shared/src/ipc.ts | 28 + apps/x/pnpm-lock.yaml | 22 +- 20 files changed, 1544 insertions(+), 500 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/background-task-detail.tsx create mode 100644 apps/x/packages/core/src/agent-schedule/repo.ts create mode 100644 apps/x/packages/core/src/agent-schedule/runner.ts create mode 100644 apps/x/packages/core/src/agent-schedule/state-repo.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts delete mode 100644 apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts delete mode 100644 apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts create mode 100644 apps/x/packages/shared/src/agent-schedule-state.ts create mode 100644 apps/x/packages/shared/src/agent-schedule.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5a7a7bd9..6efd48df 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -24,6 +24,9 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; +import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; +import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; +import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -384,5 +387,38 @@ export function setupIpcHandlers() { 'composio:execute-action': async (_event, args) => { return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); }, + // Agent schedule handlers + 'agent-schedule:getConfig': async () => { + const repo = container.resolve('agentScheduleRepo'); + try { + return await repo.getConfig(); + } catch { + // Return empty config if file doesn't exist + return { agents: {} }; + } + }, + 'agent-schedule:getState': async () => { + const repo = container.resolve('agentScheduleStateRepo'); + try { + return await repo.getState(); + } catch { + // Return empty state if file doesn't exist + return { agents: {} }; + } + }, + 'agent-schedule:updateAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + await repo.upsert(args.agentName, args.entry); + // Trigger the runner to pick up the change immediately + triggerAgentScheduleRun(); + return { success: true }; + }, + 'agent-schedule:deleteAgent': async (_event, args) => { + const repo = container.resolve('agentScheduleRepo'); + const stateRepo = container.resolve('agentScheduleStateRepo'); + await repo.delete(args.agentName); + await stateRepo.deleteAgentState(args.agentName); + return { success: true }; + }, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index d73ae442..f074740f 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -10,6 +10,7 @@ import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; +import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; const __filename = fileURLToPath(import.meta.url); @@ -156,6 +157,9 @@ app.whenReady().then(async () => { // start pre-built agent runner initPreBuiltRunner(); + // start background agent runner (scheduled agents) + initAgentRunner(); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1a679ca0..3d01eee3 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -51,6 +51,9 @@ import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' +import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' type DirEntry = z.infer type RunEventType = z.infer @@ -499,6 +502,22 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // Background tasks state + type BackgroundTaskItem = { + name: string + description?: string + schedule: z.infer["agents"][string]["schedule"] + enabled: boolean + startingMessage?: string + status?: z.infer["agents"][string]["status"] + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + } + const [backgroundTasks, setBackgroundTasks] = useState([]) + const [selectedBackgroundTask, setSelectedBackgroundTask] = useState(null) + // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) useEffect(() => { runIdRef.current = runId @@ -528,12 +547,17 @@ function App() { const cleanup = window.ipc.on('workspace:didChange', async (event) => { loadDirectory().then(setTree) - // Reload current file if it was changed externally - if (!selectedPath) return - const changedPath = event.type === 'changed' ? event.path : null const changedPaths = (event.type === 'bulkChanged' ? event.paths : []) ?? [] + // Reload background tasks if agent-schedule.json changed + if (changedPath === 'config/agent-schedule.json' || changedPaths.includes('config/agent-schedule.json')) { + loadBackgroundTasks() + } + + // Reload current file if it was changed externally + if (!selectedPath) return + const isCurrentFileChanged = changedPath === selectedPath || changedPaths.includes(selectedPath) @@ -548,6 +572,7 @@ function App() { } }) return cleanup + // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadDirectory, selectedPath, editorContent]) // Load file content when selected @@ -663,6 +688,63 @@ function App() { loadRuns() }, [loadRuns]) + // Load background tasks + const loadBackgroundTasks = useCallback(async () => { + try { + const [configResult, stateResult] = await Promise.all([ + window.ipc.invoke('agent-schedule:getConfig', null), + window.ipc.invoke('agent-schedule:getState', null), + ]) + + const tasks: BackgroundTaskItem[] = Object.entries(configResult.agents).map(([name, entry]) => { + const state = stateResult.agents[name] + return { + name, + description: entry.description, + schedule: entry.schedule, + enabled: entry.enabled ?? true, + startingMessage: entry.startingMessage, + status: state?.status, + nextRunAt: state?.nextRunAt, + lastRunAt: state?.lastRunAt, + lastError: state?.lastError, + runCount: state?.runCount ?? 0, + } + }) + + setBackgroundTasks(tasks) + } catch (err) { + console.error('Failed to load background tasks:', err) + } + }, []) + + // Load background tasks on mount + useEffect(() => { + loadBackgroundTasks() + }, [loadBackgroundTasks]) + + // Handle toggling background task enabled state + const handleToggleBackgroundTask = useCallback(async (taskName: string, enabled: boolean) => { + const task = backgroundTasks.find(t => t.name === taskName) + if (!task) return + + try { + await window.ipc.invoke('agent-schedule:updateAgent', { + agentName: taskName, + entry: { + schedule: task.schedule, + enabled, + startingMessage: task.startingMessage, + description: task.description, + }, + }) + // Reload to get updated state + await loadBackgroundTasks() + } catch (err) { + console.error('Failed to update background task:', err) + } + }, [backgroundTasks, loadBackgroundTasks]) + // Load a specific run and populate conversation const loadRun = useCallback(async (id: string) => { try { @@ -1169,6 +1251,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setSelectedBackgroundTask(null) }, []) const handleChatInputSubmit = (text: string) => { @@ -1193,6 +1276,8 @@ function App() { // Clear forward history when navigating to a new file setFileHistoryForward([]) setSelectedPath(path) + // Clear background task selection when navigating to a file + setSelectedBackgroundTask(null) }, [selectedPath]) const navigateBack = useCallback(() => { @@ -1686,7 +1771,16 @@ function App() { const conversationContentClassName = hasConversation ? "mx-auto w-full max-w-4xl pb-28" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" - const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') + const headerTitle = selectedPath + ? selectedPath + : isGraphOpen + ? 'Graph View' + : selectedBackgroundTask + ? `Background Task: ${selectedBackgroundTask}` + : 'Chat' + const selectedTask = selectedBackgroundTask + ? backgroundTasks.find(t => t.name === selectedBackgroundTask) + : null return ( @@ -1716,8 +1810,18 @@ function App() { currentRunId={runId} tasksActions={{ onNewChat: handleNewChat, - onSelectRun: loadRun, + onSelectRun: (runIdToLoad) => { + setSelectedBackgroundTask(null) + loadRun(runIdToLoad) + }, + onSelectBackgroundTask: (taskName) => { + setSelectedBackgroundTask(taskName) + setSelectedPath(null) + setIsGraphOpen(false) + }, }} + backgroundTasks={backgroundTasks} + selectedBackgroundTask={selectedBackgroundTask} /> {/* Header with sidebar triggers */} @@ -1819,6 +1923,21 @@ function App() { ) + ) : selectedTask ? ( +
+ handleToggleBackgroundTask(selectedTask.name, enabled)} + /> +
) : (
diff --git a/apps/x/apps/renderer/src/components/background-task-detail.tsx b/apps/x/apps/renderer/src/components/background-task-detail.tsx new file mode 100644 index 00000000..78d69f2e --- /dev/null +++ b/apps/x/apps/renderer/src/components/background-task-detail.tsx @@ -0,0 +1,175 @@ +import { Bot, Calendar, Clock, AlertCircle, CheckCircle } from "lucide-react" +import { Switch } from "@/components/ui/switch" + +interface BackgroundTaskSchedule { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string +} + +interface BackgroundTaskDetailProps { + name: string + description?: string + schedule: BackgroundTaskSchedule + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null + lastError?: string | null + runCount?: number + onToggleEnabled: (enabled: boolean) => void +} + +function formatScheduleDescription(schedule: BackgroundTaskSchedule): string { + switch (schedule.type) { + case "cron": + return `Runs on cron schedule: ${schedule.expression}` + case "window": + return `Runs once between ${schedule.startTime} and ${schedule.endTime} based on: ${schedule.cron}` + case "once": + return `Runs once at ${schedule.runAt}` + default: + return "Unknown schedule type" + } +} + +function formatDateTime(isoString: string | null | undefined): string { + if (!isoString) return "Never" + try { + const date = new Date(isoString) + return date.toLocaleString() + } catch { + return isoString + } +} + +export function BackgroundTaskDetail({ + name, + description, + schedule, + enabled, + status, + nextRunAt, + lastRunAt, + lastError, + runCount = 0, + onToggleEnabled, +}: BackgroundTaskDetailProps) { + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{name}

+

Background Agent

+
+
+
+ + {/* Content */} +
+ {/* Description */} + {description && ( +
+

Description

+

{description}

+
+ )} + + {/* Schedule */} +
+

Schedule

+
+
+ + {schedule.type} Schedule +
+

+ {formatScheduleDescription(schedule)} +

+
+
+ + {/* Enabled Toggle - hide for completed one-time schedules */} + {status === "triggered" ? ( +
+

Status

+
+
+ +

Completed

+
+

+ This one-time agent has finished running and will not run again. +

+
+
+ ) : ( +
+

Status

+
+
+

{enabled ? "Enabled" : "Disabled"}

+

+ {enabled ? "This agent will run according to its schedule" : "This agent is paused and will not run"} +

+
+ +
+
+ )} + + {/* Run Statistics */} +
+

Run History

+
+
+

{runCount}

+

Total Runs

+
+
+

{formatDateTime(lastRunAt)}

+

Last Run

+
+
+
+ + {/* Next Run */} + {nextRunAt && schedule.type !== "once" && ( +
+

Next Scheduled Run

+
+
+ + {formatDateTime(nextRunAt)} +
+
+
+ )} + + {/* Last Error */} + {lastError && ( +
+

Last Error

+
+
+ +

{lastError}

+
+
+
+ )} +
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index cee59809..02422b20 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { useState } from "react" import { + Bot, ChevronRight, ChevronsDownUp, ChevronsUpDown, @@ -78,9 +79,27 @@ type RunListItem = { agentId: string } +type BackgroundTaskItem = { + name: string + description?: string + schedule: { + type: "cron" | "window" | "once" + expression?: string + cron?: string + startTime?: string + endTime?: string + runAt?: string + } + enabled: boolean + status?: "scheduled" | "running" | "finished" | "failed" | "triggered" + nextRunAt?: string | null + lastRunAt?: string | null +} + type TasksActions = { onNewChat: () => void onSelectRun: (runId: string) => void + onSelectBackgroundTask?: (taskName: string) => void } type SidebarContentPanelProps = { @@ -93,6 +112,8 @@ type SidebarContentPanelProps = { runs?: RunListItem[] currentRunId?: string | null tasksActions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null } & React.ComponentProps const sectionTitles = { @@ -110,6 +131,8 @@ export function SidebarContentPanel({ runs = [], currentRunId, tasksActions, + backgroundTasks = [], + selectedBackgroundTask, ...props }: SidebarContentPanelProps) { const { activeSection } = useSidebarSection() @@ -137,6 +160,8 @@ export function SidebarContentPanel({ runs={runs} currentRunId={currentRunId} actions={tasksActions} + backgroundTasks={backgroundTasks} + selectedBackgroundTask={selectedBackgroundTask} /> )} @@ -653,15 +678,40 @@ function Tree({ ) } +// Get status indicator color +function getStatusColor(status?: string, enabled?: boolean): string { + // Disabled agents always show gray + if (enabled === false) { + return "bg-gray-400" + } + switch (status) { + case "running": + return "bg-blue-500" + case "finished": + return "bg-green-500" + case "failed": + return "bg-red-500" + case "triggered": + return "bg-gray-400" + case "scheduled": + default: + return "bg-yellow-500" + } +} + // Tasks Section function TasksSection({ runs, currentRunId, actions, + backgroundTasks = [], + selectedBackgroundTask, }: { runs: RunListItem[] currentRunId?: string | null actions?: TasksActions + backgroundTasks?: BackgroundTaskItem[] + selectedBackgroundTask?: string | null }) { return ( @@ -677,9 +727,38 @@ function TasksSection({
- {runs.length > 0 && ( + {/* Background Tasks Section */} + {backgroundTasks.length > 0 && ( <>
+ Background Tasks +
+ + {backgroundTasks.map((task) => ( + + actions?.onSelectBackgroundTask?.(task.name)} + className="gap-2" + > +
+ + +
+ + {task.name} + +
+
+ ))} +
+ + )} + {runs.length > 0 && ( + <> +
Chat history
diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 7fb0bc68..20f12f6b 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -12,9 +12,9 @@ "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", - "@composio/core": "^0.6.0", "@ai-sdk/openai-compatible": "^1.0.27", "@ai-sdk/provider": "^2.0.0", + "@composio/core": "^0.6.0", "@google-cloud/local-auth": "^3.0.1", "@modelcontextprotocol/sdk": "^1.25.1", "@openrouter/ai-sdk-provider": "^1.2.6", @@ -24,6 +24,7 @@ "ai": "^5.0.102", "awilix": "^12.0.5", "chokidar": "^4.0.3", + "cron-parser": "^5.5.0", "glob": "^13.0.0", "google-auth-library": "^10.5.0", "googleapis": "^169.0.0", diff --git a/apps/x/packages/core/src/agent-schedule/repo.ts b/apps/x/packages/core/src/agent-schedule/repo.ts new file mode 100644 index 00000000..f32eb0ae --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/repo.ts @@ -0,0 +1,43 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULES: z.infer["agents"] = {}; + +export interface IAgentScheduleRepo { + ensureConfig(): Promise; + getConfig(): Promise>; + upsert(agentName: string, entry: z.infer): Promise; + delete(agentName: string): Promise; +} + +export class FSAgentScheduleRepo implements IAgentScheduleRepo { + private readonly configPath = path.join(WorkDir, "config", "agent-schedule.json"); + + async ensureConfig(): Promise { + try { + await fs.access(this.configPath); + } catch { + await fs.writeFile(this.configPath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULES }, null, 2)); + } + } + + async getConfig(): Promise> { + const config = await fs.readFile(this.configPath, "utf8"); + return AgentScheduleConfig.parse(JSON.parse(config)); + } + + async upsert(agentName: string, entry: z.infer): Promise { + const conf = await this.getConfig(); + conf.agents[agentName] = entry; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } + + async delete(agentName: string): Promise { + const conf = await this.getConfig(); + delete conf.agents[agentName]; + await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2)); + } +} diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts new file mode 100644 index 00000000..4eab6081 --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -0,0 +1,335 @@ +import { CronExpressionParser } from "cron-parser"; +import container from "../di/container.js"; +import { IAgentScheduleRepo } from "./repo.js"; +import { IAgentScheduleStateRepo } from "./state-repo.js"; +import { IRunsRepo } from "../runs/repo.js"; +import { IAgentRuntime } from "../agents/runtime.js"; +import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; +import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; +import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; +import { MessageEvent } from "@x/shared/dist/runs.js"; +import z from "zod"; + +const DEFAULT_STARTING_MESSAGE = "go"; + +const POLL_INTERVAL_MS = 60 * 1000; // 1 minute +const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Convert a Date to local ISO 8601 string (without Z suffix). + * Example: "2024-02-05T08:30:00" + */ +function toLocalISOString(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +// --- Wake Signal for Immediate Run Trigger --- +let wakeResolve: (() => void) | null = null; + +export function triggerRun(): void { + if (wakeResolve) { + console.log("[AgentRunner] Triggered - waking up immediately"); + wakeResolve(); + wakeResolve = null; + } +} + +function interruptibleSleep(ms: number): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); +} + +/** + * Calculate the next run time for a schedule. + * Returns ISO datetime string or null if schedule shouldn't run again. + */ +function calculateNextRunAt( + schedule: z.infer["schedule"] +): string | null { + const now = new Date(); + + switch (schedule.type) { + case "cron": { + try { + const interval = CronExpressionParser.parse(schedule.expression, { + currentDate: now, + }); + return toLocalISOString(interval.next().toDate()); + } catch (error) { + console.error("[AgentRunner] Invalid cron expression:", schedule.expression, error); + return null; + } + } + case "window": { + try { + // Parse base cron to get the next occurrence date + const interval = CronExpressionParser.parse(schedule.cron, { + currentDate: now, + }); + const nextDate = interval.next().toDate(); + + // Parse start and end times + const [startHour, startMin] = schedule.startTime.split(":").map(Number); + const [endHour, endMin] = schedule.endTime.split(":").map(Number); + + // Pick a random time within the window + const startMinutes = startHour * 60 + startMin; + const endMinutes = endHour * 60 + endMin; + const randomMinutes = startMinutes + Math.floor(Math.random() * (endMinutes - startMinutes)); + + nextDate.setHours(Math.floor(randomMinutes / 60), randomMinutes % 60, 0, 0); + return toLocalISOString(nextDate); + } catch (error) { + console.error("[AgentRunner] Invalid window schedule:", error); + return null; + } + } + case "once": { + // Once schedules don't have a "next" run - they're done after first run + return null; + } + } +} + +/** + * Check if an agent should run now based on its schedule and state. + */ +function shouldRunNow( + entry: z.infer, + state: z.infer | null +): boolean { + // Don't run if disabled + if (entry.enabled === false) { + return false; + } + + // Don't run if already running + if (state?.status === "running") { + return false; + } + + // Don't run once-schedules that are already triggered + if (entry.schedule.type === "once" && state?.status === "triggered") { + return false; + } + + const now = new Date(); + + // For once-schedules without state, check if runAt time has passed + if (entry.schedule.type === "once") { + const runAt = new Date(entry.schedule.runAt); + return now >= runAt; + } + + // For cron and window schedules, check nextRunAt + if (!state?.nextRunAt) { + // No nextRunAt set - needs to be initialized, so run now + return true; + } + + const nextRunAt = new Date(state.nextRunAt); + return now >= nextRunAt; +} + +/** + * Run a single agent. + */ +async function runAgent( + agentName: string, + entry: z.infer, + stateRepo: IAgentScheduleStateRepo, + runsRepo: IRunsRepo, + agentRuntime: IAgentRuntime, + idGenerator: IMonotonicallyIncreasingIdGenerator +): Promise { + console.log(`[AgentRunner] Starting agent: ${agentName}`); + + const startedAt = toLocalISOString(new Date()); + + // Update state to running with startedAt timestamp + await stateRepo.updateAgentState(agentName, { + status: "running", + startedAt: startedAt, + }); + + try { + // Create a new run + const run = await runsRepo.create({ agentId: agentName }); + console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`); + + // Add the starting message as a user message + const startingMessage = entry.startingMessage ?? DEFAULT_STARTING_MESSAGE; + const messageEvent: z.infer = { + runId: run.id, + type: "message", + messageId: await idGenerator.next(), + message: { + role: "user", + content: startingMessage, + }, + subflow: [], + }; + await runsRepo.appendEvents(run.id, [messageEvent]); + console.log(`[AgentRunner] Sent starting message to agent ${agentName}: "${startingMessage}"`); + + // Trigger the run + await agentRuntime.trigger(run.id); + + // Calculate next run time + const nextRunAt = calculateNextRunAt(entry.schedule); + + // Update state to finished (clear startedAt) + const currentState = await stateRepo.getAgentState(agentName); + await stateRepo.updateAgentState(agentName, { + status: entry.schedule.type === "once" ? "triggered" : "finished", + startedAt: null, + lastRunAt: toLocalISOString(new Date()), + nextRunAt: nextRunAt, + lastError: null, + runCount: (currentState?.runCount ?? 0) + 1, + }); + + console.log(`[AgentRunner] Finished agent: ${agentName}`); + } catch (error) { + console.error(`[AgentRunner] Error running agent ${agentName}:`, error); + + // Calculate next run time even on failure (for retry) + const nextRunAt = calculateNextRunAt(entry.schedule); + + // Update state to failed (clear startedAt) + const currentState = await stateRepo.getAgentState(agentName); + await stateRepo.updateAgentState(agentName, { + status: "failed", + startedAt: null, + lastRunAt: toLocalISOString(new Date()), + nextRunAt: nextRunAt, + lastError: error instanceof Error ? error.message : String(error), + runCount: (currentState?.runCount ?? 0) + 1, + }); + } +} + +/** + * Check for timed-out agents and mark them as failed. + */ +async function checkForTimeouts( + state: z.infer, + config: z.infer, + stateRepo: IAgentScheduleStateRepo +): Promise { + const now = new Date(); + + for (const [agentName, agentState] of Object.entries(state.agents)) { + if (agentState.status === "running" && agentState.startedAt) { + const startedAt = new Date(agentState.startedAt); + const elapsed = now.getTime() - startedAt.getTime(); + + if (elapsed > TIMEOUT_MS) { + console.log(`[AgentRunner] Agent ${agentName} timed out after ${Math.round(elapsed / 1000 / 60)} minutes`); + + // Get schedule entry for calculating next run + const entry = config.agents[agentName]; + const nextRunAt = entry ? calculateNextRunAt(entry.schedule) : null; + + await stateRepo.updateAgentState(agentName, { + status: "failed", + startedAt: null, + lastRunAt: toLocalISOString(now), + nextRunAt: nextRunAt, + lastError: `Timed out after ${Math.round(elapsed / 1000 / 60)} minutes`, + runCount: (agentState.runCount ?? 0) + 1, + }); + } + } + } +} + +/** + * Main polling loop. + */ +async function pollAndRun(): Promise { + const scheduleRepo = container.resolve("agentScheduleRepo"); + const stateRepo = container.resolve("agentScheduleStateRepo"); + const runsRepo = container.resolve("runsRepo"); + const agentRuntime = container.resolve("agentRuntime"); + const idGenerator = container.resolve("idGenerator"); + + // Load config and state + let config: z.infer; + let state: z.infer; + + try { + config = await scheduleRepo.getConfig(); + state = await stateRepo.getState(); + } catch (error) { + console.error("[AgentRunner] Error loading config/state:", error); + return; + } + + // Check for timed-out agents first + await checkForTimeouts(state, config, stateRepo); + + // Reload state after timeout checks (state may have changed) + try { + state = await stateRepo.getState(); + } catch (error) { + console.error("[AgentRunner] Error reloading state:", error); + return; + } + + // Check each agent + for (const [agentName, entry] of Object.entries(config.agents)) { + const agentState = state.agents[agentName] ?? null; + + // Initialize state if needed (set nextRunAt for new agents) + if (!agentState && entry.schedule.type !== "once") { + const nextRunAt = calculateNextRunAt(entry.schedule); + if (nextRunAt) { + await stateRepo.updateAgentState(agentName, { + status: "scheduled", + startedAt: null, + lastRunAt: null, + nextRunAt: nextRunAt, + lastError: null, + runCount: 0, + }); + console.log(`[AgentRunner] Initialized state for ${agentName}, next run at ${nextRunAt}`); + } + continue; // Don't run immediately on first initialization + } + + if (shouldRunNow(entry, agentState)) { + // Run agent (don't await - let it run in background) + runAgent(agentName, entry, stateRepo, runsRepo, agentRuntime, idGenerator).catch((error) => { + console.error(`[AgentRunner] Unhandled error in runAgent for ${agentName}:`, error); + }); + } + } +} + +/** + * Initialize the background agent runner service. + * Polls every minute to check for agents that need to run. + */ +export async function init(): Promise { + console.log("[AgentRunner] Starting background agent runner service"); + + while (true) { + try { + await pollAndRun(); + } catch (error) { + console.error("[AgentRunner] Error in main loop:", error); + } + + await interruptibleSleep(POLL_INTERVAL_MS); + } +} diff --git a/apps/x/packages/core/src/agent-schedule/state-repo.ts b/apps/x/packages/core/src/agent-schedule/state-repo.ts new file mode 100644 index 00000000..38c8f034 --- /dev/null +++ b/apps/x/packages/core/src/agent-schedule/state-repo.ts @@ -0,0 +1,64 @@ +import { WorkDir } from "../config/config.js"; +import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; +import fs from "fs/promises"; +import path from "path"; +import z from "zod"; + +const DEFAULT_AGENT_SCHEDULE_STATE: z.infer["agents"] = {}; + +export interface IAgentScheduleStateRepo { + ensureState(): Promise; + getState(): Promise>; + getAgentState(agentName: string): Promise | null>; + updateAgentState(agentName: string, entry: Partial>): Promise; + setAgentState(agentName: string, entry: z.infer): Promise; + deleteAgentState(agentName: string): Promise; +} + +export class FSAgentScheduleStateRepo implements IAgentScheduleStateRepo { + private readonly statePath = path.join(WorkDir, "config", "agent-schedule-state.json"); + + async ensureState(): Promise { + try { + await fs.access(this.statePath); + } catch { + await fs.writeFile(this.statePath, JSON.stringify({ agents: DEFAULT_AGENT_SCHEDULE_STATE }, null, 2)); + } + } + + async getState(): Promise> { + const state = await fs.readFile(this.statePath, "utf8"); + return AgentScheduleState.parse(JSON.parse(state)); + } + + async getAgentState(agentName: string): Promise | null> { + const state = await this.getState(); + return state.agents[agentName] ?? null; + } + + async updateAgentState(agentName: string, entry: Partial>): Promise { + const state = await this.getState(); + const existing = state.agents[agentName] ?? { + status: "scheduled" as const, + startedAt: null, + lastRunAt: null, + nextRunAt: null, + lastError: null, + runCount: 0, + }; + state.agents[agentName] = { ...existing, ...entry }; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async setAgentState(agentName: string, entry: z.infer): Promise { + const state = await this.getState(); + state.agents[agentName] = entry; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } + + async deleteAgentState(agentName: string): Promise { + const state = await this.getState(); + delete state.agents[agentName]; + await fs.writeFile(this.statePath, JSON.stringify(state, null, 2)); + } +} diff --git a/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts new file mode 100644 index 00000000..7ac1b89e --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/background-agents/skill.ts @@ -0,0 +1,555 @@ +export const skill = String.raw` +# Background Agents + +Load this skill whenever a user wants to inspect, create, edit, or schedule background agents inside the Rowboat workspace. + +## Core Concepts + +**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. + +- **All definitions live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter +- Agents configure a model, tools (in frontmatter), and instructions (in the body) +- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents** +- **"Workflows" are just agents that orchestrate other agents** by having them as tools +- **Background agents run on schedules** defined in ` + "`~/.rowboat/config/agent-schedule.json`" + ` + +## How multi-agent workflows work + +1. **Create an orchestrator agent** that has other agents in its ` + "`tools`" + ` +2. **Schedule the orchestrator** in agent-schedule.json (see Scheduling section below) +3. The orchestrator calls other agents as tools when needed +4. Data flows through tool call parameters and responses + +## Scheduling Background Agents + +Background agents run automatically based on schedules defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `. + +### Schedule Configuration File + +` + "```json" + ` +{ + "agents": { + "agent_name": { + "schedule": { ... }, + "enabled": true + } + } +} +` + "```" + ` + +### Schedule Types + +**IMPORTANT: All times are in local time** (the timezone of the machine running Rowboat). + +**1. Cron Schedule** - Runs at exact times defined by cron expression +` + "```json" + ` +{ + "schedule": { + "type": "cron", + "expression": "0 8 * * *" + }, + "enabled": true +} +` + "```" + ` + +Common cron expressions: +- ` + "`*/5 * * * *`" + ` - Every 5 minutes +- ` + "`0 8 * * *`" + ` - Every day at 8am +- ` + "`0 9 * * 1`" + ` - Every Monday at 9am +- ` + "`0 0 1 * *`" + ` - First day of every month at midnight + +**2. Window Schedule** - Runs once during a time window +` + "```json" + ` +{ + "schedule": { + "type": "window", + "cron": "0 0 * * *", + "startTime": "08:00", + "endTime": "10:00" + }, + "enabled": true +} +` + "```" + ` + +The agent will run once at a random time within the window. Use this when you want flexibility (e.g., "sometime in the morning" rather than "exactly at 8am"). + +**3. Once Schedule** - Runs exactly once at a specific time +` + "```json" + ` +{ + "schedule": { + "type": "once", + "runAt": "2024-02-05T10:30:00" + }, + "enabled": true +} +` + "```" + ` + +Use this for one-time tasks like migrations or setup scripts. The ` + "`runAt`" + ` is in local time (no Z suffix). + +### Starting Message + +You can specify a ` + "`startingMessage`" + ` that gets sent to the agent when it starts. If not provided, defaults to ` + "`\"go\"`" + `. + +` + "```json" + ` +{ + "schedule": { "type": "cron", "expression": "0 8 * * *" }, + "enabled": true, + "startingMessage": "Please summarize my emails from the last 24 hours" +} +` + "```" + ` + +### Description + +You can add a ` + "`description`" + ` field to describe what the agent does. This is displayed in the UI. + +` + "```json" + ` +{ + "schedule": { "type": "cron", "expression": "0 8 * * *" }, + "enabled": true, + "description": "Summarizes emails and calendar events every morning" +} +` + "```" + ` + +### Complete Schedule Example + +` + "```json" + ` +{ + "agents": { + "daily_digest": { + "schedule": { + "type": "cron", + "expression": "0 8 * * *" + }, + "enabled": true, + "description": "Daily email and calendar summary", + "startingMessage": "Summarize my emails and calendar for today" + }, + "morning_briefing": { + "schedule": { + "type": "window", + "cron": "0 0 * * *", + "startTime": "07:00", + "endTime": "09:00" + }, + "enabled": true, + "description": "Morning news and updates briefing" + }, + "one_time_setup": { + "schedule": { + "type": "once", + "runAt": "2024-12-01T12:00:00" + }, + "enabled": true, + "description": "One-time data migration task" + } + } +} +` + "```" + ` + +### Schedule State (Read-Only) + +**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner. + +The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: +- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) +- ` + "`lastRunAt`" + `: When the agent last ran +- ` + "`nextRunAt`" + `: When the agent will run next +- ` + "`lastError`" + `: Error message if the last run failed +- ` + "`runCount`" + `: Total number of runs + +When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `. + +## Agent File Format + +Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. + +### Basic Structure +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + tool_key: + type: builtin + name: tool_name +--- +# Instructions + +Your detailed instructions go here in Markdown format. +` + "```" + ` + +### Frontmatter Fields +- ` + "`model`" + `: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') +- ` + "`provider`" + `: (OPTIONAL) Provider alias from models.json +- ` + "`tools`" + `: (OPTIONAL) Object containing tool definitions + +### Instructions (Body) +The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. + +### Naming Rules +- Agent filename determines the agent name (without .md extension) +- Example: ` + "`summariser_agent.md`" + ` creates an agent named "summariser_agent" +- Use lowercase with underscores for multi-word names +- No spaces or special characters in names +- **The agent name in agent-schedule.json must match the filename** (without .md) + +### Agent Format Example +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string + description: Search query + required: + - query +--- +# Web Search Agent + +You are a web search agent. When asked a question: + +1. Use the search tool to find relevant information +2. Summarize the results clearly +3. Cite your sources + +Be concise and accurate. +` + "```" + ` + +## Tool Types & Schemas + +Tools in agents must follow one of three types. Each has specific required fields. + +### 1. Builtin Tools +Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: builtin + name: tool_name +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "builtin" +- ` + "`name`" + `: Builtin tool name (e.g., "executeCommand", "workspace-readFile") + +**Example:** +` + "```yaml" + ` +bash: + type: builtin + name: executeCommand +` + "```" + ` + +**Available builtin tools:** +- ` + "`executeCommand`" + ` - Execute shell commands +- ` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, ` + "`workspace-remove`" + ` - File operations +- ` + "`workspace-readdir`" + `, ` + "`workspace-exists`" + `, ` + "`workspace-stat`" + ` - Directory operations +- ` + "`workspace-mkdir`" + `, ` + "`workspace-rename`" + `, ` + "`workspace-copy`" + ` - File/directory management +- ` + "`analyzeAgent`" + ` - Analyze agent structure +- ` + "`addMcpServer`" + `, ` + "`listMcpServers`" + `, ` + "`listMcpTools`" + ` - MCP management +- ` + "`loadSkill`" + ` - Load skill guidance + +### 2. MCP Tools +Tools from external MCP servers (APIs, databases, web scraping, etc.) + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: mcp + name: tool_name_from_server + description: What the tool does + mcpServerName: server_name_from_config + inputSchema: + type: object + properties: + param: + type: string + description: Parameter description + required: + - param +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "mcp" +- ` + "`name`" + `: Exact tool name from MCP server +- ` + "`description`" + `: What the tool does (helps agent understand when to use it) +- ` + "`mcpServerName`" + `: Server name from config/mcp.json +- ` + "`inputSchema`" + `: Full JSON Schema object for tool parameters + +**Example:** +` + "```yaml" + ` +search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string + description: Search query + required: + - query +` + "```" + ` + +**Important:** +- Use ` + "`listMcpTools`" + ` to get the exact inputSchema from the server +- Copy the schema exactly—don't modify property types or structure +- Only include ` + "`required`" + ` array if parameters are mandatory + +### 3. Agent Tools (for chaining agents) +Reference other agents as tools to build multi-agent workflows + +**YAML Schema:** +` + "```yaml" + ` +tool_key: + type: agent + name: target_agent_name +` + "```" + ` + +**Required fields:** +- ` + "`type`" + `: Must be "agent" +- ` + "`name`" + `: Name of the target agent (must exist in agents/ directory) + +**Example:** +` + "```yaml" + ` +summariser: + type: agent + name: summariser_agent +` + "```" + ` + +**How it works:** +- Use ` + "`type: agent`" + ` to call other agents as tools +- The target agent will be invoked with the parameters you pass +- Results are returned as tool output +- This is how you build multi-agent workflows +- The referenced agent file must exist (e.g., ` + "`agents/summariser_agent.md`" + `) + +## Complete Multi-Agent Workflow Example + +**Email digest workflow** - This is all done through agents calling other agents: + +**1. Task-specific agent** (` + "`agents/email_reader.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + read_file: + type: builtin + name: workspace-readFile + list_dir: + type: builtin + name: workspace-readdir +--- +# Email Reader Agent + +Read emails from the gmail_sync folder and extract key information. +Look for unread or recent emails and summarize the sender, subject, and key points. +Don't ask for human input. +` + "```" + ` + +**2. Agent that delegates to other agents** (` + "`agents/daily_summary.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + email_reader: + type: agent + name: email_reader + write_file: + type: builtin + name: workspace-writeFile +--- +# Daily Summary Agent + +1. Use the email_reader tool to get email summaries +2. Create a consolidated daily digest +3. Save the digest to ~/Desktop/daily_digest.md + +Don't ask for human input. +` + "```" + ` + +Note: The output path (` + "`~/Desktop/daily_digest.md`" + `) is hardcoded in the instructions. When creating agents that output files, always ask the user where they want files saved and include the full path in the agent instructions. + +**3. Orchestrator agent** (` + "`agents/morning_briefing.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + daily_summary: + type: agent + name: daily_summary + search: + type: mcp + name: search + mcpServerName: exa + description: Search the web for news + inputSchema: + type: object + properties: + query: + type: string + description: Search query +--- +# Morning Briefing Workflow + +Create a morning briefing: + +1. Get email digest using daily_summary +2. Search for relevant news using the search tool +3. Compile a comprehensive morning briefing + +Execute these steps in sequence. Don't ask for human input. +` + "```" + ` + +**4. Schedule the workflow** in ` + "`~/.rowboat/config/agent-schedule.json`" + `: +` + "```json" + ` +{ + "agents": { + "morning_briefing": { + "schedule": { + "type": "cron", + "expression": "0 7 * * *" + }, + "enabled": true, + "startingMessage": "Create my morning briefing for today" + } + } +} +` + "```" + ` + +This schedules the morning briefing workflow to run every day at 7am local time. + +## Naming and organization rules +- **All agents live in ` + "`agents/*.md`" + `** - Markdown files with YAML frontmatter +- Agent filename (without .md) becomes the agent name +- When referencing an agent as a tool, use its filename without extension +- When scheduling an agent, use its filename without extension in agent-schedule.json +- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users + +## Best practices for background agents +1. **Single responsibility**: Each agent should do one specific thing well +2. **Clear delegation**: Agent instructions should explicitly say when to call other agents +3. **Autonomous operation**: Add "Don't ask for human input" for background agents +4. **Data passing**: Make it clear what data to extract and pass between agents +5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") +6. **Orchestration**: Create a top-level agent that coordinates the workflow +7. **Scheduling**: Use appropriate schedule types - cron for recurring, window for flexible timing, once for one-time tasks +8. **Error handling**: Background agents should handle errors gracefully since there's no human to intervene +9. **Avoid executeCommand**: Do NOT attach ` + "`executeCommand`" + ` to background agents as it poses security risks when running unattended. Instead, use the specific builtin tools needed (` + "`workspace-readFile`" + `, ` + "`workspace-writeFile`" + `, etc.) or MCP tools for external integrations +10. **File output paths**: When creating an agent that outputs files, ASK the user where the file should be stored (default to Desktop: ` + "`~/Desktop`" + `). Then hardcode the full output path in the agent's instructions so it knows exactly where to write files. Example instruction: "Save the output to /Users/username/Desktop/daily_report.md" + +## Validation & Best Practices + +### CRITICAL: Schema Compliance +- Agent files MUST be valid Markdown with YAML frontmatter +- Agent filename (without .md) becomes the agent name +- Tools in frontmatter MUST have valid ` + "`type`" + ` ("builtin", "mcp", or "agent") +- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema +- Agent tools MUST reference existing agent files +- Invalid agents will fail to load and prevent workflow execution + +### File Creation/Update Process +1. When creating an agent, use ` + "`workspace-writeFile`" + ` with valid Markdown + YAML frontmatter +2. When updating an agent, read it first with ` + "`workspace-readFile`" + `, modify, then use ` + "`workspace-writeFile`" + ` +3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent +4. **Quote strings containing colons** (e.g., ` + "`description: \"Default: 8\"`" + ` not ` + "`description: Default: 8`" + `) +5. Test agent loading after creation/update by using ` + "`analyzeAgent`" + ` + +### Common Validation Errors to Avoid + +❌ **WRONG - Missing frontmatter delimiters:** +` + "```markdown" + ` +model: gpt-5.1 +# My Agent +Instructions here +` + "```" + ` + +❌ **WRONG - Invalid YAML indentation:** +` + "```markdown" + ` +--- +tools: +bash: + type: builtin +--- +` + "```" + ` +(bash should be indented under tools) + +❌ **WRONG - Invalid tool type:** +` + "```yaml" + ` +tools: + tool1: + type: custom + name: something +` + "```" + ` +(type must be builtin, mcp, or agent) + +❌ **WRONG - Unquoted strings containing colons:** +` + "```yaml" + ` +tools: + search: + description: Number of results (default: 8) +` + "```" + ` +(Strings with colons must be quoted: ` + "`description: \"Number of results (default: 8)\"`" + `) + +❌ **WRONG - MCP tool missing required fields:** +` + "```yaml" + ` +tools: + search: + type: mcp + name: firecrawl_search +` + "```" + ` +(Missing: description, mcpServerName, inputSchema) + +✅ **CORRECT - Minimal valid agent** (` + "`agents/simple_agent.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +--- +# Simple Agent + +Do simple tasks as instructed. +` + "```" + ` + +✅ **CORRECT - Agent with MCP tool** (` + "`agents/search_agent.md`" + `): +` + "```markdown" + ` +--- +model: gpt-5.1 +tools: + search: + type: mcp + name: firecrawl_search + description: Search the web + mcpServerName: firecrawl + inputSchema: + type: object + properties: + query: + type: string +--- +# Search Agent + +Use the search tool to find information on the web. +` + "```" + ` + +## Capabilities checklist +1. Explore ` + "`agents/`" + ` directory to understand existing agents before editing +2. Read existing agents with ` + "`workspace-readFile`" + ` before making changes +3. Validate YAML frontmatter syntax before creating/updating agents +4. Use ` + "`analyzeAgent`" + ` to verify agent structure after creation/update +5. When creating multi-agent workflows, create an orchestrator agent +6. Add other agents as tools with ` + "`type: agent`" + ` for chaining +7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations +8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file) +9. Confirm work done and outline next steps once changes are complete +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 6ef19e8d..0d167a52 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -8,9 +8,8 @@ import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; import slackSkill from "./slack/skill.js"; -import workflowAuthoringSkill from "./workflow-authoring/skill.js"; +import backgroundAgentsSkill from "./background-agents/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; -import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -66,10 +65,10 @@ const definitions: SkillDefinition[] = [ content: slackSkill, }, { - id: "workflow-authoring", - title: "Workflow Authoring", - summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", - content: workflowAuthoringSkill, + id: "background-agents", + title: "Background Agents", + summary: "Creating, editing, and scheduling background agents. Configure schedules in agent-schedule.json and build multi-agent workflows.", + content: backgroundAgentsSkill, }, { id: "builtin-tools", @@ -89,12 +88,6 @@ const definitions: SkillDefinition[] = [ summary: "Following the confirmation process before removing workflows or agents and their dependencies.", content: deletionGuardrailsSkill, }, - { - id: "workflow-run-ops", - title: "Workflow Run Operations", - summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.", - content: workflowRunOpsSkill, - }, ]; const skillEntries = definitions.map((definition) => ({ diff --git a/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts b/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts deleted file mode 100644 index bcd50258..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/workflow-authoring/skill.ts +++ /dev/null @@ -1,384 +0,0 @@ -export const skill = String.raw` -# Agent and Workflow Authoring - -Load this skill whenever a user wants to inspect, create, or update agents inside the Rowboat workspace. - -## Core Concepts - -**IMPORTANT**: In the CLI, there are NO separate "workflow" files. Everything is an agent. - -- **All definitions live in \`agents/*.md\`** - Markdown files with YAML frontmatter -- Agents configure a model, tools (in frontmatter), and instructions (in the body) -- Tools can be: builtin (like \`executeCommand\`), MCP integrations, or **other agents** -- **"Workflows" are just agents that orchestrate other agents** by having them as tools - -## How multi-agent workflows work - -1. **Create an orchestrator agent** that has other agents in its \`tools\` -2. **Run the orchestrator**: \`rowboatx --agent orchestrator_name\` -3. The orchestrator calls other agents as tools when needed -4. Data flows through tool call parameters and responses - -## Agent File Format - -Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. - -### Basic Structure -\`\`\`markdown ---- -model: gpt-5.1 -tools: - tool_key: - type: builtin - name: tool_name ---- -# Instructions - -Your detailed instructions go here in Markdown format. -\`\`\` - -### Frontmatter Fields -- \`model\`: (OPTIONAL) Model to use (e.g., 'gpt-5.1', 'claude-sonnet-4-5') -- \`provider\`: (OPTIONAL) Provider alias from models.json -- \`tools\`: (OPTIONAL) Object containing tool definitions - -### Instructions (Body) -The Markdown body after the frontmatter contains the agent's instructions. Use standard Markdown formatting. - -### Naming Rules -- Agent filename determines the agent name (without .md extension) -- Example: \`summariser_agent.md\` creates an agent named "summariser_agent" -- Use lowercase with underscores for multi-word names -- No spaces or special characters in names - -### Agent Format Example -\`\`\`markdown ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query ---- -# Web Search Agent - -You are a web search agent. When asked a question: - -1. Use the search tool to find relevant information -2. Summarize the results clearly -3. Cite your sources - -Be concise and accurate. -\`\`\` - -## Tool Types & Schemas - -Tools in agents must follow one of three types. Each has specific required fields. - -### 1. Builtin Tools -Internal Rowboat tools (executeCommand, file operations, MCP queries, etc.) - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: builtin - name: tool_name -\`\`\` - -**Required fields:** -- \`type\`: Must be "builtin" -- \`name\`: Builtin tool name (e.g., "executeCommand", "workspace-readFile") - -**Example:** -\`\`\`yaml -bash: - type: builtin - name: executeCommand -\`\`\` - -**Available builtin tools:** -- \`executeCommand\` - Execute shell commands -- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-remove\` - File operations -- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\` - Directory operations -- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management -- \`analyzeAgent\` - Analyze agent structure -- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\` - MCP management -- \`loadSkill\` - Load skill guidance - -### 2. MCP Tools -Tools from external MCP servers (APIs, databases, web scraping, etc.) - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: mcp - name: tool_name_from_server - description: What the tool does - mcpServerName: server_name_from_config - inputSchema: - type: object - properties: - param: - type: string - description: Parameter description - required: - - param -\`\`\` - -**Required fields:** -- \`type\`: Must be "mcp" -- \`name\`: Exact tool name from MCP server -- \`description\`: What the tool does (helps agent understand when to use it) -- \`mcpServerName\`: Server name from config/mcp.json -- \`inputSchema\`: Full JSON Schema object for tool parameters - -**Example:** -\`\`\`yaml -search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string - description: Search query - required: - - query -\`\`\` - -**Important:** -- Use \`listMcpTools\` to get the exact inputSchema from the server -- Copy the schema exactly—don't modify property types or structure -- Only include \`required\` array if parameters are mandatory - -### 3. Agent Tools (for chaining agents) -Reference other agents as tools to build multi-agent workflows - -**YAML Schema:** -\`\`\`yaml -tool_key: - type: agent - name: target_agent_name -\`\`\` - -**Required fields:** -- \`type\`: Must be "agent" -- \`name\`: Name of the target agent (must exist in agents/ directory) - -**Example:** -\`\`\`yaml -summariser: - type: agent - name: summariser_agent -\`\`\` - -**How it works:** -- Use \`type: agent\` to call other agents as tools -- The target agent will be invoked with the parameters you pass -- Results are returned as tool output -- This is how you build multi-agent workflows -- The referenced agent file must exist (e.g., \`agents/summariser_agent.md\`) - -## Complete Multi-Agent Workflow Example - -**Podcast creation workflow** - This is all done through agents calling other agents: - -**1. Task-specific agent** (\`agents/summariser_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - bash: - type: builtin - name: executeCommand ---- -# Summariser Agent - -Download and summarise an arxiv paper. Use curl to fetch the PDF. -Output just the GIST in two lines. Don't ask for human input. -\`\`\` - -**2. Agent that delegates to other agents** (\`agents/summarise-a-few.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - summariser: - type: agent - name: summariser_agent ---- -# Summarise Multiple Papers - -Pick 2 interesting papers and summarise each using the summariser tool. -Pass the paper URL to the tool. Don't ask for human input. -\`\`\` - -**3. Orchestrator agent** (\`agents/podcast_workflow.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - bash: - type: builtin - name: executeCommand - summarise_papers: - type: agent - name: summarise-a-few - text_to_speech: - type: mcp - name: text_to_speech - mcpServerName: elevenLabs - description: Generate audio from text - inputSchema: - type: object - properties: - text: - type: string - description: Text to convert to speech ---- -# Podcast Workflow - -Create a podcast from arXiv papers: - -1. Fetch arXiv papers about agents using bash -2. Pick papers and summarise them using summarise_papers -3. Create a podcast transcript -4. Generate audio using text_to_speech - -Execute these steps in sequence. -\`\`\` - -**To run this workflow**: \`rowboatx --agent podcast_workflow\` - -## Naming and organization rules -- **All agents live in \`agents/*.md\`** - Markdown files with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- When referencing an agent as a tool, use its filename without extension -- Use relative paths (no \${BASE_DIR} prefixes) when giving examples to users - -## Best practices for multi-agent design -1. **Single responsibility**: Each agent should do one specific thing well -2. **Clear delegation**: Agent instructions should explicitly say when to call other agents -3. **Autonomous operation**: Add "Don't ask for human input" for autonomous workflows -4. **Data passing**: Make it clear what data to extract and pass between agents -5. **Tool naming**: Use descriptive tool keys (e.g., "summariser", "fetch_data", "analyze") -6. **Orchestration**: Create a top-level agent that coordinates the workflow - -## Validation & Best Practices - -### CRITICAL: Schema Compliance -- Agent files MUST be valid Markdown with YAML frontmatter -- Agent filename (without .md) becomes the agent name -- Tools in frontmatter MUST have valid \`type\` ("builtin", "mcp", or "agent") -- MCP tools MUST have all required fields: name, description, mcpServerName, inputSchema -- Agent tools MUST reference existing agent files -- Invalid agents will fail to load and prevent workflow execution - -### File Creation/Update Process -1. When creating an agent, use \`workspace-writeFile\` with valid Markdown + YAML frontmatter -2. When updating an agent, read it first with \`workspace-readFile\`, modify, then use \`workspace-writeFile\` -3. Validate YAML syntax in frontmatter before writing—malformed YAML breaks the agent -4. **Quote strings containing colons** (e.g., \`description: "Default: 8"\` not \`description: Default: 8\`) -5. Test agent loading after creation/update by using \`analyzeAgent\` - -### Common Validation Errors to Avoid - -❌ **WRONG - Missing frontmatter delimiters:** -\`\`\`markdown -model: gpt-5.1 -# My Agent -Instructions here -\`\`\` - -❌ **WRONG - Invalid YAML indentation:** -\`\`\`markdown ---- -tools: -bash: - type: builtin ---- -\`\`\` -(bash should be indented under tools) - -❌ **WRONG - Invalid tool type:** -\`\`\`yaml -tools: - tool1: - type: custom - name: something -\`\`\` -(type must be builtin, mcp, or agent) - -❌ **WRONG - Unquoted strings containing colons:** -\`\`\`yaml -tools: - search: - description: Number of results (default: 8) -\`\`\` -(Strings with colons must be quoted: \`description: "Number of results (default: 8)"\`) - -❌ **WRONG - MCP tool missing required fields:** -\`\`\`yaml -tools: - search: - type: mcp - name: firecrawl_search -\`\`\` -(Missing: description, mcpServerName, inputSchema) - -✅ **CORRECT - Minimal valid agent** (\`agents/simple_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 ---- -# Simple Agent - -Do simple tasks as instructed. -\`\`\` - -✅ **CORRECT - Agent with MCP tool** (\`agents/search_agent.md\`): -\`\`\`markdown ---- -model: gpt-5.1 -tools: - search: - type: mcp - name: firecrawl_search - description: Search the web - mcpServerName: firecrawl - inputSchema: - type: object - properties: - query: - type: string ---- -# Search Agent - -Use the search tool to find information on the web. -\`\`\` - -## Capabilities checklist -1. Explore \`agents/\` directory to understand existing agents before editing -2. Read existing agents with \`workspace-readFile\` before making changes -3. Validate YAML frontmatter syntax before creating/updating agents -4. Use \`analyzeAgent\` to verify agent structure after creation/update -5. When creating multi-agent workflows, create an orchestrator agent -6. Add other agents as tools with \`type: agent\` for chaining -7. Use \`listMcpServers\` and \`listMcpTools\` when adding MCP integrations -8. Confirm work done and outline next steps once changes are complete -`; - -export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts b/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts deleted file mode 100644 index 25f62267..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/workflow-run-ops/skill.ts +++ /dev/null @@ -1,95 +0,0 @@ -export const skill = String.raw` -# Agent Run Operations - -Package of repeatable commands for running agents, inspecting agent run history under ~/.rowboat/runs, and managing cron schedules. Load this skill whenever a user asks about running agents, execution history, paused runs, or scheduling. - -## When to use -- User wants to run an agent (including multi-agent workflows) -- User wants to list or filter agent runs (all runs, by agent, time range, or paused for input) -- User wants to inspect cron jobs or change agent schedules -- User asks how to set up monitoring for waiting runs - -## Running Agents - -**To run any agent**: -\`\`\`bash -rowboatx --agent -\`\`\` - -**With input**: -\`\`\`bash -rowboatx --agent --input "your input here" -\`\`\` - -**Non-interactive** (for automation/cron): -\`\`\`bash -rowboatx --agent --input "input" --no-interactive -\`\`\` - -**Note**: Multi-agent workflows are just agents that have other agents in their tools. Run the orchestrator agent to trigger the whole workflow. - -## Run monitoring examples -Operate from ~/.rowboat (Rowboat tools already set this as the working directory). Use executeCommand with the sample Bash snippets below, modifying placeholders as needed. - -Each run file name starts with a timestamp like '2025-11-12T08-02-41Z'. You can use this to filter for date/time ranges. - -Each line of the run file contains a running log with the first line containing information about the agent run. E.g. '{"type":"start","runId":"2025-11-12T08-02-41Z-0014322-000","agent":"agent_name","interactive":true,"ts":"2025-11-12T08:02:41.168Z"}' - -If a run is waiting for human input the last line will contain 'paused_for_human_input'. See examples below. - -1. **List all runs** - - ls ~/.rowboat/runs - - -2. **Filter by agent** - - grep -rl '"agent":""' ~/.rowboat/runs | xargs -n1 basename | sed 's/\.jsonl$//' | sort -r - - Replace with the desired agent name. - -3. **Filter by time window** - To the previous commands add the below through unix pipe - - awk -F'/' '$NF >= "2025-11-12T08-03" && $NF <= "2025-11-12T08-10"' - - Use the correct timestamps. - -4. **Show runs waiting for human input** - - awk 'FNR==1{if (NR>1) print fn, last; fn=FILENAME} {last=$0} END{print fn, last}' ~/.rowboat/runs/*.jsonl | grep 'pause-for-human-input' | awk '{print $1}' - - Prints the files whose last line equals 'pause-for-human-input'. - -## Cron management examples - -For scheduling agents to run automatically at specific times. - -1. **View current cron schedule** - \`\`\`bash - crontab -l 2>/dev/null || echo 'No crontab entries configured.' - \`\`\` - -2. **Schedule an agent to run periodically** - \`\`\`bash - (crontab -l 2>/dev/null; echo '0 10 * * * cd /path/to/cli && rowboatx --agent --input "input" --no-interactive >> ~/.rowboat/logs/.log 2>&1') | crontab - - \`\`\` - - Example (runs daily at 10 AM): - \`\`\`bash - (crontab -l 2>/dev/null; echo '0 10 * * * cd ~/rowboat-V2/apps/cli && rowboatx --agent podcast_workflow --no-interactive >> ~/.rowboat/logs/podcast.log 2>&1') | crontab - - \`\`\` - -3. **Unschedule/remove an agent** - \`\`\`bash - crontab -l | grep -v '' | crontab - - \`\`\` - -## Common cron schedule patterns -- \`0 10 * * *\` - Daily at 10 AM -- \`0 */6 * * *\` - Every 6 hours -- \`0 9 * * 1\` - Every Monday at 9 AM -- \`*/30 * * * *\` - Every 30 minutes -`; - -export default skill; diff --git a/apps/x/packages/core/src/config/initConfigs.ts b/apps/x/packages/core/src/config/initConfigs.ts index 1c447e37..adfb8b24 100644 --- a/apps/x/packages/core/src/config/initConfigs.ts +++ b/apps/x/packages/core/src/config/initConfigs.ts @@ -1,6 +1,8 @@ import container from "../di/container.js"; import type { IModelConfigRepo } from "../models/repo.js"; import type { IMcpConfigRepo } from "../mcp/repo.js"; +import type { IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import type { IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { ensureSecurityConfig } from "./security.js"; /** @@ -11,10 +13,14 @@ export async function initConfigs(): Promise { // Resolve repos and explicitly call their ensureConfig methods const modelConfigRepo = container.resolve("modelConfigRepo"); const mcpConfigRepo = container.resolve("mcpConfigRepo"); + const agentScheduleRepo = container.resolve("agentScheduleRepo"); + const agentScheduleStateRepo = container.resolve("agentScheduleStateRepo"); await Promise.all([ modelConfigRepo.ensureConfig(), mcpConfigRepo.ensureConfig(), + agentScheduleRepo.ensureConfig(), + agentScheduleStateRepo.ensureState(), ensureSecurityConfig(), ]); } diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 2b3fd2d7..d02ca7e6 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -12,6 +12,8 @@ import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js"; import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; +import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; +import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -33,6 +35,8 @@ container.register({ oauthRepo: asClass(FSOAuthRepo).singleton(), clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(), granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(), + agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(), + agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/shared/src/agent-schedule-state.ts b/apps/x/packages/shared/src/agent-schedule-state.ts new file mode 100644 index 00000000..09e9037c --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule-state.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +// "triggered" is terminal state for once-schedules (will not run again) +export const AgentScheduleStatus = z.enum(["scheduled", "running", "finished", "failed", "triggered"]); + +export const AgentScheduleStateEntry = z.object({ + status: AgentScheduleStatus, + startedAt: z.string().nullable(), // When current run started (for timeout detection) + lastRunAt: z.string().nullable(), // ISO 8601 local datetime + nextRunAt: z.string().nullable(), // ISO 8601 local datetime + lastError: z.string().nullable(), + runCount: z.number().default(0), +}); + +export const AgentScheduleState = z.object({ + agents: z.record(z.string(), AgentScheduleStateEntry), +}); diff --git a/apps/x/packages/shared/src/agent-schedule.ts b/apps/x/packages/shared/src/agent-schedule.ts new file mode 100644 index 00000000..62184083 --- /dev/null +++ b/apps/x/packages/shared/src/agent-schedule.ts @@ -0,0 +1,44 @@ +import z from "zod"; + +// Cron schedule - runs at exact times defined by cron expression. +// Examples: +// - Every 5 minutes: "*/5 * * * *" +// - Everyday at 8am: "0 8 * * *" +// - Every Monday at 9am: "0 9 * * 1" +export const CronSchedule = z.object({ + type: z.literal("cron"), + expression: z.string(), +}); + +// Window schedule - runs once during a time window. +// The agent will run once at a random time within the specified window. +// Examples: +// - Daily between 8am and 10am: cron="0 0 * * *", startTime="08:00", endTime="10:00" +// - Weekly on Monday between 9am-12pm: cron="0 0 * * 1", startTime="09:00", endTime="12:00" +export const WindowSchedule = z.object({ + type: z.literal("window"), + cron: z.string(), // Base frequency cron expression + startTime: z.string(), // "HH:MM" format + endTime: z.string(), // "HH:MM" format +}); + +// Once schedule - runs exactly once at a specific time, then never again. +// Examples: +// - Run once at specific datetime: runAt="2024-02-05T10:30:00" +export const OnceSchedule = z.object({ + type: z.literal("once"), + runAt: z.string(), // ISO 8601 datetime (local time, e.g., "2024-02-05T10:30:00") +}); + +export const ScheduleDefinition = z.union([CronSchedule, WindowSchedule, OnceSchedule]); + +export const AgentScheduleEntry = z.object({ + schedule: ScheduleDefinition, + enabled: z.boolean().optional().default(true), + startingMessage: z.string().optional(), // Message sent to agent when run starts (defaults to "go") + description: z.string().optional(), // Brief description of what the agent does (for UI display) +}); + +export const AgentScheduleConfig = z.object({ + agents: z.record(z.string(), AgentScheduleEntry), +}); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 3bca8969..5d54883f 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -4,4 +4,6 @@ export * as ipc from './ipc.js'; export * as models from './models.js'; export * as workspace from './workspace.js'; export * as mcp from './mcp.js'; +export * as agentSchedule from './agent-schedule.js'; +export * as agentScheduleState from './agent-schedule-state.js'; export { PrefixLogger }; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 2835a90b..767de9a0 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -3,6 +3,8 @@ import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, Work import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; import { LlmModelConfig } from './models.js'; +import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; +import { AgentScheduleState } from './agent-schedule-state.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -353,6 +355,32 @@ const ipcSchemas = { }), res: z.null(), }, + // Agent schedule channels + 'agent-schedule:getConfig': { + req: z.null(), + res: AgentScheduleConfig, + }, + 'agent-schedule:getState': { + req: z.null(), + res: AgentScheduleState, + }, + 'agent-schedule:updateAgent': { + req: z.object({ + agentName: z.string(), + entry: AgentScheduleEntry, + }), + res: z.object({ + success: z.literal(true), + }), + }, + 'agent-schedule:deleteAgent': { + req: z.object({ + agentName: z.string(), + }), + res: z.object({ + success: z.literal(true), + }), + }, } as const; // ============================================================================ diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 5995b0ea..2775c44c 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + cron-parser: + specifier: ^5.5.0 + version: 5.5.0 glob: specifier: ^13.0.0 version: 13.0.0 @@ -3452,6 +3455,10 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -4248,6 +4255,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -4256,12 +4264,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4898,6 +4906,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + macos-alias@0.2.12: resolution: {integrity: sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==} os: [darwin] @@ -10620,6 +10632,10 @@ snapshots: crelt@1.0.6: {} + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + cross-dirname@0.1.0: {} cross-spawn@6.0.6: @@ -12345,6 +12361,8 @@ snapshots: dependencies: react: 19.2.3 + luxon@3.7.2: {} + macos-alias@0.2.12: dependencies: nan: 2.24.0