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
386 changes: 270 additions & 116 deletions docs/design-docs/slash-commands.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions prompts/en/commands/digest.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using available tools and channel context, generate a concise day digest from local 00:00 to now with exactly this order:
1) top decisions
2) key convo themes
3) open loops
keep it practical and concise; if there are no meaningful updates, reply exactly: no material updates today.
4 changes: 4 additions & 0 deletions prompts/en/commands/tasks.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
use channel tools to fetch my ready tasks (limit 10) and reply exactly with:
- header: tasks (ready):
- each line: - #<task_number> [<priority>] <title>
if no tasks are ready, reply exactly: tasks (ready): none
7 changes: 7 additions & 0 deletions prompts/en/commands/today.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use channel tools to build a local tasks snapshot and reply exactly in this format:
- first line: today (local tasks snapshot):
- section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title>
- section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title>
if a section is empty use:
- in progress: none
- up next (ready): none
Comment on lines +1 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Define literal headers for non-empty sections.

The prompt requires an exact format, but it does not require in progress: or up next (ready): when a section contains tasks. The response can contain two indistinguishable groups of task lines. Add literal section headers to the non-empty format and keep the empty format consistent.

Proposed prompt wording
- - section 1: in-progress tasks (up to 5), each line:   #<task_number> [<priority>] <title>
- - section 2: up next ready tasks (up to 5), each line:   #<task_number> [<priority>] <title>
+ - second line: in progress:
+ - next up to 5 lines:   #<task_number> [<priority>] <title>
+ - next section header: up next (ready):
+ - next up to 5 lines:   #<task_number> [<priority>] <title>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
use channel tools to build a local tasks snapshot and reply exactly in this format:
- first line: today (local tasks snapshot):
- section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title>
- section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title>
if a section is empty use:
- in progress: none
- up next (ready): none
use channel tools to build a local tasks snapshot and reply exactly in this format:
- first line: today (local tasks snapshot):
- second line: in progress:
- next up to 5 lines: #<task_number> [<priority>] <title>
- next section header: up next (ready):
- next up to 5 lines: #<task_number> [<priority>] <title>
if a section is empty use:
- in progress: none
- up next (ready): none
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prompts/en/commands/today.md.j2` around lines 1 - 7, Update the prompt’s
non-empty output format to require literal headers for both sections, using “in
progress:” and “up next (ready):” before their task lines. Keep the existing
task-line format and empty-section strings unchanged, ensuring both populated
and empty sections remain clearly distinguishable.

214 changes: 90 additions & 124 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1100,37 +1100,6 @@ impl Channel {
self.control_handle.clone()
}

fn rewrite_tool_routed_command_prompt(&self, raw_text: &str) -> Option<String> {
match raw_text.trim() {
"/tasks" => Some(
"use channel tools to fetch my ready tasks (limit 10) and reply exactly with:\n\
- header: tasks (ready):\n\
- each line: - #<task_number> [<priority>] <title>\n\
if no tasks are ready, reply exactly: tasks (ready): none"
.to_string(),
),
"/today" => Some(
"use channel tools to build a local tasks snapshot and reply exactly in this format:\n\
- first line: today (local tasks snapshot):\n\
- section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\
- section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\
if a section is empty use:\n\
- in progress: none\n\
- up next (ready): none"
.to_string(),
),
"/digest" => Some(
"using available tools and channel context, generate a concise day digest from local 00:00 to now with exactly this order:\n\
1) top decisions\n\
2) key convo themes\n\
3) open loops\n\
keep it practical and concise; if there are no meaningful updates, reply exactly: no material updates today."
.to_string(),
),
_ => None,
}
}

fn compute_listen_mode_invocation(
&self,
message: &InboundMessage,
Expand Down Expand Up @@ -1212,32 +1181,20 @@ impl Channel {
}
}

async fn try_handle_builtin_ops_commands(
/// Execute a control-plane command. These run deterministically against
/// channel state and never consume an agent turn.
async fn handle_control_command(
&mut self,
raw_text: &str,
message: &InboundMessage,
) -> Result<bool> {
if message.source == "system" {
return Ok(false);
}
let supported_source = matches!(
message.source.as_str(),
"telegram" | "discord" | "slack" | "twitch" | "signal"
);
if !supported_source {
return Ok(false);
}

let text = raw_text.trim();
if !text.starts_with('/') {
return Ok(false);
}

let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref());
let now_line = temporal_context.current_time_line();
def: &'static crate::commands::CommandDef,
action: crate::commands::ControlAction,
) {
use crate::commands::ControlAction;

match text {
"/status" => {
match action {
ControlAction::Status => {
let temporal_context =
TemporalContext::from_runtime(self.deps.runtime_config.as_ref());
let now_line = temporal_context.current_time_line();
let routing = self.deps.runtime_config.routing.load();
let channel_model = self
.resolved_settings
Expand Down Expand Up @@ -1270,59 +1227,33 @@ impl Channel {
branch_model,
now_line
);
self.send_builtin_text(body, "status").await;
return Ok(true);
}
"/quiet" | "/observe" => {
self.set_response_mode(ResponseMode::Observe).await;
self.send_builtin_text(
"observe mode enabled. i'll learn from this conversation but won't respond."
.to_string(),
"observe",
)
.await;
return Ok(true);
self.send_builtin_text(body, def.name).await;
}
"/active" => {
self.set_response_mode(ResponseMode::Active).await;
self.send_builtin_text(
"active mode enabled. i'll respond normally in this chat.".to_string(),
"active",
)
.await;
return Ok(true);
ControlAction::SetResponseMode(mode) => {
self.set_response_mode(mode).await;
let confirmation = match mode {
ResponseMode::Active => {
"active mode enabled. i'll respond normally in this chat."
}
ResponseMode::Observe => {
"observe mode enabled. i'll learn from this conversation but won't respond."
}
ResponseMode::MentionOnly => {
"mention-only mode enabled. i'll only respond when @mentioned or replied to."
}
};
self.send_builtin_text(confirmation.to_string(), def.name)
.await;
}
"/mention-only" => {
self.set_response_mode(ResponseMode::MentionOnly).await;
self.send_builtin_text(
"mention-only mode enabled. i'll only respond when @mentioned or replied to."
.to_string(),
"mention-only",
)
.await;
return Ok(true);
ControlAction::Help => {
self.send_builtin_text(crate::commands::REGISTRY.help_text(), def.name)
.await;
}
"/help" => {
let lines = [
"commands:".to_string(),
"- /status: current mode, models, binding snapshot".to_string(),
"- /today: in-progress + ready task snapshot".to_string(),
"- /tasks: ready task list".to_string(),
"- /digest: one-shot day digest (00:00 -> now)".to_string(),
"- /observe: learn from conversation, never respond".to_string(),
"- /mention-only: only respond when @mentioned, replied to, or given a command"
.to_string(),
"- /active: normal reply mode".to_string(),
"- /agent-id: runtime agent id".to_string(),
];
let body = lines.join("\n");
self.send_builtin_text(body, "help").await;
return Ok(true);
ControlAction::AgentId => {
self.send_builtin_text(self.deps.agent_id.to_string(), def.name)
.await;
}
_ => {}
}

Ok(false)
}

/// Run the channel event loop.
Expand Down Expand Up @@ -1483,6 +1414,7 @@ impl Channel {
.as_deref()
.is_some_and(|value| value.trim_start().starts_with('/')),
crate::MessageContent::Interaction { .. } => false,
crate::MessageContent::Command { .. } => true,
};
if looks_like_command {
return false;
Expand Down Expand Up @@ -1684,8 +1616,10 @@ impl Channel {
crate::MessageContent::Media { text, attachments } => {
(text.clone().unwrap_or_default(), attachments.clone())
}
// Render interactions as their Display form so the LLM sees plain text.
crate::MessageContent::Interaction { .. } => {
// Render interactions and commands as their Display form
// so the LLM sees plain text.
crate::MessageContent::Interaction { .. }
| crate::MessageContent::Command { .. } => {
(message.content.to_string(), Vec::new())
}
};
Expand Down Expand Up @@ -2073,8 +2007,12 @@ impl Channel {
crate::MessageContent::Media { text, attachments } => {
(text.clone().unwrap_or_default(), attachments.clone())
}
// Render interactions as their Display form so the LLM sees plain text.
crate::MessageContent::Interaction { .. } => (message.content.to_string(), Vec::new()),
// Render interactions and commands as their Display form so the
// LLM sees plain text; a Command renders as "/name args" and is
// dispatched by the same parse below.
crate::MessageContent::Interaction { .. } | crate::MessageContent::Command { .. } => {
(message.content.to_string(), Vec::new())
}
};

// Save attachments to disk when enabled, capturing bytes for LLM reuse
Expand Down Expand Up @@ -2107,11 +2045,27 @@ impl Channel {
self.persist_inbound_user_message(&message, &raw_text, saved_metas.as_deref());
self.track_participant_from_message(&message).await;

// Deterministic built-in command: bypass model output drift for agent identity checks.
if message.source != "system" && raw_text.trim() == "/agent-id" {
self.send_builtin_text(self.deps.agent_id.to_string(), "agent-id")
.await;
return Ok(());
// Slash-command dispatch. Control commands execute deterministically
// on the spot and never consume an agent turn; agent commands are
// rewritten into their instruction below. System messages are never
// commands, and unrecognized "/words" flow to the model as text.
let parsed_command = if message.source == "system" {
crate::commands::ParseResult::NotACommand
} else {
crate::commands::REGISTRY.parse(&raw_text)
};
match &parsed_command {
crate::commands::ParseResult::Command(cmd) => {
if let crate::commands::CommandHandler::Control(action) = cmd.def.handler {
self.handle_control_command(cmd.def, action).await;
return Ok(());
Comment on lines +2048 to +2061

@coderabbitai coderabbitai Bot Aug 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Route control commands outside the channel actor queue.

Lines 2048-2061 execute control commands only after handle_message starts. While run_agent_turn awaits at Lines 2220-2228, the serial actor cannot dequeue a /status or mode command. A pending coalesce buffer also flushes before this path runs.

Parse and route recognized control commands through a control-plane entry point before queueing an InboundMessage. Bind the reply to the originating inbound message. Do not flush the buffered conversation for a control command.

As per coding guidelines, “Don't block the channel. The channel never waits on branches, workers, or compaction.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/channel.rs` around lines 2048 - 2061, Move control-command parsing
and dispatch out of the serial channel actor path, routing recognized control
commands through a control-plane entry point before enqueueing the
InboundMessage. Use the originating inbound message to bind the reply, return
without flushing any pending coalesce buffer, and leave agent-command rewriting
and unrecognized command text on the existing path; update the relevant channel
entry point and handle_control_command flow rather than the in-actor match
alone.

