Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 8 additions & 10 deletions src/agent/routine_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,18 +488,16 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(),
})?;

// Set the message tool's default channel/target from the routine's notify config
// so the LLM can send results without triggering cross-channel approval.
// TODO: This mutates shared global state and can race with concurrent jobs.
// Move notify config into JobContext metadata and apply per-job instead.
// Notify config is carried in job metadata (see above) and read by
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The comment "Notify config is carried in job metadata (see above) and read by MessageTool::execute from JobContext" references "see above" but there is nothing above this function explaining this mechanism. The metadata population happens immediately below (lines 494-500), not above. The comment is self-contradictory: it says the config is carried in metadata, then immediately populates the metadata. The "see above" reference should be removed or replaced with "see below".

Suggested change
// Notify config is carried in job metadata (see above) and read by
// Notify config is carried in job metadata (populated below) and read by

Copilot uses AI. Check for mistakes.
// MessageTool::execute from JobContext — no global state mutation needed.

let mut metadata = serde_json::json!({ "max_iterations": max_iterations });
// Carry the routine's notify config in job metadata so the message tool
// can resolve channel/target per-job without global state mutation.
if let Some(channel) = &routine.notify.channel {
scheduler
.tools()
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
.await;
metadata["notify_channel"] = serde_json::json!(channel);
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

When routine.notify.channel is None, notify_channel is intentionally omitted from the job metadata. However, the PR description states this fix resolves the case where notify.channel is None — yet the message tool's channel resolution (in message.rs lines 115–135) will still fall through all three tiers and return "No channel specified" in that case. Only the notify_user / target resolution is always populated (line 500).

If a full-job routine is expected to work when notify.channel is None (meaning "broadcast to all channels"), the metadata key should still be set unconditionally (e.g., as an empty string or a sentinel value), or the broadcast/default-channel behavior needs to be handled differently in MessageTool::execute. Currently, the channel case is treated symmetrically with target but the population logic is asymmetric: notify_user is always written, notify_channel is only written when non-None.

Suggested change
metadata["notify_channel"] = serde_json::json!(channel);
metadata["notify_channel"] = serde_json::json!(channel);
} else {
// When no explicit channel is configured, write an empty string so
// MessageTool::execute can treat this as "no specific channel" (e.g.,
// broadcast or default-channel behavior) instead of seeing the key
// as entirely absent and reporting "No channel specified".
metadata["notify_channel"] = serde_json::json!("");

Copilot uses AI. Check for mistakes.
}

let metadata = serde_json::json!({ "max_iterations": max_iterations });
metadata["notify_user"] = serde_json::json!(&routine.notify.user);

// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
// Always tools require explicit listing in tool_permissions.
Expand Down
106 changes: 83 additions & 23 deletions src/tools/builtin/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,42 +105,52 @@ impl Tool for MessageTool {
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();

let content = require_str(&params, "content")?;

// Get channel: use param or fall back to default
// Get channel: use param → conversation default → job metadata
let channel = if let Some(c) = params.get("channel").and_then(|v| v.as_str()) {
c.to_string()
} else if let Some(c) = self
.default_channel
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
c
} else if let Some(c) = ctx
.metadata
.get("notify_channel")
.and_then(|v| v.as_str())
{
c.to_string()
} else {
self.default_channel
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
.ok_or_else(|| {
ToolError::ExecutionFailed(
"No channel specified and no active conversation. Provide channel parameter."
.to_string(),
)
})?
return Err(ToolError::ExecutionFailed(
"No channel specified and no active conversation. Provide channel parameter."
.to_string(),
));
};

// Get target: use param or fall back to default
// Get target: use param → conversation default → job metadata
let target = if let Some(t) = params.get("target").and_then(|v| v.as_str()) {
t.to_string()
} else if let Some(t) = self
.default_target
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
t
} else if let Some(t) = ctx.metadata.get("notify_user").and_then(|v| v.as_str()) {
t.to_string()
} else {
self.default_target
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
.ok_or_else(|| {
ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
.to_string(),
)
})?
return Err(ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
.to_string(),
));
};

let attachments: Vec<String> = match params.get("attachments") {
Expand Down Expand Up @@ -576,4 +586,54 @@ mod tests {
ApprovalRequirement::Never,
);
}

#[tokio::test]
async fn message_tool_falls_back_to_job_metadata() {
// Regression: when no conversation context is set (e.g. routine full-job),
// the message tool should fall back to notify_channel/notify_user from
// JobContext metadata instead of returning "No target specified".
let tool = MessageTool::new(Arc::new(ChannelManager::new()));

let mut ctx = crate::context::JobContext::new("routine-job", "price alert");
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
"notify_user": "123456789",
});

// No set_context called — simulates a routine full-job worker
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await;

// Should fail at channel broadcast (no real channel), NOT at
// "No target specified and no active conversation"
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
!err.contains("No target specified"),
"Should not get 'No target specified' when metadata has notify_user, got: {}",
err
);
assert!(
!err.contains("No channel specified"),
"Should not get 'No channel specified' when metadata has notify_channel, got: {}",
err
);
}

#[tokio::test]
async fn message_tool_no_metadata_still_errors() {
// When neither conversation context nor metadata is set, should still
// return a clear error.
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
let ctx = crate::context::JobContext::new("orphan-job", "no notify config");

let result = tool
.execute(serde_json::json!({"content": "hello"}), &ctx)
.await;

assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("No channel specified") || err.contains("No target specified"));
}
}
Loading