[LLMコンテキストでDMメッセージに送信者属性がない] - DM Messages Lack Sender Attribution in LLM Context (BodyForAgent)
ダイレクトメッセージの会話は、送信者識別情報が受信パイプラインを通じて伝播されておらず、BodyForAgentに送信者プレフィックスがないため、LLMにとっては区別のないテキストストリームとして表示されます。
🔍 症状
主な症状
エージェントがDMの会話を処理する際、LLMは送信者コンテキストなしでメッセージを受信します。以下のDM交換を考えてみましょう:
LLMが実際に受信するもの(現在の動作):
Hey, are you free tonight?
Yes, I'll be there at 8
Great, see you then!
Looking forward to it!LLMが受信すべきもの(期待される動作):
[Alice]: Hey, are you free tonight?
[Agent]: Yes, I'll be there at 8
[Alice]: Great, see you then!
[Agent]: Looking forward to it!技術的観察
fromMeフラグはプロトコルアダプターレベルで存在しますが、LLMでは利用できません:
// アダプターレベル(WhatsAppの例):
msg.key.fromMe // Boolean - 送信者を正しく識別
// LLM入力レベル(BodyForAgent):
params.msg.body // "Hey, are you free tonight?" — 送信者コンテキストなし診断コマンド
inboundMessageの現在の状態を検査するには:
# 受信メッセージ構造を観察するためにデバッグログを有効にする
DEBUG=openclaw:inbound node agent.js
# 欠落しているフィールドを示す予想デバッグ出力:
# inboundMessage {
# body: "Hey, are you free tonight?",
# from: "+1234567890",
# pushName: "Alice",
# chatType: "direct",
# // fromMe: undefined ← 欠落
# // senderName: undefined ← 伝播されていない
# }バージョン固有の動作
この問題は、フレームワークがLLMにBodyを渡すことからBodyForAgentを渡すように変更されたため、v2026.3.1以降で具体的に発生します。formatInboundEnvelopeへの変更はBodyのみに影響し、LLMには見えません。
🧠 原因
アーキテクチャ上のギャップ:欠落している伝播パス
根本原因是、プロトコルアダプターとLLMコンテキスト間のデータフローの途絶です。fromMeフラグとsenderNameはパイプラインの早い段階で利用できますが、BodyForAgentへの整个チェーンを通じて伝播されません。
データフロー分析
┌─────────────────────────────────────────────────────────────────────────┐ │ 現在のデータフロー │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ プロトコルアダプター │ │ ├── msg.key.fromMe = true/false ✓ 利用可能 │ │ ├── msg.pushName = “Alice” ✓ 利用可能 │ │ └── msg.chatType = “direct” ✓ 利用可能 │ │ │ │ │ ▼ │ │ inboundMessage コンストラクタ │ │ └── fromMe フィールドが追加されていない ✗ ここでドロップ │ │ │ │ │ ▼ │ │ processMessage → buildInboundLine → formatInboundEnvelope │ │ └── fromMe は依然として undefined ✗ 転送されていない │ │ │ │ │ ▼ │ │ finalizeInboundContext 呼び出し元 │ │ └── BodyForAgent に [senderName]: プレフィックスがない ✗ LLMは曖昧なデータを受信 │ │ └─────────────────────────────────────────────────────────────────────────┘
コードレベルの分析
1. inboundMessage 構築(ドロップポイント #1)
javascript // 現在の実装 - fromMe フィールドが欠落 function inboundMessage(msg, chatId) { return { body: msg.body || msg.text || “”, from: msg.from || msg.chat?.id, pushName: msg.pushName || msg.sender?.first_name, chatType: msg.chatType || (msg.chat?.isGroup ? “group” : “direct”), timestamp: msg.timestamp || Date.now(), // 欠落: fromMe: Boolean(msg.key?.fromMe) // 欠落: senderName: extractSenderName(msg) }; }
2. formatInboundEnvelope(LLMには影響しない)
javascript
// この関数は Body を修正するが、LLMが受信するのは Body ではない
function formatInboundEnvelope(params) {
const selfMarker = params.fromMe ? “[You]: " : “”;
return {
Body: ${selfMarker}${params.body},
// BodyForAgent はここで設定されない — LLMは生の値を受け取る
};
}
3. finalizeInboundContext 呼び出し元(ドロップポイント #2)
BodyForAgentを構築する最終変換には、送信者プレフィックスロジックが含まれていません:
javascript // 現在の実装 const BodyForAgent = params.msg.body; // 生テキスト、帰属情報なし
グループメッセージが正しく動作する理由
グループメッセージは、メッセージフォーマットに既に送信者名が含まれているため、帰属情報を自然に含んでいます:
javascript // グループメッセージはプロトコルから既にこの構造を持っている: “[GroupName] @Alice: message content” // または “[Alice]: message content”
DMメッセージにはこの構造的なプレフィックスがないため、明示的な処理なしでは送信者識別ができません。
バージョン回帰分析
| バージョン | LLM入力 | 動作 |
|---|---|---|
| < v2026.3.1 | Body | formatInboundEnvelope で修正可能だった |
| ≥ v2026.3.1 | BodyForAgent | formatInboundEnvelope で修正不可 |
v2026.3.1のリファクタリングにより、formatInboundEnvelope変換をバイパスする直接的なBodyForAgentフィールドが導入され、このギャップが発生しました。
🛠️ 解決手順
この修正には、受信パイプライン全体の5つの関数への変更が必要です。データ整合性を維持するため、記載された順序で変更を適用してください。
フェーズ1:パイプライン全体のfromMe伝播
Step 1.1: inboundMessage構築にfromMeを追加
ファイル: src/core/message/inbound-message.js
変更前: javascript function inboundMessage(msg, chatId) { return { body: msg.body || msg.text || “”, from: msg.from || msg.chat?.id, pushName: msg.pushName || msg.sender?.first_name, chatType: msg.chatType || (msg.chat?.isGroup ? “group” : “direct”), timestamp: msg.timestamp || Date.now(), // 欠落 }; }
変更後: javascript function inboundMessage(msg, chatId) { return { body: msg.body || msg.text || “”, from: msg.from || msg.chat?.id, pushName: msg.pushName || msg.sender?.first_name, chatType: msg.chatType || (msg.chat?.isGroup ? “group” : “direct”), timestamp: msg.timestamp || Date.now(), fromMe: Boolean(msg.key?.fromMe), // 追加: fromMe フラグを伝播 senderName: msg.pushName || msg.sender?.first_name || msg.sender?.username || “Unknown”, // 追加: 送信者名抽出 }; }
Step 1.2: processMessageでfromMeを渡す
ファイル: src/core/message/process-message.js
変更前: javascript async function processMessage(msg, chatId, context) { const inbound = inboundMessage(msg, chatId);
// … 他の処理 …
await buildInboundLine(inbound, context); }
変更後: javascript async function processMessage(msg, chatId, context) { const inbound = inboundMessage(msg, chatId);
// … 他の処理 …
await buildInboundLine(inbound, context, { fromMe: inbound.fromMe }); }
Step 1.3: buildInboundLineでfromMeをデструкクトして転送
ファイル: src/core/message/build-inbound-line.js
変更前: javascript async function buildInboundLine(inbound, context) { const { body, from, pushName, chatType, timestamp } = inbound;
// … 処理 …
await formatInboundEnvelope({ msg: { body, from, pushName, chatType, timestamp }, conversation: context.conversation, }); }
変更後: javascript async function buildInboundLine(inbound, context, options = {}) { const { body, from, pushName, chatType, timestamp, fromMe, senderName } = inbound;
// … 処理 …
await formatInboundEnvelope({ msg: { body, from, pushName, chatType, timestamp, fromMe, senderName }, conversation: context.conversation, fromMe: options.fromMe ?? fromMe, }); }
Step 1.4: formatInboundEnvelopeにセルフマーカーを追加(Body用)
ファイル: src/core/message/format-inbound-envelope.js
変更前: javascript function formatInboundEnvelope(params) { return { Body: params.msg.body, // … 他のフィールド }; }
変更後: javascript function formatInboundEnvelope(params) { const selfMarker = params.fromMe ? “[You]: " : “”;
return {
Body: ${selfMarker}${params.msg.body},
// … 他のフィールド
};
}
フェーズ2:BodyForAgentに送信者プレフィックスを追加
Step 2.1: finalizeInboundContext呼び出し元を修正
ファイル: src/core/context/finalize-inbound-context.js
変更前: javascript function finalizeInboundContext(params) { // … 他の処理 …
const BodyForAgent = params.msg.body;
return { // … 他のフィールド BodyForAgent, }; }
変更後: javascript function finalizeInboundContext(params) { // … 他の処理 …
// DMのみに送信者プレフィックスを追加;グループメッセージは既に帰属情報を持つ
const dmPrefix = params.msg.chatType !== “group”
? [${params.msg.senderName || params.msg.from || "Unknown"}]: : “”;
const BodyForAgent = ${dmPrefix}${params.msg.body};
return { // … 他のフィールド BodyForAgent, }; }
フェーズ3:検証チェックリスト
すべての変更を適用した後、以下の修正を確認してください:
| ファイル | 変更 | 検証項目 |
|---|---|---|
inbound-message.js | fromMeとsenderNameフィールドを追加 | inbound.fromMeがbooleanであることを確認 |
process-message.js | fromMeをbuildInboundLineに渡す | 第3引数が存在することを確認 |
build-inbound-line.js | fromMe、senderNameをデストラクトして転送 | パラメータが正しく渡されていることを確認 |
format-inbound-envelope.js | Bodyに[You]:セルフマーカーを追加 | ログでBody形式を確認 |
finalize-inbound-context.js | DMに[Name]:プレフィックスを追加 | BodyForAgent形式を確認 |
🧪 検証
検証方法1:ユニットテスト検証
伝播チェーンを検証するために以下のテストを作成して実行します:
// test/inbound-propagation.test.js
const { inboundMessage } = require('../src/core/message/inbound-message');
const { processMessage } = require('../src/core/message/process-message');
const { buildInboundLine } = require('../src/core/message/build-inbound-line');
const { formatInboundEnvelope } = require('../src/core/message/format-inbound-envelope');
const { finalizeInboundContext } = require('../src/core/context/finalize-inbound-context');
describe('fromMe propagation and sender identification', () => {
const mockMsg = {
body: "Test message",
from: "+1234567890",
pushName: "Alice",
chatType: "direct",
timestamp: Date.now(),
key: { fromMe: false },
};
const mockMsgFromMe = {
...mockMsg,
key: { fromMe: true },
};
test('inboundMessage includes fromMe and senderName', () => {
const result = inboundMessage(mockMsg, 'chat123');
expect(result.fromMe).toBe(false);
expect(result.senderName).toBe('Alice');
});
test('inboundMessage.fromMe is true when key.fromMe is true', () => {
const result = inboundMessage(mockMsgFromMe, 'chat123');
expect(result.fromMe).toBe(true);
});
test('BodyForAgent includes [senderName]: prefix for DMs', () => {
const context = { conversation: [] };
const result = finalizeInboundContext({
msg: { body: "Test", chatType: "direct", senderName: "Alice", from: "+1234567890" },
conversation: context.conversation,
});
expect(result.BodyForAgent).toBe('[Alice]: Test');
});
test('BodyForAgent has no prefix for group messages', () => {
const context = { conversation: [] };
const result = finalizeInboundContext({
msg: { body: "Test", chatType: "group", senderName: "Alice", from: "+1234567890" },
conversation: context.conversation,
});
expect(result.BodyForAgent).toBe('Test');
});
test('Body includes [You]: prefix when fromMe is true', () => {
const result = formatInboundEnvelope({
msg: { body: "My message", fromMe: true },
});
expect(result.Body).toBe('[You]: My message');
});
});
テストを実行:
npm test -- test/inbound-propagation.test.js予想される出力:
✓ inboundMessage includes fromMe and senderName
✓ inboundMessage.fromMe is true when key.fromMe is true
✓ BodyForAgent includes [senderName]: prefix for DMs
✓ BodyForAgent has no prefix for group messages
✓ Body includes [You]: prefix when fromMe is true検証方法2:モックプロトコルアダプターを使った統合テスト
javascript // test/dm-sender-attribution.test.js const { runFullPipeline } = require(’../src/core/test-helpers’);
async function testDMSenderAttribution() { const mockAdapter = { name: ‘mock’, sendMessage: jest.fn(), onMessage: (handler) => { // Aliceからの受信DMをシミュレート handler({ key: { fromMe: false }, body: “Hey, are you free tonight?”, from: “+1111111111”, pushName: “Alice”, chatType: “direct”, timestamp: Date.now(), });
// 送信DMをシミュレート(fromMe = true)
handler({
key: { fromMe: true },
body: "Yes, I'll be there at 8",
from: "+2222222222",
pushName: "Agent",
chatType: "direct",
timestamp: Date.now(),
});
},
};
const agent = createAgent({ adapter: mockAdapter }); await agent.start();
// LLM入力をキャプチャ const llmInput = captureLLMInput();
console.log(‘LLMが受信したBodyForAgent:’); console.log(llmInput.BodyForAgent);
// 予想される出力: // [Alice]: Hey, are you free tonight? // [Agent]: Yes, I’ll be there at 8
await agent.stop(); }
testDMSenderAttribution();
検証方法3:手動デバッグログ
フルパイプラインを検査するために詳細ログを有効にします:
# 環境変数を設定
export DEBUG=openclaw:inbound,openclaw:context
export LOG_LEVEL=debug
# エージェントを実行
node agent.js 2>&1 | grep -E "(fromMe|senderName|BodyForAgent|\[.*\]:)"
# DMメッセージの予想ログ出力:
# [debug] inboundMessage.fromMe: false
# [debug] inboundMessage.senderName: "Alice"
# [debug] BodyForAgent: "[Alice]: Hey, are you free tonight?"検証方法4:データベース状態検査
永続コンテキストを使用している場合、保存されたメッセージを確認します:
# messagesテーブルをクエリ
SELECT id, sender_name, from_me, body, body_for_agent
FROM messages
WHERE chat_type = 'direct'
ORDER BY timestamp DESC LIMIT 5;
-- 予想される結果:
-- | id | sender_name | from_me | body | body_for_agent |
-- | 1 | Alice | false | Hey, free? | [Alice]: Hey, free? |
-- | 2 | Agent | true | Yes, at 8 | [Agent]: Yes, at 8 |⚠️ よくある落とし穴
落とし穴1:プロトコルアダプターでのフィールド名の差異
問題: 異なるメッセージングプラットフォームでfromMeが様々なフィールド名で公開されています。
- WhatsApp:
msg.key.fromMe - Telegram:
msg.from.is_bot(ロジック反転)またはmsg.outgoing - Signal:
msg.direction === "outgoing" - Discord:
msg.author.id === msg.client.user.id
対策: inboundMessageコンストラクタにアダプター固有のフィールドマッパーを作成します:
javascript function extractFromMe(msg, platform) { switch (platform) { case ‘whatsapp’: return Boolean(msg.key?.fromMe); case ’telegram’: return Boolean(msg.outgoing); case ‘signal’: return msg.direction === ‘outgoing’; case ‘discord’: return msg.author?.id === msg.client?.user?.id; default: return false; } }
落とし穴2:欠落しているsenderNameフォールバックチェーン
問題: pushNameが利用できない場合があります(プライバシー設定が有効なユーザー、またはプッシュ名がキャッシュされる前の最初のメッセージ)。
**対策:**堅牢なフォールバックチェーンを実装します:
javascript function extractSenderName(msg) { return ( msg.pushName || msg.sender?.first_name || msg.sender?.username || msg.from?.split(’@’)[0] || // 最後の手段としてJID/電話番号を使用 “Unknown” ); }
落とし穴3:グループメッセージでの二重プレフィックス
問題: グループメッセージがプロトコルペイロードに既に送信者帰属情報を含んでいる場合、別のプレフィックスを追加すると重複が発生します。
悪い出力の例:
[#general] @Alice: [Alice]: Message content // 二重帰属
対策: プレフィックスを追加する前に既存のメッセージ形式を確認します:
javascript function shouldAddPrefix(msg, chatType) { if (chatType !== ‘direct’) { // グループメッセージが既に帰属パターンを持っているか確認 const existingPattern = /^[[^]]+]\s*@\w+:/; return !existingPattern.test(msg.body); } return true; }
落とし穴4:プラットフォーム固有の送信者名形式
問題: 異なるプラットフォームで送信者名の形式が異なります。
- WhatsApp: 表示名(例:"John Smith")
- Telegram: 名 + オプションの姓
- Discord: ユーザー名 + 識別子(例:"User#1234")
対策: プレフィックスで使用する前に送信者名を正規化します:
javascript function normalizeSenderName(name, platform) { if (!name) return “Unknown”;
let normalized = name.trim();
if (platform === ‘discord’) { // 識別子が 있으면削除 normalized = normalized.split(’#’)[0]; }
// 解析を壊す可能性のある特殊文字を削除 return normalized.replace(/[[]]/g, ‘’).substring(0, 50); }
落とし穴5:v2026.3.1以降のバージョン互換性
問題: BodyForAgentかBodyかの使用に基づいて条件付きロジックがある場合、修正が均一に適用されない可能性があります。
対策: LLMアダプターが実際に消費するフィールドを確認します:
javascript // LLMアダプター設定を確認 const llmAdapter = config.llm?.adapter;
// Body を直接使用(古い動作)の場合、formatInboundEnvelope の修正が適用される // BodyForAgent を使用(新しい動作)の場合、finalizeInboundContext の修正が適用される // 一部のアダプターは両方を使用する場合がある — 一貫性を確保
落とし穴6:パラレルメッセージ処理での競合状態
問題: 複数のDMを同時に処理する際、fromMe状態が古くなっているか、異なるメッセージコンテキストに誤って関連付けられている可能性があります。
対策: fromMeが構築時にグローバルに取得されるのではなく、特定のメッセージコンテキストにバインドされていることを確認します:
javascript // 誤り:グローバル状態参照 inboundMessage.fromMe = globalLastMessageFromMe; // 競合状態
// 正しい:メッセージ固有の抽出 inboundMessage.fromMe = Boolean(msg.key?.fromMe); // メッセージごとに分離
🔗 関連するエラー
関連問題 #32060: 送信DMをコンテキストに含める
症状: エージェントは受信DMのみを受け取り、エージェント自体が送信した送信メッセージを受け取らないため、会話を一方的に見えます。
関連性: この問題は現在の修正の補完です。#32060は送信メッセージをコンテキストに保存することを保証しますが、この修正は受信と送信の両方のメッセージが適切な送信者帰属情報を持つことを保証します。
解決依存性: この修正で実装されたfromMe伝播は、#32060の適切な実装に必要です。
関連問題 #32059: DMコンテキストウィンドウオーバーフロー
症状: 長いDM会話は、各メッセージが必要な帰属識別子を失うため、過度のコンテキストウィンドウトークンを消費します。
関連性: この修正で導入された[Name]:プレフィックス形式は、必要な帰属情報を提供しながらトークンオーバーヘッドを最小限に抑えるように意図的に簡潔です。
関連エラー: BodyForAgentでsenderNameがundefined
エラーパターン:
TypeError: Cannot read property 'senderName' of undefined
at finalizeInboundContext (finalize-inbound-context.js:42)
at processMessage (process-message.js:87)原因: inboundMessage伝播が完了する前にsenderNameにアクセスされています。
解決: finalizeInboundContextが呼び出される前にinboundMessage構築にsenderNameフィールドが含まれていることを確認します。
関連する警告: fromMeはbooleanではありません
警告パターン:
Warning: BodyForAgent received non-boolean fromMe value: "true"
Warning: Self-marker logic may behave unexpectedly原因: fromMeがbooleanではなく文字列(“true”/“false”)として保存されていました。
解決: inboundMessageコンストラクタでBoolean(msg.key?.fromMe)の強制変換を確保します。
関連設定: dm.senderPrefix.enabled
設定キー: openclaws.dm.senderPrefix.enabled
目的: テストまたはユーザー設定のためにDMでの送信者プレフィックスを切り替え。
デフォルト: true
相互作用: 無効にすると、DMは曖昧な形式に戻ります;fromMe伝播は他の目的(分析、フィルタリング)では引き続き機能します。
関連ログカテゴリ: openclaw:inbound:fromme
デバッグフラグ: DEBUG=openclaw:inbound:fromme
出力: 各パイプラインテンポでのfromMe値をログ出力し、伝播失敗のトレースに有用。
歴史的メモ:v2026.3.1以前の動作
コンテキスト: v2026.3.1以前、フレームワークはLLM入力としてBody(formatInboundEnvelopeで修正)を使用していました。セルフマーカー修正がそこで適用されていれば、LLMに見えていたでしょう。
変更: v2026.3.1はBodyForAgentを別フィールドとして導入し、エンベロープ変換をバイパスしました。
影響: このアーキテクチャ変更により、この修正が対処する伝播ギャップが発生しました。