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
8 changes: 8 additions & 0 deletions assistant/src/calls/call-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { RelayConnection } from './relay-server.js';
import { registerCallOrchestrator, unregisterCallOrchestrator, fireCallQuestionNotifier, fireCallCompletionNotifier, fireCallTranscriptNotifier } from './call-state.js';
import type { PromptSpeakerContext } from './speaker-identification.js';
import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
import * as conversationStore from '../memory/conversation-store.js';
import { dispatchGuardianQuestion } from './guardian-dispatch.js';
import type { ServerMessage } from '../daemon/ipc-contract.js';

Expand Down Expand Up @@ -452,6 +453,13 @@ export class CallOrchestrator {
if (spokenText.length > 0) {
const session = getCallSession(this.callSessionId);
if (session) {
// Persist assistant transcript to the voice conversation so it
// survives even when no live daemon Session is listening.
conversationStore.addMessage(
session.conversationId,
'assistant',
JSON.stringify([{ type: 'text', text: spokenText }]),
);
Comment on lines +458 to +462

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.

🔴 Duplicate assistant transcript persistence when a live daemon Session is listening on the voice conversation

Same duplication issue as the caller transcript path, but for assistant transcripts in the call orchestrator.

Root Cause

The new code at assistant/src/calls/call-orchestrator.ts:458-462 persists the assistant transcript directly:

conversationStore.addMessage(
  session.conversationId,
  'assistant',
  JSON.stringify([{ type: 'text', text: spokenText }]),
);

Then on line 463, fireCallTranscriptNotifier triggers the notifier in assistant/src/daemon/session-notifiers.ts:125-129 which persists a second formatted copy to the same conversation as 'assistant' role with "**Live call transcript**\nAssistant: ..." prefix. This results in two assistant messages per LLM turn in the voice conversation when a Session is listening.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

fireCallTranscriptNotifier(session.conversationId, this.callSessionId, 'assistant', spokenText);
Comment on lines +459 to 463

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid double-persisting voice transcript messages

This block now writes the assistant transcript directly to conversation_store and then still calls fireCallTranscriptNotifier; when a daemon session is attached, the notifier callback in assistant/src/daemon/session-notifiers.ts also calls conversationStore.addMessage for the same transcript event. The same pattern was added for caller transcripts in assistant/src/calls/relay-server.ts, so any open voice thread now stores each utterance twice, inflating history/memory indexing and polluting later model context with duplicates.

Useful? React with 👍 / 👎.

}
}
Expand Down
7 changes: 7 additions & 0 deletions assistant/src/calls/relay-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,13 @@ export class RelayConnection {

const session = getCallSession(this.callSessionId);
if (session) {
// Persist caller transcript to the voice conversation so it survives
// even when no live daemon Session is listening.
conversationStore.addMessage(
session.conversationId,
'user',
JSON.stringify([{ type: 'text', text: msg.voicePrompt }]),
);
Comment on lines +459 to +463

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.

🔴 Duplicate transcript persistence when a live daemon Session is listening on the voice conversation

Every caller and assistant transcript is persisted twice to the voice conversation when a daemon Session is actively listening. The new direct conversationStore.addMessage calls write the raw transcript, and then fireCallTranscriptNotifier triggers the notifier callback in session-notifiers.ts:125-129 which also calls conversationStore.addMessage on the same conversationId.

Root Cause and Impact

The new code at assistant/src/calls/relay-server.ts:459-462 persists caller transcripts:

conversationStore.addMessage(
  session.conversationId, // voice conversation
  'user',
  JSON.stringify([{ type: 'text', text: msg.voicePrompt }]),
);

Then fireCallTranscriptNotifier on line 464 fires the callback registered in assistant/src/daemon/session-notifiers.ts:119-129, which also persists to the same conversation:

conversationStore.addMessage(
  conversationId, // same voice conversation
  'assistant',
  JSON.stringify([{ type: 'text', text: transcriptText }]),
);

The same duplication occurs for assistant transcripts in call-orchestrator.ts:458-462 followed by fireCallTranscriptNotifier on line 463.

When no Session is listening the notifier doesn't fire, so only the new direct write happens (correct). But when a Session IS listening, both writes execute, producing two messages per transcript event. The messages also have inconsistent roles: the direct write uses 'user' for caller utterances while the notifier always writes as 'assistant' with a formatted prefix. This corrupts the voice conversation history with duplicate, inconsistently-roled messages.

Prompt for agents
The fix needs to coordinate between the new direct conversationStore.addMessage calls (in relay-server.ts:459-462 and call-orchestrator.ts:458-462) and the existing notifier callback in session-notifiers.ts:119-142 that also calls conversationStore.addMessage on the same conversation. Two approaches:

1. Remove the conversationStore.addMessage call from the notifier callback in assistant/src/daemon/session-notifiers.ts lines 125-129 (the registerCallTranscriptNotifier callback), since the new direct writes in relay-server.ts and call-orchestrator.ts now handle persistence unconditionally. The notifier would then only push to ctx.messages and send to the client. You'd also need to ensure the notifier's ctx.messages.push uses the same format/role as the direct write.

2. Alternatively, only call conversationStore.addMessage in relay-server.ts/call-orchestrator.ts when no notifier is registered (i.e., no live Session is listening). This could be done by checking if fireCallTranscriptNotifier returned without invoking a callback, but the current API doesn't support that.

Approach 1 is cleaner: keep the direct writes as the single source of truth for persistence, and let the notifier only handle in-memory state and client notifications.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

fireCallTranscriptNotifier(session.conversationId, this.callSessionId, 'caller', msg.voicePrompt);
}

Expand Down
2 changes: 1 addition & 1 deletion assistant/src/config/bundled-skills/phone-calls/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ An optional verification step where the callee must enter a numeric code via the
| Setting | Description | Default |
|---|---|---|
| `calls.verification.enabled` | Enable DTMF callee verification | `false` |
| `calls.verification.codeLength` | Number of digits in the verification code | `4` |
| `calls.verification.codeLength` | Number of digits in the verification code | `6` |

## Optional: Higher Quality Voice with ElevenLabs

Expand Down
32 changes: 21 additions & 11 deletions assistant/src/daemon/session-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,6 @@ export async function processMessage(
if (guardianDelivery) {
const guardianRequest = getGuardianActionRequest(guardianDelivery.requestId);
if (guardianRequest && guardianRequest.status === 'pending') {
const resolved = resolveGuardianActionRequest(guardianRequest.id, content, 'mac');
const userMsg = createUserMessage(content, attachments);
const persisted = conversationStore.addMessage(
session.conversationId,
Expand All @@ -245,25 +244,36 @@ export async function processMessage(
);
session.messages.push(userMsg);

if (resolved) {
void answerCall({ callSessionId: guardianRequest.callSessionId, answer: content });
const confirmMsg = createAssistantMessage('Your answer has been relayed to the call.');
// Attempt to deliver the answer to the call first. Only resolve
// the guardian action request if answerCall succeeds, so that a
// failed delivery leaves the request pending for retry from
// another channel.
const answerResult = await answerCall({ callSessionId: guardianRequest.callSessionId, answer: content });

if ('ok' in answerResult && answerResult.ok) {
const resolved = resolveGuardianActionRequest(guardianRequest.id, content, 'mac');
const replyText = resolved
? 'Your answer has been relayed to the call.'
: 'This question has already been answered from another channel.';
const replyMsg = createAssistantMessage(replyText);
conversationStore.addMessage(
session.conversationId,
'assistant',
JSON.stringify(confirmMsg.content),
JSON.stringify(replyMsg.content),
);
session.messages.push(confirmMsg);
onEvent({ type: 'assistant_text_delta', text: 'Your answer has been relayed to the call.' });
session.messages.push(replyMsg);
onEvent({ type: 'assistant_text_delta', text: replyText });
} else {
const staleMsg = createAssistantMessage('This question has already been answered from another channel.');
const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
log.warn({ callSessionId: guardianRequest.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
const failMsg = createAssistantMessage('Failed to deliver your answer to the call. Please try again.');
conversationStore.addMessage(
session.conversationId,
'assistant',
JSON.stringify(staleMsg.content),
JSON.stringify(failMsg.content),
);
session.messages.push(staleMsg);
onEvent({ type: 'assistant_text_delta', text: 'This question has already been answered from another channel.' });
session.messages.push(failMsg);
onEvent({ type: 'assistant_text_delta', text: 'Failed to deliver your answer to the call. Please try again.' });
}
onEvent({ type: 'message_complete', sessionId: session.conversationId });
return persisted.id;
Expand Down
Loading