Source: Coding guidelines

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct observation, deliberately out of scope for this PR. This is phase 2 of the design committed on this branch (docs/design-docs/slash-commands.md): control commands move to the control plane (ChannelControlHandle / ProcessControl / settings store) and stop entering the channel queue entirely, which also covers the coalesce-flush ordering. Phase 1 is the behavior-preserving port — commands dispatch from the same position in handle_message as the code they replace.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamiepine, acknowledged. The finding is valid, but it does not apply to the Phase 1 scope.

Phase 1 preserves the existing Channel::handle_message dispatch position. Phase 2 will move control commands to the control plane and remove their dependency on the channel actor queue. That change will also prevent coalesce-buffer flushing for control commands.

Would you like me to create a GitHub follow-up issue for Phase 2?

You are interacting with an AI system.

}
}
crate::commands::ParseResult::Usage(_, usage) => {
self.send_builtin_text(usage.clone(), "command-usage").await;
return Ok(());
}
crate::commands::ParseResult::NotACommand => {}
}

// Deterministic liveness ping for Telegram mentions.
Expand Down Expand Up @@ -2155,18 +2109,30 @@ impl Channel {
)?);
}

if self
.try_handle_builtin_ops_commands(&raw_text, &message)
.await?
{
return Ok(());
}

