fix(agent): reject send tool when targeting the same conversation

Pass replyTarget through the full pipeline (ChatRequest → gateway
identity → agent headers → MCP session) so the send tool can detect
when the destination matches the current conversation and return an
error guiding the agent to reply directly instead.
This commit is contained in:
Acbox
2026-03-11 16:59:42 +08:00
parent a2e5c4f893
commit 30653fbdbf
8 changed files with 40 additions and 2 deletions
+1
View File
@@ -31,6 +31,7 @@ export const IdentityContextModel = z.object({
channelIdentityId: z.string().min(1, 'Channel identity ID is required'),
displayName: z.string().min(1, 'Display name is required'),
currentPlatform: z.string().optional(),
replyTarget: z.string().optional(),
conversationType: z.string().optional(),
sessionToken: z.string().optional(),
})
+1
View File
@@ -382,6 +382,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel
RouteID: resolved.RouteID,
ChatToken: chatToken,
ExternalMessageID: sourceMessageID,
ReplyTarget: target,
ConversationType: msg.Conversation.Type,
ConversationName: msg.Conversation.Name,
Query: text,
+2
View File
@@ -161,6 +161,7 @@ type gatewayIdentity struct {
ChannelIdentityID string `json:"channelIdentityId"`
DisplayName string `json:"displayName"`
CurrentPlatform string `json:"currentPlatform,omitempty"`
ReplyTarget string `json:"replyTarget,omitempty"`
ConversationType string `json:"conversationType,omitempty"`
SessionToken string `json:"sessionToken,omitempty"` //nolint:gosec // intentional: session token forwarded to agent gateway for channel reply routing
}
@@ -470,6 +471,7 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r
ChannelIdentityID: strings.TrimSpace(req.SourceChannelIdentityID),
DisplayName: displayName,
CurrentPlatform: req.CurrentChannel,
ReplyTarget: strings.TrimSpace(req.ReplyTarget),
ConversationType: strings.TrimSpace(req.ConversationType),
SessionToken: req.ChatToken,
},
+1
View File
@@ -225,6 +225,7 @@ type ChatRequest struct {
RouteID string `json:"-"`
ChatToken string `json:"-"`
ExternalMessageID string `json:"-"`
ReplyTarget string `json:"-"`
ConversationType string `json:"-"`
ConversationName string `json:"-"`
UserMessagePersisted bool `json:"-"`
@@ -227,6 +227,16 @@ func (p *Executor) callSend(ctx context.Context, session mcpgw.ToolSessionContex
return mcpgw.BuildToolErrorResult("target is required"), nil
}
// Reject send when the destination matches the current conversation.
if strings.EqualFold(channelType.String(), strings.TrimSpace(session.CurrentPlatform)) &&
target == strings.TrimSpace(session.ReplyTarget) {
return mcpgw.BuildToolErrorResult(
"You are trying to send a message to the SAME conversation you are already in. " +
"Do NOT use the send tool for this. Instead, write your reply as plain text directly. " +
"To include files, use the <attachments> block in your response (e.g. <attachments>[{\"type\":\"image\",\"path\":\"/data/media/file.jpg\"}]</attachments>).",
), nil
}
sendReq := channel.SendRequest{
Target: target,
Message: outboundMessage,
@@ -252,7 +252,7 @@ func TestExecutor_CallTool_SendError(t *testing.T) {
}
}
func TestExecutor_CallTool_Success(t *testing.T) {
func TestExecutor_CallTool_SameRouteRejected(t *testing.T) {
sender := &fakeSender{}
resolver := &fakeResolver{ct: channel.ChannelType("feishu")}
exec := NewExecutor(nil, sender, nil, resolver, nil)
@@ -263,6 +263,23 @@ func TestExecutor_CallTool_Success(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if isErr, _ := result["isError"].(bool); !isErr {
t.Error("expected error when sending to the same route as current session")
}
}
func TestExecutor_CallTool_Success(t *testing.T) {
sender := &fakeSender{}
resolver := &fakeResolver{ct: channel.ChannelType("feishu")}
exec := NewExecutor(nil, sender, nil, resolver, nil)
session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "feishu", ReplyTarget: "chat1"}
result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{
"target": "chat2",
"text": "hello",
})
if err != nil {
t.Fatal(err)
}
if err := mcpgw.PayloadError(result); err != nil {
t.Fatal(err)
}
@@ -284,6 +301,7 @@ func TestExecutor_CallTool_ReplyTo(t *testing.T) {
exec := NewExecutor(nil, sender, nil, resolver, nil)
session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"}
result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{
"target": "456",
"text": "reply text",
"reply_to": "msg-789",
})
@@ -307,7 +325,8 @@ func TestExecutor_CallTool_NoReplyTo(t *testing.T) {
exec := NewExecutor(nil, sender, nil, resolver, nil)
session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"}
result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{
"text": "no reply",
"target": "456",
"text": "no reply",
})
if err != nil {
t.Fatal(err)
+1
View File
@@ -9,6 +9,7 @@ export interface IdentityContext {
channelIdentityId: string
displayName: string
currentPlatform?: string
replyTarget?: string
conversationType?: string
sessionToken?: string
}
+3
View File
@@ -17,6 +17,9 @@ export const buildIdentityHeaders = (identity: IdentityContext, auth: AgentAuthC
if (identity.currentPlatform) {
headers['X-Memoh-Current-Platform'] = identity.currentPlatform
}
if (identity.replyTarget) {
headers['X-Memoh-Reply-Target'] = identity.replyTarget
}
if (options?.isSubagent) {
headers['X-Memoh-Is-Subagent'] = 'true'
}