Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/config/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ You are the user's digital clone with access to their message history, accumulat

You run inside the Nomos daemon — a self-hosted, locally-running agent. You are NOT Claude.ai's web product.

**Scheduling — IMPORTANT.** You may notice a built-in skill called \`schedule\` (sometimes invoked as \`/schedule\`) in your skill list. **DO NOT USE IT.** That skill creates Anthropic-hosted Routines with a 1-hour minimum interval — it is a different product unrelated to Nomos. **Always** use the \`schedule_task\` MCP tool instead. It accepts any interval ('1m', '5m', '15m', '30m', '1h', etc.) and runs the task inside the local Nomos daemon. There is no minimum interval. Examples:
**Scheduling — IMPORTANT.** You may notice built-in skills called \`schedule\` (\`/schedule\`) and \`loop\` (\`/loop\`) in your skill list, and built-in tools named \`CronCreate\`, \`RemoteTrigger\`, or \`ScheduleWakeup\`. **DO NOT USE ANY OF THEM.** They create Anthropic-hosted claude.ai Routines (1-hour minimum, results land on the claude.ai dashboard, and they never run in the daemon or show up in the user's settings) — a different product unrelated to Nomos. **Always** use the Nomos MCP tools instead: \`schedule_task\` for one-off or recurring background tasks, and \`loop_create\` for autonomous loops. They accept any interval ('1m', '5m', '15m', '30m', '1h', etc.), have no minimum, and run inside the local Nomos daemon. Examples:
- "check email every 15 min" → \`schedule_task(schedule='15m', schedule_type='every', prompt='Check unread emails and draft replies')\`
- "remind me daily at 9am" → \`schedule_task(schedule='0 9 * * *', schedule_type='cron', prompt='...')\`

Expand Down Expand Up @@ -315,6 +315,13 @@ You can create background tasks that run automatically in the daemon:
When the user asks for recurring actions (e.g. "check my emails every 15 minutes", "remind me daily"), create a scheduled task instead of suggesting manual checks. Tasks run in the background even between conversations.`,
);

// Asking & planning — these tools render proper cards in the app.
sections.push(
`## Asking & planning
- **Need the user to decide or supply a missing detail before you can act?** ALWAYS ask through the \`ask_user\` tool — NEVER by writing questions in prose. It shows them tappable choices. Ask the SINGLE most important missing detail first, with 2-4 short options; once they answer, ask the next. Even when several things are unknown (e.g. "book me a dinner reservation" → day, time, party size, place), do NOT dump a numbered list of questions in the chat — pick the one detail that unblocks you and ask it with \`ask_user\`. Only skip the tool when you can reasonably proceed on a sensible default or infer the answer from memory.
- **Laying out a multi-step plan?** Use the \`TodoWrite\` tool to write the steps as todos — it renders a single tracked plan the user can follow, and you update statuses as you work. Do NOT spin up real scheduled tasks for ephemeral planning steps; \`schedule_task\` is only for things that should actually run on a schedule.`,
);

