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.
This commit is contained in:
@@ -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.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.
|
- **file.list** -- List directory contents. Supports glob patterns.
|
||||||
- **system.info** -- Get current date, time, hostname, platform, and system information.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -1063,7 +1063,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1292,
|
"total_test_count": 1329,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (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",
|
"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",
|
"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"
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,4 +100,54 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
getJobNames(): string[] {
|
getJobNames(): string[] {
|
||||||
return Array.from(this.jobs.keys());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-3
@@ -8,7 +8,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
|
|||||||
const cronList: Tool = {
|
const cronList: Tool = {
|
||||||
name: 'cron.list',
|
name: 'cron.list',
|
||||||
description:
|
description:
|
||||||
'List all configured cron jobs with their names and status.',
|
'List all configured cron jobs with their names, schedules, and messages.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {},
|
properties: {},
|
||||||
@@ -21,7 +21,11 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
|
|||||||
return { success: true, output: 'No cron jobs configured.' };
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: `${jobNames.length} cron job(s):\n\n${lines.join('\n')}`,
|
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<ToolResult> => {
|
||||||
|
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<ToolResult> => {
|
||||||
|
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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
'drive.read',
|
'drive.read',
|
||||||
'tasks.lists',
|
'tasks.lists',
|
||||||
'tasks.list',
|
'tasks.list',
|
||||||
|
'cron.list',
|
||||||
|
'cron.trigger',
|
||||||
|
'cron.create',
|
||||||
|
'cron.delete',
|
||||||
]),
|
]),
|
||||||
coding: new Set([
|
coding: new Set([
|
||||||
'file.read',
|
'file.read',
|
||||||
@@ -58,6 +62,10 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
'drive.read',
|
'drive.read',
|
||||||
'tasks.lists',
|
'tasks.lists',
|
||||||
'tasks.list',
|
'tasks.list',
|
||||||
|
'cron.list',
|
||||||
|
'cron.trigger',
|
||||||
|
'cron.create',
|
||||||
|
'cron.delete',
|
||||||
'file.write',
|
'file.write',
|
||||||
'file.edit',
|
'file.edit',
|
||||||
'file.patch',
|
'file.patch',
|
||||||
@@ -90,6 +98,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||||||
'group:gdocs': ['docs.list', 'docs.search', 'docs.read'],
|
'group:gdocs': ['docs.list', 'docs.search', 'docs.read'],
|
||||||
'group:gdrive': ['drive.list', 'drive.search', 'drive.read'],
|
'group:gdrive': ['drive.list', 'drive.search', 'drive.read'],
|
||||||
'group:gtasks': ['tasks.lists', 'tasks.list'],
|
'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. */
|
/** Expand group references in a list of tool names/patterns. */
|
||||||
|
|||||||
Reference in New Issue
Block a user