fix: improve agent output schema, tool descriptions, and shutdown robustness

- Rename status→outputType, responded/silent→message/log for clarity
- Remove scheduled task special-casing: userMessage now sent for all contexts
- Update schema, tool, and CLAUDE.md descriptions to be clear and
  non-contradictory about communication mechanisms
- Use full tool name mcp__nanoclaw__send_message in docs
- Change schedule_task target_group to accept JID instead of folder name
- Only show target_group_jid parameter to main group agents
- Add defense-in-depth sanitization and error callback to exec() in shutdown
- Use "user or group" consistently (supports both 1:1 and group chats)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-06 20:22:45 +02:00
parent ae177156ec
commit 44f0b3d99c
8 changed files with 68 additions and 63 deletions

View File

@@ -18,7 +18,7 @@ interface ContainerInput {
} }
interface AgentResponse { interface AgentResponse {
status: 'responded' | 'silent'; outputType: 'message' | 'log';
userMessage?: string; userMessage?: string;
internalLog?: string; internalLog?: string;
} }
@@ -26,21 +26,21 @@ interface AgentResponse {
const AGENT_RESPONSE_SCHEMA = { const AGENT_RESPONSE_SCHEMA = {
type: 'object', type: 'object',
properties: { properties: {
status: { outputType: {
type: 'string', type: 'string',
enum: ['responded', 'silent'], enum: ['message', 'log'],
description: 'Use "responded" when you have a message for the user. Use "silent" when the messages don\'t require a response (e.g. the conversation is between other people and doesn\'t involve you, or no trigger/mention was directed at you).', description: '"message": the userMessage field contains a message to send to the user or group. "log": the output will not be sent to the user or group.',
}, },
userMessage: { userMessage: {
type: 'string', type: 'string',
description: 'The message to send to the user. Required when status is "responded".', description: 'A message to send to the user or group. Include when outputType is "message".',
}, },
internalLog: { internalLog: {
type: 'string', type: 'string',
description: 'Optional internal note about why you chose this status (for logging, not shown to users).', description: 'Information that will be logged internally but not sent to the user or group.',
}, },
}, },
required: ['status'], required: ['outputType'],
} as const; } as const;
interface ContainerOutput { interface ContainerOutput {
@@ -254,7 +254,7 @@ async function main(): Promise<void> {
// Add context for scheduled tasks // Add context for scheduled tasks
let prompt = input.prompt; let prompt = input.prompt;
if (input.isScheduledTask) { if (input.isScheduledTask) {
prompt = `[SCHEDULED TASK - You are running automatically, not in response to a user message. Use mcp__nanoclaw__send_message if needed to communicate with the user.]\n\n${input.prompt}`; prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${input.prompt}`;
} }
try { try {
@@ -294,13 +294,13 @@ async function main(): Promise<void> {
if (message.type === 'result') { if (message.type === 'result') {
if (message.subtype === 'success' && message.structured_output) { if (message.subtype === 'success' && message.structured_output) {
result = message.structured_output as AgentResponse; result = message.structured_output as AgentResponse;
log(`Agent result: status=${result.status}${result.internalLog ? `, log=${result.internalLog}` : ''}`); log(`Agent result: outputType=${result.outputType}${result.internalLog ? `, log=${result.internalLog}` : ''}`);
} else if (message.subtype === 'error_max_structured_output_retries') { } else if (message.subtype === 'error_max_structured_output_retries') {
// Agent couldn't produce valid structured output — fall back to text result // Agent couldn't produce valid structured output — fall back to text result
log('Agent failed to produce structured output, falling back to text'); log('Agent failed to produce structured output, falling back to text');
const textResult = 'result' in message ? (message as { result?: string }).result : null; const textResult = 'result' in message ? (message as { result?: string }).result : null;
if (textResult) { if (textResult) {
result = { status: 'responded', userMessage: textResult }; result = { outputType: 'message', userMessage: textResult };
} }
} }
} }
@@ -309,7 +309,7 @@ async function main(): Promise<void> {
log('Agent completed successfully'); log('Agent completed successfully');
writeOutput({ writeOutput({
status: 'success', status: 'success',
result: result ?? { status: 'silent' }, result: result ?? { outputType: 'log' },
newSessionId newSessionId
}); });

View File

@@ -42,7 +42,7 @@ export function createIpcMcp(ctx: IpcMcpContext) {
tools: [ tools: [
tool( tool(
'send_message', 'send_message',
'Send a message to the current WhatsApp group. Use this to proactively share information or updates.', 'Send a message to the user or group. The message is delivered immediately while you\'re still running. You can call this multiple times to send multiple messages.',
{ {
text: z.string().describe('The message text to send') text: z.string().describe('The message text to send')
}, },
@@ -55,12 +55,12 @@ export function createIpcMcp(ctx: IpcMcpContext) {
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
const filename = writeIpcFile(MESSAGES_DIR, data); writeIpcFile(MESSAGES_DIR, data);
return { return {
content: [{ content: [{
type: 'text', type: 'text',
text: `Message queued for delivery (${filename})` text: 'Message sent.'
}] }]
}; };
} }
@@ -71,10 +71,10 @@ export function createIpcMcp(ctx: IpcMcpContext) {
`Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools.
CONTEXT MODE - Choose based on task type: CONTEXT MODE - Choose based on task type:
• "group" (recommended for most tasks): Task runs in the group's conversation context, with access to chat history and memory. Use for tasks that need context about ongoing discussions, user preferences, or previous interactions. • "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions.
• "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. • "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself.
If unsure which mode to use, ask the user. Examples: If unsure which mode to use, you can ask the user. Examples:
- "Remind me about our discussion" → group (needs conversation context) - "Remind me about our discussion" → group (needs conversation context)
- "Check the weather every morning" → isolated (self-contained task) - "Check the weather every morning" → isolated (self-contained task)
- "Follow up on my request" → group (needs to know what was requested) - "Follow up on my request" → group (needs to know what was requested)
@@ -89,7 +89,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'),
schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'),
context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'),
target_group: z.string().optional().describe('Target group folder (main only, defaults to current group)') ...(isMain ? { target_group_jid: z.string().optional().describe('JID of the group to schedule the task for. The group must be registered — look up JIDs in /workspace/project/data/registered_groups.json (the keys are JIDs). If the group is not registered, let the user know and ask if they want to activate it. Defaults to the current group.') } : {}),
}, },
async (args) => { async (args) => {
// Validate schedule_value before writing IPC // Validate schedule_value before writing IPC
@@ -121,7 +121,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
} }
// Non-main groups can only schedule for themselves // Non-main groups can only schedule for themselves
const targetGroup = isMain && args.target_group ? args.target_group : groupFolder; const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid;
const data = { const data = {
type: 'schedule_task', type: 'schedule_task',
@@ -129,8 +129,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
schedule_type: args.schedule_type, schedule_type: args.schedule_type,
schedule_value: args.schedule_value, schedule_value: args.schedule_value,
context_mode: args.context_mode || 'group', context_mode: args.context_mode || 'group',
groupFolder: targetGroup, targetJid,
chatJid,
createdBy: groupFolder, createdBy: groupFolder,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };

View File

@@ -11,24 +11,16 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c
- Schedule tasks to run later or on a recurring basis - Schedule tasks to run later or on a recurring basis
- Send messages back to the chat - Send messages back to the chat
## Long Tasks ## Communication
If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: You have two ways to send messages to the user or group:
1. Send a brief message: what you understood and what you'll do - **mcp__nanoclaw__send_message tool** — Sends a message to the user or group immediately, while you're still running. You can call it multiple times.
2. Do the work - **Output userMessage** — When your outputType is "message", this is sent to the user or group.
3. Exit with the final answer
This keeps users informed instead of waiting in silence. Your output **internalLog** is information that will be logged internally but not sent to the user or group.
## Scheduled Tasks For requests that involve significant work, consider sending a quick acknowledgment via mcp__nanoclaw__send_message so the user knows you're working on it.
When you run as a scheduled task (no direct user message), use `mcp__nanoclaw__send_message` if needed to communicate with the user. Your return value is only logged internally - it won't be sent to the user.
Example: If your task is "Share the weather forecast", you should:
1. Get the weather data
2. Call `mcp__nanoclaw__send_message` with the formatted forecast
3. Return a brief summary for the logs
## Your Workspace ## Your Workspace

View File

@@ -11,15 +11,16 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c
- Schedule tasks to run later or on a recurring basis - Schedule tasks to run later or on a recurring basis
- Send messages back to the chat - Send messages back to the chat
## Long Tasks ## Communication
If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: You have two ways to send messages to the user or group:
1. Send a brief message: what you understood and what you'll do - **mcp__nanoclaw__send_message tool** — Sends a message to the user or group immediately, while you're still running. You can call it multiple times.
2. Do the work - **Output userMessage** — When your outputType is "message", this is sent to the user or group.
3. Exit with the final answer
This keeps users informed instead of waiting in silence. Your output **internalLog** is information that will be logged internally but not sent to the user or group.
For requests that involve significant work, consider sending a quick acknowledgment via mcp__nanoclaw__send_message so the user knows you're working on it.
## Memory ## Memory
@@ -188,7 +189,7 @@ You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts
## Scheduling for Other Groups ## Scheduling for Other Groups
When scheduling tasks for other groups, use the `target_group` parameter: When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group: "family-chat")` - `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory. The task will run in that group's context with access to their files and memory.

View File

@@ -41,7 +41,7 @@ export interface ContainerInput {
} }
export interface AgentResponse { export interface AgentResponse {
status: 'responded' | 'silent'; outputType: 'message' | 'log';
userMessage?: string; userMessage?: string;
internalLog?: string; internalLog?: string;
} }

View File

@@ -256,8 +256,16 @@ export class GroupQueue {
// Stop all active containers gracefully // Stop all active containers gracefully
for (const { jid, proc, containerName } of activeProcs) { for (const { jid, proc, containerName } of activeProcs) {
if (containerName) { if (containerName) {
logger.info({ jid, containerName }, 'Stopping container'); // Defense-in-depth: re-sanitize before shell interpolation.
exec(`container stop ${containerName}`); // Primary sanitization is in container-runner.ts when building the name,
// but we sanitize again here since exec() runs through a shell.
const safeName = containerName.replace(/[^a-zA-Z0-9-]/g, '');
logger.info({ jid, containerName: safeName }, 'Stopping container');
exec(`container stop ${safeName}`, (err) => {
if (err) {
logger.warn({ jid, containerName: safeName, err: err.message }, 'container stop failed');
}
});
} else { } else {
logger.info({ jid, pid: proc.pid }, 'Sending SIGTERM to process'); logger.info({ jid, pid: proc.pid }, 'Sending SIGTERM to process');
proc.kill('SIGTERM'); proc.kill('SIGTERM');

View File

@@ -247,13 +247,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
missedMessages[missedMessages.length - 1].timestamp; missedMessages[missedMessages.length - 1].timestamp;
saveState(); saveState();
if (response.status === 'responded' && response.userMessage) { if (response.outputType === 'message' && response.userMessage) {
await sendMessage(chatJid, `${ASSISTANT_NAME}: ${response.userMessage}`); await sendMessage(chatJid, `${ASSISTANT_NAME}: ${response.userMessage}`);
} }
if (response.internalLog) { if (response.internalLog) {
logger.info( logger.info(
{ group: group.name, agentStatus: response.status }, { group: group.name, outputType: response.outputType },
`Agent: ${response.internalLog}`, `Agent: ${response.internalLog}`,
); );
} }
@@ -320,7 +320,7 @@ async function runAgent(
return 'error'; return 'error';
} }
return output.result ?? { status: 'silent' }; return output.result ?? { outputType: 'log' };
} catch (err) { } catch (err) {
logger.error({ group: group.name, err }, 'Agent error'); logger.error({ group: group.name, err }, 'Agent error');
return 'error'; return 'error';
@@ -468,6 +468,7 @@ async function processTaskIpc(
context_mode?: string; context_mode?: string;
groupFolder?: string; groupFolder?: string;
chatJid?: string; chatJid?: string;
targetJid?: string;
// For register_group // For register_group
jid?: string; jid?: string;
name?: string; name?: string;
@@ -484,27 +485,27 @@ async function processTaskIpc(
data.prompt && data.prompt &&
data.schedule_type && data.schedule_type &&
data.schedule_value && data.schedule_value &&
data.groupFolder data.targetJid
) { ) {
// Authorization: non-main groups can only schedule for themselves // Resolve the target group from JID
const targetGroup = data.groupFolder; const targetJid = data.targetJid as string;
if (!isMain && targetGroup !== sourceGroup) { const targetGroupEntry = registeredGroups[targetJid];
if (!targetGroupEntry) {
logger.warn( logger.warn(
{ sourceGroup, targetGroup }, { targetJid },
'Unauthorized schedule_task attempt blocked', 'Cannot schedule task: target group not registered',
); );
break; break;
} }
// Resolve the correct JID for the target group (don't trust IPC payload) const targetFolder = targetGroupEntry.folder;
const targetJid = Object.entries(registeredGroups).find(
([, group]) => group.folder === targetGroup,
)?.[0];
if (!targetJid) { // Authorization: non-main groups can only schedule for themselves
if (!isMain && targetFolder !== sourceGroup) {
logger.warn( logger.warn(
{ targetGroup }, { sourceGroup, targetFolder },
'Cannot schedule task: target group not registered', 'Unauthorized schedule_task attempt blocked',
); );
break; break;
} }
@@ -554,7 +555,7 @@ async function processTaskIpc(
: 'isolated'; : 'isolated';
createTask({ createTask({
id: taskId, id: taskId,
group_folder: targetGroup, group_folder: targetFolder,
chat_jid: targetJid, chat_jid: targetJid,
prompt: data.prompt, prompt: data.prompt,
schedule_type: scheduleType, schedule_type: scheduleType,
@@ -565,7 +566,7 @@ async function processTaskIpc(
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}); });
logger.info( logger.info(
{ taskId, sourceGroup, targetGroup, contextMode }, { taskId, sourceGroup, targetFolder, contextMode },
'Task created via IPC', 'Task created via IPC',
); );
} }

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { import {
ASSISTANT_NAME,
GROUPS_DIR, GROUPS_DIR,
MAIN_GROUP_FOLDER, MAIN_GROUP_FOLDER,
SCHEDULER_POLL_INTERVAL, SCHEDULER_POLL_INTERVAL,
@@ -104,6 +105,9 @@ async function runTask(
if (output.status === 'error') { if (output.status === 'error') {
error = output.error || 'Unknown error'; error = output.error || 'Unknown error';
} else if (output.result) { } else if (output.result) {
if (output.result.outputType === 'message' && output.result.userMessage) {
await deps.sendMessage(task.chat_jid, `${ASSISTANT_NAME}: ${output.result.userMessage}`);
}
result = output.result.userMessage || output.result.internalLog || null; result = output.result.userMessage || output.result.internalLog || null;
} }