// Permissions
const permissionsSection = [
`## Permissions
Expand Down
57 changes: 43 additions & 14 deletions src/daemon/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,31 @@ import { resolveMemoryUserId } from "../auth/tenant-context.ts";
* here so both single-agent and team-runtime call sites stay consistent.
*/
function getDisallowedTools(): string[] {
// The SDK's generic `Workflow` orchestration tool spawns its OWN sub-agents
// outside Nomos's team runtime (no memory/persona, an async "notify when done"
// model that doesn't fit a single chat turn) and leaks a raw script into the UI.
// Block it so "spin up a team" routes to the Nomos-native `delegate_to_team`.
const blocked: string[] = ["Workflow"];
// Block the SDK's generic orchestration/task built-ins so the agent routes to the
// Nomos-native equivalents (which render proper cards + own durable state):
// - `Workflow` spawns sub-agents outside the team runtime + leaks a raw script →
// use `delegate_to_team`.
// - `TaskCreate`/`TaskList`/`TaskUpdate`/`TaskDelete` are the SDK task tracker;
// they render as raw CoT noise instead of a Plan card and create stray tasks →
// use `TodoWrite` for a tracked plan, `schedule_task`/`loop_create` for real ones.
// - `CronCreate`/`CronDelete`/`CronList`/`RemoteTrigger`/`ScheduleWakeup` are what the
// built-in `schedule` + `loop` skills call to create Anthropic-hosted claude.ai
// Routines (1-hour minimum, results land on the claude.ai dashboard, never run in
// the daemon and never show in the user's settings). A prompt warning alone didn't
// stop the agent from reaching for them, so block them outright → the agent must use
// the `schedule_task` / `loop_create` MCP tools, which run locally in the daemon.
const blocked: string[] = [
"Workflow",
"TaskCreate",
"TaskList",
"TaskUpdate",
"TaskDelete",
"CronCreate",
"CronDelete",
"CronList",
"RemoteTrigger",
"ScheduleWakeup",
];
if (!FEATURES.bashTool()) {
blocked.push("Bash", "BashOutput", "KillBash");
}
Expand Down Expand Up @@ -88,16 +108,25 @@ function summarizeToolInput(name: string, input: unknown): string {
case "Grep":
case "Glob":
return str(o.pattern);
case "Skill":
return str(o.skill) || str(o.name) || str(o.command);
default: {
const pick =
str(o.query) || str(o.prompt) || str(o.path) || str(o.message) || str(o.description);
if (pick) return pick;
try {
const j = JSON.stringify(input);
return j && j.length > 2 ? (j.length > 120 ? `${j.slice(0, 117)}…` : j) : "";
} catch {
return "";
}
// A friendly summary from a known field. NEVER dump raw JSON into the card —
// an unrecognized tool with no readable field shows no subtitle instead.
return (
str(o.query) ||
str(o.prompt) ||
str(o.path) ||
str(o.message) ||
str(o.description) ||
str(o.skill) ||
str(o.name) ||
str(o.command) ||
str(o.url) ||
str(o.content) ||
str(o.title) ||
""
);
}
}
}
Expand Down
14 changes: 9 additions & 5 deletions src/daemon/channels/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SlackBolt from "@slack/bolt";
import type { ChannelAdapter, IncomingMessage, OutgoingMessage } from "../types.ts";
import type { DraftManager } from "../draft-manager.ts";
import { chunkResponse } from "../response-chunker.ts";
import { markdownToSlackMrkdwn } from "./slack-mrkdwn.ts";
import { randomUUID } from "node:crypto";
import { createLogger } from "../../lib/logger.ts";

Expand Down Expand Up @@ -168,7 +169,9 @@ export class SlackAdapter implements ChannelAdapter {
async send(message: OutgoingMessage): Promise<void> {
if (!this.app) return;
const token = process.env.SLACK_BOT_TOKEN!;
const result = chunkResponse(message.content, "slack");
// Translate Markdown -> Slack mrkdwn before chunking so the language tag on
// fenced code blocks is stripped and **bold**/headings/links render natively.
const result = chunkResponse(markdownToSlackMrkdwn(message.content), "slack");

for (const text of result.chunks) {
await this.app.client.chat.postMessage({
Expand All @@ -179,13 +182,14 @@ export class SlackAdapter implements ChannelAdapter {
});
}

// Upload full response as file for very long messages
// Upload full response as file for very long messages. The file keeps the
// original Markdown -- it renders correctly in editors/viewers as-is.
if (result.strategy === "file" && result.fullText && result.filename) {
const uploadArgs = {
token,
channel_id: message.channelId,
filename: result.filename,
content: result.fullText,
content: message.content,
title: "Full Response",
...(message.threadId ? { thread_ts: message.threadId } : {}),
};
Expand All @@ -203,7 +207,7 @@ export class SlackAdapter implements ChannelAdapter {
const result = await this.app.client.chat.postMessage({
token: process.env.SLACK_BOT_TOKEN!,
channel: channelId,
text,
text: markdownToSlackMrkdwn(text),
thread_ts: threadId,
});
return result.ts;
Expand All @@ -215,7 +219,7 @@ export class SlackAdapter implements ChannelAdapter {
token: process.env.SLACK_BOT_TOKEN!,
channel: channelId,
ts: messageId,
text,
text: markdownToSlackMrkdwn(text),
});
}

Expand Down
Loading