From 5270234bbb181e5f5f522f8077de0aae0555d0a3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 11 Feb 2026 09:32:36 -0800 Subject: [PATCH] feat: improve tool usage guidance in SOUL.md and add cron.create/cron.delete tools - SOUL.md: list all available tools (web.search, memory.*, cron.*, etc.) and add Tool Usage Rules section enforcing 'act, don't narrate' - cron.ts: add getJob(), addJob(), removeJob() to CronScheduler for runtime (ephemeral) cron job management - cron tools: add cron.create and cron.delete tools, enhance cron.list to show schedule/output/message details - policy.ts: add cron tools to messaging and coding profiles, add group:cron to tool groups Fixes issue where models would narrate tool intent ('let me search...') then stop without actually calling tools. --- SOUL.md | 19 ++++- docs/plans/state.json | 12 +++- src/automation/cron.ts | 50 ++++++++++++++ src/tools/builtin/cron.ts | 142 +++++++++++++++++++++++++++++++++++++- src/tools/policy.ts | 9 +++ 5 files changed, 226 insertions(+), 6 deletions(-) diff --git a/SOUL.md b/SOUL.md index 0fcbd04..dbac901 100644 --- a/SOUL.md +++ b/SOUL.md @@ -48,9 +48,24 @@ You have tools for interacting with your operator's system: - **file.patch** -- Apply structured multi-hunk patches to one or more files. Line-based replacements, insertions, and deletions in a single call. - **file.list** -- List directory contents. Supports glob patterns. - **system.info** -- Get current date, time, hostname, platform, and system information. -- **web.fetch** -- Fetch web pages. Use for looking things up, checking URLs, downloading content. +- **web.fetch** -- Fetch a specific URL and extract its content as markdown or text. Use when you already know the URL. +- **web.search** -- Search the web for current information. Returns titles, URLs, and snippets. Use this to look things up when you don't have a specific URL. +- **memory.read / memory.write / memory.search** -- Persistent memory across sessions. Store and retrieve notes, facts, and context. +- **cron.list / cron.trigger / cron.create** -- List, manually trigger, or create new scheduled automation jobs. +- **process.start / process.status / process.output / process.kill / process.list** -- Manage background processes. +- **message.send** -- Send messages to other channels (Telegram, Discord, etc.). -Use tools when the task requires it. For conversational questions, respond directly. Don't narrate tool usage -- just use them and present results. +Additional tools (image.analyze, media.send, browser.*, gmail.*, calendar.*, sessions.*, agents.list) may be available depending on configuration. Check your tool definitions if unsure. + +## Tool Usage Rules + +**Act, don't narrate.** When a task requires tools, call them immediately. Never say "let me search for that" or "I'll look that up" and then stop -- actually call the tool in the same response. The worst possible behavior is describing what you would do without doing it. + +**Be honest about limitations.** If you lack the tools or access to complete a task, say so clearly. Never generate a confident-sounding response that implies you're about to take action when you have no way to follow through. "I don't have a tool for that" is always better than "Let me do that for you" followed by nothing. + +**Use the right tool.** If someone asks you to search for something and you have `web.search`, use it. Don't fall back to `web.fetch` with a guessed URL. If someone asks about recent events, search -- don't guess from training data. + +For conversational questions, respond directly. Don't narrate tool usage -- just use them and present results. --- diff --git a/docs/plans/state.json b/docs/plans/state.json index 304b15e..2703278 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1063,7 +1063,7 @@ }, "overall_progress": { - "total_test_count": 1292, + "total_test_count": 1329, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1082,5 +1082,15 @@ "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 1/2 plans complete — metrics backend done, dashboard UI next", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "next_up": "GSD Milestone: Operator DX — Phase 3 Plan 02 (Dashboard UI consuming metrics RPC). All phases P0-P8 and Tiers 1-4 complete. Setup wizard added. TUI fullscreen mode now has full tool access and proper display. Remaining gaps: Tier 4 channels (Signal, Matrix, Teams, Google Chat), Tier 5 deferred/niche items" + }, + "soul_md_and_cron_create": { + "date": "2026-02-11", + "summary": "SOUL.md updated with comprehensive tool listing (web.search, memory.*, cron.*, process.*) and stronger 'act don't narrate' guidance. Added cron.create and cron.delete tools for runtime cron job management. Updated tool policy profiles (messaging/coding) and group:cron. Fixed issue where Flynn would narrate intended actions without calling tools.", + "files_modified": [ + "SOUL.md", + "src/tools/builtin/cron.ts", + "src/automation/cron.ts", + "src/tools/policy.ts" + ] } } \ No newline at end of file diff --git a/src/automation/cron.ts b/src/automation/cron.ts index b1c7b01..95e190e 100644 --- a/src/automation/cron.ts +++ b/src/automation/cron.ts @@ -100,4 +100,54 @@ export class CronScheduler implements ChannelAdapter { getJobNames(): string[] { return Array.from(this.jobs.keys()); } + + /** Get a job's config by name. */ + getJob(name: string): CronJobConfig | undefined { + return this.jobs.get(name); + } + + /** + * Add a new cron job at runtime and start it immediately. + * The job is ephemeral — it persists only until the daemon restarts. + * Returns true if the job was created, false if a job with that name already exists. + */ + addJob(config: CronJobConfig): boolean { + if (this.jobs.has(config.name)) { + return false; + } + + this.jobs.set(config.name, config); + + if (config.enabled && this._status === 'connected') { + const cronInstance = new Cron(config.schedule, { + timezone: config.timezone, + paused: false, + }, () => { + this.triggerJob(config.name); + }); + + this.cronInstances.set(config.name, cronInstance); + } + + return true; + } + + /** + * Remove a cron job by name. Stops the cron instance if running. + * Returns true if the job was found and removed, false otherwise. + */ + removeJob(name: string): boolean { + if (!this.jobs.has(name)) { + return false; + } + + const cronInstance = this.cronInstances.get(name); + if (cronInstance) { + cronInstance.stop(); + this.cronInstances.delete(name); + } + + this.jobs.delete(name); + return true; + } } diff --git a/src/tools/builtin/cron.ts b/src/tools/builtin/cron.ts index 533839d..21b21a4 100644 --- a/src/tools/builtin/cron.ts +++ b/src/tools/builtin/cron.ts @@ -8,7 +8,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] { const cronList: Tool = { name: 'cron.list', description: - 'List all configured cron jobs with their names and status.', + 'List all configured cron jobs with their names, schedules, and messages.', inputSchema: { type: 'object', properties: {}, @@ -21,7 +21,11 @@ export function createCronTools(scheduler: CronScheduler): Tool[] { return { success: true, output: 'No cron jobs configured.' }; } - const lines = jobNames.map((name) => `- ${name}`); + const lines = jobNames.map((name) => { + const job = scheduler.getJob(name); + if (!job) return `- ${name}`; + return `- **${name}** — schedule: \`${job.schedule}\`, enabled: ${job.enabled}, output: ${job.output.channel}/${job.output.peer}\n message: "${job.message.length > 80 ? job.message.slice(0, 80) + '...' : job.message}"`; + }); return { success: true, output: `${jobNames.length} cron job(s):\n\n${lines.join('\n')}`, @@ -78,5 +82,137 @@ export function createCronTools(scheduler: CronScheduler): Tool[] { }, }; - return [cronList, cronTrigger]; + const cronCreate: Tool = { + name: 'cron.create', + description: + 'Create a new cron job that sends a message on a schedule. The job starts immediately and runs until the daemon restarts (ephemeral). Use standard cron syntax for the schedule (e.g. "0 9 * * *" for daily at 9am, "*/30 * * * *" for every 30 minutes).', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Unique name for the cron job (kebab-case, e.g. "apartment-check")', + }, + schedule: { + type: 'string', + description: 'Cron schedule expression (e.g. "0 9 * * *" for daily at 9am, "0 */6 * * *" for every 6 hours)', + }, + message: { + type: 'string', + description: 'The message to send when the job fires. This message will be processed as if a user sent it, so you can include instructions for yourself.', + }, + output_channel: { + type: 'string', + description: 'Channel to send output to (e.g. "telegram", "discord", "slack"). Defaults to "cron" (responses stay in the cron session).', + }, + output_peer: { + type: 'string', + description: 'Peer ID on the output channel (e.g. Telegram chat ID). Required if output_channel is set.', + }, + timezone: { + type: 'string', + description: 'IANA timezone (e.g. "America/Los_Angeles"). Defaults to system timezone.', + }, + }, + required: ['name', 'schedule', 'message'], + }, + execute: async (rawArgs: unknown): Promise => { + const args = rawArgs as { + name: string; + schedule: string; + message: string; + output_channel?: string; + output_peer?: string; + timezone?: string; + }; + + try { + // Validate name format + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(args.name)) { + return { + success: false, + output: '', + error: 'Job name must be kebab-case (lowercase letters, numbers, and hyphens, not starting/ending with hyphen)', + }; + } + + const outputChannel = args.output_channel ?? 'cron'; + const outputPeer = args.output_peer ?? args.name; + + const created = scheduler.addJob({ + name: args.name, + schedule: args.schedule, + message: args.message, + output: { + channel: outputChannel, + peer: outputPeer, + }, + enabled: true, + timezone: args.timezone, + }); + + if (!created) { + return { + success: false, + output: '', + error: `Cron job "${args.name}" already exists. Use a different name or delete the existing one first.`, + }; + } + + return { + success: true, + output: `Cron job "${args.name}" created and started.\nSchedule: ${args.schedule}${args.timezone ? ` (${args.timezone})` : ''}\nOutput: ${outputChannel}/${outputPeer}\nMessage: "${args.message}"\n\nNote: This job is ephemeral and will not persist across daemon restarts. To make it permanent, add it to the config file.`, + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + const cronDelete: Tool = { + name: 'cron.delete', + description: + 'Delete a cron job by name. Stops the job immediately. Only works for runtime-created jobs; config-defined jobs will reappear on restart.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Name of the cron job to delete', + }, + }, + required: ['name'], + }, + execute: async (rawArgs: unknown): Promise => { + const args = rawArgs as { name: string }; + + try { + const removed = scheduler.removeJob(args.name); + if (!removed) { + return { + success: false, + output: '', + error: `Cron job "${args.name}" not found. Available: ${scheduler.getJobNames().join(', ')}`, + }; + } + + return { + success: true, + output: `Cron job "${args.name}" deleted and stopped.`, + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + return [cronList, cronTrigger, cronCreate, cronDelete]; } diff --git a/src/tools/policy.ts b/src/tools/policy.ts index 80ab2cc..82410d8 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -34,6 +34,10 @@ const PROFILE_TOOLS: Record> = { 'drive.read', 'tasks.lists', 'tasks.list', + 'cron.list', + 'cron.trigger', + 'cron.create', + 'cron.delete', ]), coding: new Set([ 'file.read', @@ -58,6 +62,10 @@ const PROFILE_TOOLS: Record> = { 'drive.read', 'tasks.lists', 'tasks.list', + 'cron.list', + 'cron.trigger', + 'cron.create', + 'cron.delete', 'file.write', 'file.edit', 'file.patch', @@ -90,6 +98,7 @@ export const TOOL_GROUPS: Record = { 'group:gdocs': ['docs.list', 'docs.search', 'docs.read'], 'group:gdrive': ['drive.list', 'drive.search', 'drive.read'], 'group:gtasks': ['tasks.lists', 'tasks.list'], + 'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'], }; /** Expand group references in a list of tool names/patterns. */