Skip to content

Commit c57ea27

Browse files
houkoclaude
andcommitted
feat: add message_react tool for AI-chosen emoji reactions
- Add `message_react` tool so agents can choose their own reaction emoji instead of hardcoded lifecycle emojis - Add `send_channel_reaction()` to KernelHandle trait and implement in kernel - Inject [channel_context] header into messages so agents know channel, recipient, and message_id for the react tool - Remove hardcoded "done" reaction — agent chooses via tool call - Keep hardcoded "error" reaction (agent can't react on failure) - Update bridge integration tests for channel_context header in messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5b29656 commit c57ea27

5 files changed

Lines changed: 147 additions & 26 deletions

File tree

crates/openfang-channels/src/bridge.rs

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -656,8 +656,14 @@ async fn dispatch_message(
656656
// Send typing indicator (best-effort)
657657
let _ = adapter.send_typing(&message.sender).await;
658658

659-
// Lifecycle reactions: 👀 queued → 🤔 thinking → 👍 done / 👎 error
659+
// Inject channel context so agent can use message_react tool
660660
let msg_id = &message.platform_message_id;
661+
let text_with_ctx = format!(
662+
"[channel_context: channel={} recipient={} message_id={}]\n{}",
663+
ct_str, &message.sender.platform_id, msg_id, &text
664+
);
665+
666+
// Ack reaction: 👀 immediately so user knows we received it
661667
let _ = adapter
662668
.send_reaction(
663669
&message.sender,
@@ -671,9 +677,8 @@ async fn dispatch_message(
671677
.await;
672678

673679
// Send to agent; switch to "thinking" after a short delay while waiting
674-
let agent_future = handle.send_message(agent_id, &text);
680+
let agent_future = handle.send_message(agent_id, &text_with_ctx);
675681
let thinking_delay = tokio::time::sleep(std::time::Duration::from_secs(2));
676-
let mut sent_thinking = false;
677682

678683
tokio::pin!(agent_future);
679684
tokio::pin!(thinking_delay);
@@ -695,25 +700,15 @@ async fn dispatch_message(
695700
},
696701
)
697702
.await;
698-
sent_thinking = true;
699703
// Now await the actual response
700704
agent_future.await
701705
}
702706
};
703707

704708
match result {
705709
Ok(response) => {
706-
let _ = adapter
707-
.send_reaction(
708-
&message.sender,
709-
msg_id,
710-
&LifecycleReaction {
711-
phase: AgentPhase::Done,
712-
emoji: default_phase_emoji(&AgentPhase::Done).to_string(),
713-
remove_previous: true,
714-
},
715-
)
716-
.await;
710+
// No hardcoded "done" reaction — agent can choose via message_react tool.
711+
// Only clear thinking reaction if agent didn't react.
717712
send_response(adapter, &message.sender, response, thread_id, output_format).await;
718713
handle
719714
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
@@ -752,7 +747,6 @@ async fn dispatch_message(
752747
.await;
753748
}
754749
}
755-
let _ = sent_thinking; // suppress unused warning
756750
}
757751

758752
/// Handle a bot command (returns the response text).

crates/openfang-channels/tests/bridge_integration_test.rs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,18 +225,31 @@ async fn test_bridge_dispatch_text_message() {
225225
// Give the async dispatch loop time to process
226226
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
227227

228-
// Verify: adapter received the echo response
228+
// Verify: adapter received the echo response (now includes channel_context prefix)
229229
let sent = adapter_ref.get_sent();
230230
assert_eq!(sent.len(), 1, "Expected 1 response, got {}", sent.len());
231231
assert_eq!(sent[0].0, "user1");
232-
assert_eq!(sent[0].1, "Echo: Hello agent!");
232+
assert!(
233+
sent[0].1.contains("Hello agent!"),
234+
"Response should contain original message, got: {}",
235+
sent[0].1
236+
);
233237

234-
// Verify: handle received the message
238+
// Verify: handle received the message with channel_context header
235239
{
236240
let received = handle.received.lock().unwrap();
237241
assert_eq!(received.len(), 1);
238242
assert_eq!(received[0].0, agent_id);
239-
assert_eq!(received[0].1, "Hello agent!");
243+
assert!(
244+
received[0].1.contains("Hello agent!"),
245+
"Handle should receive original message, got: {}",
246+
received[0].1
247+
);
248+
assert!(
249+
received[0].1.contains("[channel_context:"),
250+
"Handle should receive channel_context header, got: {}",
251+
received[0].1
252+
);
240253
}
241254

242255
manager.stop().await;
@@ -486,7 +499,10 @@ async fn test_bridge_manager_lifecycle() {
486499
assert_eq!(sent.len(), 5, "Expected 5 responses, got {}", sent.len());
487500

488501
for (i, (_, text)) in sent.iter().enumerate() {
489-
assert_eq!(*text, format!("Echo: message {i}"));
502+
assert!(
503+
text.contains(&format!("message {i}")),
504+
"Response {i} should contain 'message {i}', got: {text}"
505+
);
490506
}
491507

492508
// Stop — should complete without hanging
@@ -535,11 +551,19 @@ async fn test_bridge_multiple_adapters() {
535551

536552
let tg_sent = tg_ref.get_sent();
537553
assert_eq!(tg_sent.len(), 1);
538-
assert_eq!(tg_sent[0].1, "Echo: from telegram");
554+
assert!(
555+
tg_sent[0].1.contains("from telegram"),
556+
"Telegram response should contain original message, got: {}",
557+
tg_sent[0].1
558+
);
539559

540560
let dc_sent = dc_ref.get_sent();
541561
assert_eq!(dc_sent.len(), 1);
542-
assert_eq!(dc_sent[0].1, "Echo: from discord");
562+
assert!(
563+
dc_sent[0].1.contains("from discord"),
564+
"Discord response should contain original message, got: {}",
565+
dc_sent[0].1
566+
);
543567

544568
manager.stop().await;
545569
}

crates/openfang-kernel/src/kernel.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5397,6 +5397,41 @@ impl KernelHandle for OpenFangKernel {
53975397
Ok(format!("{} sent to {} via {}", media_type, recipient, channel))
53985398
}
53995399

5400+
async fn send_channel_reaction(
5401+
&self,
5402+
channel: &str,
5403+
recipient: &str,
5404+
message_id: &str,
5405+
emoji: &str,
5406+
) -> Result<String, String> {
5407+
let adapter = self
5408+
.channel_adapters
5409+
.get(channel)
5410+
.ok_or_else(|| {
5411+
format!("Channel '{}' not found for reaction", channel)
5412+
})?
5413+
.clone();
5414+
5415+
let user = openfang_channels::types::ChannelUser {
5416+
platform_id: recipient.to_string(),
5417+
display_name: recipient.to_string(),
5418+
openfang_user: None,
5419+
};
5420+
5421+
let reaction = openfang_channels::types::LifecycleReaction {
5422+
phase: openfang_channels::types::AgentPhase::Done,
5423+
emoji: emoji.to_string(),
5424+
remove_previous: true,
5425+
};
5426+
5427+
adapter
5428+
.send_reaction(&user, message_id, &reaction)
5429+
.await
5430+
.map_err(|e| format!("Reaction failed: {e}"))?;
5431+
5432+
Ok(format!("Reacted with {} on {} via {}", emoji, message_id, channel))
5433+
}
5434+
54005435
async fn spawn_agent_checked(
54015436
&self,
54025437
manifest_toml: &str,

crates/openfang-runtime/src/kernel_handle.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,20 @@ pub trait KernelHandle: Send + Sync {
209209
Err("Channel media send not available".to_string())
210210
}
211211

212+
/// Send an emoji reaction to a message on a channel.
213+
/// `channel` is the adapter name (e.g. "telegram"), `recipient` is the user/chat ID,
214+
/// `message_id` is the platform message ID, `emoji` is the emoji character.
215+
async fn send_channel_reaction(
216+
&self,
217+
channel: &str,
218+
recipient: &str,
219+
message_id: &str,
220+
emoji: &str,
221+
) -> Result<String, String> {
222+
let _ = (channel, recipient, message_id, emoji);
223+
Err("Channel reactions not available".to_string())
224+
}
225+
212226
/// Spawn an agent with capability inheritance enforcement.
213227
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
214228
/// that every capability in the child manifest is covered by `parent_caps`.

crates/openfang-runtime/src/tool_runner.rs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ pub async fn execute_tool(
306306

307307
// Channel send tool (proactive outbound messaging)
308308
"channel_send" => tool_channel_send(input, kernel).await,
309+
// Channel reaction tool (emoji reaction on incoming message)
310+
"message_react" => tool_message_react(input, kernel).await,
309311

310312
// Persistent process tools
311313
"process_start" => tool_process_start(input, process_manager, caller_agent_id).await,
@@ -1014,6 +1016,21 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
10141016
"required": ["channel", "recipient"]
10151017
}),
10161018
},
1019+
// --- Channel reaction tool ---
1020+
ToolDefinition {
1021+
name: "message_react".to_string(),
1022+
description: "Add an emoji reaction to the incoming message on the current channel (Telegram, Discord, etc). Use this to react to user messages with a contextually appropriate emoji. The channel, recipient, and message_id are provided in the [channel_context] header of the incoming message.".to_string(),
1023+
input_schema: serde_json::json!({
1024+
"type": "object",
1025+
"properties": {
1026+
"channel": { "type": "string", "description": "Channel name (e.g. 'telegram')" },
1027+
"recipient": { "type": "string", "description": "User/chat ID on the platform" },
1028+
"message_id": { "type": "string", "description": "Platform message ID to react to" },
1029+
"emoji": { "type": "string", "description": "Single emoji to react with (e.g. '❤️', '🎉', '👀'). For Telegram, must be from the supported set." }
1030+
},
1031+
"required": ["channel", "recipient", "message_id", "emoji"]
1032+
}),
1033+
},
10171034
// --- Hand tools (curated autonomous capability packages) ---
10181035
ToolDefinition {
10191036
name: "hand_list".to_string(),
@@ -2190,6 +2207,42 @@ async fn tool_channel_send(
21902207
.await
21912208
}
21922209

2210+
// ---------------------------------------------------------------------------
2211+
// Channel reaction tool
2212+
// ---------------------------------------------------------------------------
2213+
2214+
async fn tool_message_react(
2215+
input: &serde_json::Value,
2216+
kernel: Option<&Arc<dyn KernelHandle>>,
2217+
) -> Result<String, String> {
2218+
let kh = require_kernel(kernel)?;
2219+
2220+
let channel = input["channel"]
2221+
.as_str()
2222+
.ok_or("Missing 'channel' parameter")?
2223+
.trim()
2224+
.to_lowercase();
2225+
let recipient = input["recipient"]
2226+
.as_str()
2227+
.ok_or("Missing 'recipient' parameter")?
2228+
.trim();
2229+
let message_id = input["message_id"]
2230+
.as_str()
2231+
.ok_or("Missing 'message_id' parameter")?
2232+
.trim();
2233+
let emoji = input["emoji"]
2234+
.as_str()
2235+
.ok_or("Missing 'emoji' parameter")?
2236+
.trim();
2237+
2238+
if emoji.is_empty() {
2239+
return Err("Emoji cannot be empty".to_string());
2240+
}
2241+
2242+
kh.send_channel_reaction(&channel, recipient, message_id, emoji)
2243+
.await
2244+
}
2245+
21932246
// ---------------------------------------------------------------------------
21942247
// Hand tools (delegated to kernel via KernelHandle trait)
21952248
// ---------------------------------------------------------------------------
@@ -3121,8 +3174,8 @@ mod tests {
31213174
fn test_builtin_tool_definitions() {
31223175
let tools = builtin_tool_definitions();
31233176
assert!(
3124-
tools.len() >= 39,
3125-
"Expected at least 39 tools, got {}",
3177+
tools.len() >= 40,
3178+
"Expected at least 40 tools, got {}",
31263179
tools.len()
31273180
);
31283181
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
@@ -3168,8 +3221,9 @@ mod tests {
31683221
assert!(names.contains(&"cron_create"));
31693222
assert!(names.contains(&"cron_list"));
31703223
assert!(names.contains(&"cron_cancel"));
3171-
// 1 channel send tool
3224+
// channel tools
31723225
assert!(names.contains(&"channel_send"));
3226+
assert!(names.contains(&"message_react"));
31733227
// 4 hand tools
31743228
assert!(names.contains(&"hand_list"));
31753229
assert!(names.contains(&"hand_activate"));

0 commit comments

Comments
 (0)