let rewritten_text = if message.source == "system" {
raw_text.clone()
} else {
self.rewrite_tool_routed_command_prompt(&raw_text)
.unwrap_or_else(|| raw_text.clone())
let rewritten_text = match &parsed_command {
crate::commands::ParseResult::Command(cmd) => match cmd.def.handler {
crate::commands::CommandHandler::Agent(
crate::commands::AgentAction::PromptTemplate(template),
) => {
let prompt_engine = self.deps.runtime_config.prompts.load();
match prompt_engine.render_static(template) {
Ok(instruction) => instruction,
Err(error) => {
tracing::error!(
channel_id = %self.id,
command = cmd.def.name,
%template,
%error,
"failed to render command prompt template; using raw text"
);
raw_text.clone()
}
}
}
// Control commands returned above.
crate::commands::CommandHandler::Control(_) => raw_text.clone(),
},
_ => raw_text.clone(),
};

let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref());
Expand Down
10 changes: 10 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! Slash commands: the typed registry and its dispatch types.
//!
//! Design: `docs/design-docs/slash-commands.md`.

pub mod registry;

pub use registry::{
AgentAction, ArgSpec, COMMANDS, CommandCategory, CommandDef, CommandHandler, CommandRegistry,
ControlAction, ParseResult, ParsedCommand, REGISTRY,
};
Loading
Loading