Skip to content

feat(twilio): per-agent relay settings, the cut line said again, a si… - #1489

Merged
yileicn merged 3 commits into
SciSharp:masterfrom
yuyixg:features/relay-resume-silence
Nov 10, 2026
Merged

yileicn merged 3 commits into
SciSharp:masterfrom
yuyixg:features/relay-resume-silence

Conversation

@yuyixg

@yuyixg yuyixg commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

…lence chain that asks the question again, tails dropped

  • ConversationRelaySetting.Agents: an agent's own transcription provider, speech model, interrupt sensitivity and the settings below, over the shared ones (For(agentId)); the TwiML and the session use them, so one agent can move to Deepgram Flux and a low interrupt sensitivity without touching the others
  • ResumeAfterCutInSeconds: a cut-in nothing follows (a noise, the line's own voice) gets the line a tool gave said again, once; cut into again it is not said a third time
  • IdleTimeouts and IdleAsksAgain: the first idle prompt asks the last line's question again ("Sorry, I didn't catch that. ..."), the next asks if the caller is still there, and the hang-up says goodbye (PromptTexts say-it-again-question, still-there, idle-goodbye)
  • DropBackchannelTails: an acknowledgement that comes in before the caller could hear the question just asked is the tail of an utterance the transcription split, and is dropped, unless they heard a few words of the line first
  • A transcript with no letters (".") never starts a turn
  • A line queued behind something the caller cut into (a filler) is told to the next turn as not heard at all

Off by default; each comes from a finding on real calls in the RSP call lab.

…lence chain that asks the question again, tails dropped

- ConversationRelaySetting.Agents: an agent's own transcription provider, speech model, interrupt sensitivity and the
  settings below, over the shared ones (For(agentId)); the TwiML and the session use them, so one agent can move to
  Deepgram Flux and a low interrupt sensitivity without touching the others
- ResumeAfterCutInSeconds: a cut-in nothing follows (a noise, the line's own voice) gets the line a tool gave said again,
  once; cut into again it is not said a third time
- IdleTimeouts and IdleAsksAgain: the first idle prompt asks the last line's question again ("Sorry, I didn't catch
  that. ..."), the next asks if the caller is still there, and the hang-up says goodbye (PromptTexts
  say-it-again-question, still-there, idle-goodbye)
- DropBackchannelTails: an acknowledgement that comes in before the caller could hear the question just asked is the
  tail of an utterance the transcription split, and is dropped, unless they heard a few words of the line first
- A transcript with no letters (".") never starts a turn
- A line queued behind something the caller cut into (a filler) is told to the next turn as not heard at all

Off by default; each comes from a finding on real calls in the RSP call lab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@qodo-code-review

Copy link
Copy Markdown
Contributor

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@yileicn

yileicn commented Nov 10, 2026

Copy link
Copy Markdown
Member

✅ AegisCode Code Review Report: Pass

Counted: 1 Medium, 17 Low. None of them blocks this change.
Full report · review 11277 · commit c28a4c52

Medium · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:415 · logic: The resume marker is set only after SpeakAsync completes, separately from the state updates in its…

The resume marker is set only after SpeakAsync completes, separately from the state updates in its canSpeak callback. An interrupt can arrive after the resumed line is sent but before this assignment. NoteLine has already reset _lastWasResume to false, so NoteCutIn can schedule that same line again. Setting the marker afterward does not clear the scheduled _resumeLine, and ResumeIfSilentAsync does not recheck the marker, allowing a third playback.

Suggested fix: [Not confirmed by a second review; reviewer rated High, needs a human decision] [Needs Review - Inconclusive] Set _lastWasResume = true immediately after NoteLine(line) inside the canSpeak callback, under the same synchronization used for the other resume-state updates, and remove the later assignment.

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelayLines.cs:11 · Constant naming: Constant 'ShortQuestionWords' (line 11) is not UPPER_CASE

Constant 'ShortQuestionWords' (line 11) is not UPPER_CASE

Suggested fix: [Validated by static analysis] Rename 'ShortQuestionWords' to 'SHORT_QUESTION_WORDS'

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelayLines.cs:14 · logic: Every period is treated as a sentence boundary, including periods inside decimal values.

Every period is treated as a sentence boundary, including periods inside decimal values. For example, LastQuestion("The total is $12.50. Is that correct?") returns "50. Is that correct?" because the short-question branch retains only the fragment immediately preceding the question. The helper works for simple sentences, but this case loses the information the caller is being asked to confirm.

Suggested fix: [Needs Review - Inconclusive] Use sentence-boundary detection that preserves internal punctuation, at least periods between digits, before selecting the preceding context sentence.

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelayLines.cs:16 · Line length: This line is 139 characters long; the maximum is 125

This line is 139 characters long; the maximum is 125

Suggested fix: [Validated by static analysis] Wrap the line so that no line exceeds 125 characters

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelayLines.cs:17 · style: The private static readonly fields Sentence and Acknowledgement use PascalCase instead of the…

The private static readonly fields Sentence and Acknowledgement use PascalCase instead of the required _camelCase private-field naming. The current names are readable and functional, but aligning them with the C# standard makes fields easier to distinguish from types and properties.

Suggested fix: [Needs Review - Inconclusive] Rename Sentence to _sentence and Acknowledgement to _acknowledgement, updating their references.

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelayLines.cs:32 · business-logic: A question ending in mixed punctuation is treated as containing no question.

A question ending in mixed punctuation is treated as containing no question. For example, LastQuestion("Are you still there?!") returns null: the sentence regex consumes both punctuation characters, but EndsWith('?') rejects the result. This contradicts the intended behavior that "the first idle prompt asks the last line's question again" for this input.

Suggested fix: [Validated by static analysis] Recognize a question mark anywhere in the terminal punctuation sequence, rather than requiring it to be the final character.

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:20 · Constant naming: Constant 'DefaultAskAgainText' (line 20) is not UPPER_CASE

Constant 'DefaultAskAgainText' (line 20) is not UPPER_CASE

Suggested fix: [Validated by static analysis] Rename 'DefaultAskAgainText' to 'DEFAULT_ASK_AGAIN_TEXT'

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:21 · Constant naming: Constant 'DefaultStillThereText' (line 21) is not UPPER_CASE

Constant 'DefaultStillThereText' (line 21) is not UPPER_CASE

Suggested fix: [Validated by static analysis] Rename 'DefaultStillThereText' to 'DEFAULT_STILL_THERE_TEXT'

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:22 · Constant naming: Constant 'DefaultIdleGoodbyeText' (line 22) is not UPPER_CASE

Constant 'DefaultIdleGoodbyeText' (line 22) is not UPPER_CASE

Suggested fix: [Validated by static analysis] Rename 'DefaultIdleGoodbyeText' to 'DEFAULT_IDLE_GOODBYE_TEXT'

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:78 · business-logic: A numeric-only transcript such as "123" passes IsLetterOrDigit and is enqueued.

A numeric-only transcript such as "123" passes IsLetterOrDigit and is enqueued. This contradicts the stated requirement: "A transcript with no letters (\".\") never starts a turn." Punctuation-only transcripts are filtered correctly, but the implementation does not enforce the broader no-letters condition.

Suggested fix: [Needs Review - Inconclusive] Use prompt.Any(char.IsLetter) to implement the stated transcript rule. Keep keypad handling separate.

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:88 · business-logic: The interrupt's heard text is counted without checking which utterance it belongs to.

The interrupt's heard text is counted without checking which utterance it belongs to. If Twilio reports three or more words of a filler while the question is still queued behind it, this sets _lastLineHeardSome to true. IsBackchannelTail then unconditionally accepts the next acknowledgement even though none of the question was heard. This contradicts the intent: acknowledgements are dropped "unless they heard a few words of the line first." The new PlayingBeforeAfterTurnLine logic explicitly recognizes this queued-line scenario, but this assignment ignores it.

Suggested fix: [Needs Review - Inconclusive] Only mark the last line as partly heard after establishing that the interrupted utterance belongs to that line. Keep the flag false when the interrupt identifies an earlier filler or other queued predecessor.

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:123 · Constant naming: Constant 'UtterancesKept' (line 123) is not UPPER_CASE

Constant 'UtterancesKept' (line 123) is not UPPER_CASE

Suggested fix: [Validated by static analysis] Rename 'UtterancesKept' to 'UTTERANCES_KEPT'

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:358 · Line length: This line is 133 characters long; the maximum is 125

This line is 133 characters long; the maximum is 125

Suggested fix: [Validated by static analysis] Wrap the line so that no line exceeds 125 characters

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:404 · Constant naming: Constant 'HeardSomeWords' (line 404) is not UPPER_CASE

Constant 'HeardSomeWords' (line 404) is not UPPER_CASE

Suggested fix: [Validated by static analysis] Rename 'HeardSomeWords' to 'HEARD_SOME_WORDS'

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:811 · Line length: This line is 129 characters long; the maximum is 125

This line is 129 characters long; the maximum is 125

Suggested fix: [Validated by static analysis] Wrap the line so that no line exceeds 125 characters

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:827 · Line length: This line is 137 characters long; the maximum is 125

This line is 137 characters long; the maximum is 125

Suggested fix: [Validated by static analysis] Wrap the line so that no line exceeds 125 characters

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:838 · Line length: This line is 134 characters long; the maximum is 125

This line is 134 characters long; the maximum is 125

Suggested fix: [Validated by static analysis] Wrap the line so that no line exceeds 125 characters

Low · .../BotSharp.Plugin.Twilio/Services/ConversationRelaySession.cs:916 · Line length: This line is 132 characters long; the maximum is 125

This line is 132 characters long; the maximum is 125

Suggested fix: [Validated by static analysis] Wrap the line so that no line exceeds 125 characters

@yileicn
yileicn requested a balanced review from Copilot November 10, 2026 06:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@yileicn

yileicn commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Code Review:BotSharp PR #1489

总体评价:一般,合并前需要修。 五个新开关默认都关,不打开就不影响现有通话。但打开 ResumeAfterCutInSeconds 后,有两条路径会把过期的句子再说一遍,或者在来电者说话时盖过对方。下面的行号指 PR head 分支(pr-1489)里的 ConversationRelaySession.cs。

🔴 严重问题

1. PlayingBeforeAfterTurnLine 会匹配到历史上同文本的填充语,误判"after-turn line 被打断"(L1269-1274)

  • 场景:
    • 第 1 轮说了填充语 F("One moment, please.",Mark=5),接着说 L1(_afterTurnLineMark=6)。
    • 第 2 轮处理中,watchdog 又抽到同一句 F(Mark=7,5 句 hold-on 跨轮重复很常见)。来电者在 F 播放时出了点声音。
    • playing="One moment," 能匹配到 Mark=5 的那条旧 F,5 < 6,于是 _cutAfterTurn=true、heard=""。
  • 后果:
    • L2 说完、来电者完整听到后,第 3 轮仍被告知"上一句没听到"。
    • 第 2 点的过期重说也会被触发。
    • 这是回归:改动前 _saidCount == _afterTurnLineMark 在这个场景下是 false。
  • 修法:只看最新一条匹配 playing 的 utterance:
    var u = _utterances.LastOrDefault(x => x.Text.StartsWith(playing, StringComparison.Ordinal));
    return u.Text != null && u.Mark < _afterTurnLineMark && !_afterTurnLine!.Trim().StartsWith(playing, ...);

2. _resumeLine 在新的一句发出后不会失效,可能把旧句子再说一遍(L855-866、L868-918)

  • ResumeIfSilentAsync 只检查 _activity 和 _isProcessing。如果打断发生在某个 turn 处理期间(比如第 1 点的场景),_cutInActivity 一直不变。
  • 结果:等 L2 说完、处理结束后,它会接着把 L1 再说一遍,而 L1 是上一轮的问题。
  • 修法:在 NoteLine 里清掉 _resumeLine;或者在 ResumeIfSilentAsync 里加上 _afterTurnLine == line 才重说。

3. 没开 partialPrompts 时,ResumeAfterCutInSeconds 会在来电者还没说完时重说(L884)

  • TwiML 没开 partialPrompts(TwilioService.cs 里没设这个选项),所以在来电者说完、断句结束之前,_activity 不会增加。
  • 按我对 Twilio 的理解(这是推断,没有抓包验证),interrupt 在来电者开口时就到了。来电者说一句长话("No, my address is 200 Elm Street, apartment 4")超过 N 秒,这句话就会被重说一遍,压在对方的话上。
  • 建议:在 interrupt 和 prompt 之间,用某个"说话中"的信号挡住重说;至少在 setting 注释里写明 N 要大于一句话加断句的时长,或者要求同时开启 partialPrompts。

🟡 警告

4. _lastWasResume 在锁外、发送之后才置 true(L910-915)

  • canSpeak 里调用的 NoteLine 先把它重置成 false,等 SendIfAsync 返回后才在另一个 lock 里设成 true。
  • 窗口期内到达的 interrupt 会因此允许"第三次重说"。窗口很小,但修法也很简单:直接在 canSpeak 里 NoteLine(line) 之后设 _lastWasResume = true。

5. ConversationRelayLines.LastQuestion 按 . 切句,地址和金额会切坏(ConversationRelayLines.cs L20)

  • "Is your address 100 Main St. Apt 4?" 会变成 "100 Main St. Apt 4?"("Apt 4?" 太短,被拼上了前半截)。
  • "Is the total $12.50?" 会变成 "Is the total $12. 50?"。
  • 工单场景里地址、金额很常见,建议至少排除 \d\.\d 和常见缩写,或者切不准时退回整行。

6. Backchannel 词表过宽(L397-401)

  • i / you / it / that / is / so 本身不是确认词,"Is that it?"、"So is it?" 这类反问在 1 秒窗口内会被当成 tail 丢掉。
  • 建议去掉这几个功能词,只保留真正的确认短语。

7. _lastLineHeardSome 统计的可能不是 last line 的词(L329)

  • heard 是当时正在播放的那段音频的文本,可能是填充语("One moment, please." 正好 3 个词)。
  • 结果:没听到问题,却被当成"听到了几个词",于是不丢 tail。不会造成错误丢弃,只是保护失效。

🟢 建议

  • 测试:LastQuestion 是纯静态函数,很适合补单元测试,覆盖第 5 点的 case、"And " 开头、句前是确认语这几种情况。
  • 多语言:IdleAsksAgain、DropBackchannelTails 的规则全是英文,Words() 的 [^a-z0-9'\- ] 还会把 "sí" 切成 "s"。行为是安全的(不会误丢),但开关是按 agent 配的,PromptTexts 却是全局的。西语 agent 打开 IdleAsksAgain 会说成 "Sorry, I didn't catch that. ¿…?"。建议在注释里写明只适用于英文,和 StreamReplies 的写法一致。
  • IdleTimeouts:注释说"最后一个是挂断前",这要求 Count == MaxIdlePrompts + 1,但代码没校验。可以不改,但建议在注释里写明这个对应关系。

✅ 做得好的

  • 所有新行为默认关闭,For(agentId) 没有覆盖时直接返回 this,对现有 agent 没有影响。
  • 新状态的读写都放在 _sync / canSpeak 里,延续了原文件"拿到发送锁后再判断"的模式。
  • 纯标点 prompt 过滤(Any(char.IsLetterOrDigit))改动小,针对线上实际看到的问题。
  • 每条规则都在 <remarks> 里写了来源("seen on calls"),方便以后判断能不能删。

… again, nothing said over a caller still talking

- A filler said again on a later turn ("One moment, please.") is the one playing at a cut-in, not the same words said
  before the line: only the latest utterance with the words played counts, so a line heard in full is not told to the
  next turn as cut off (this check runs for every relay agent)
- A line cut into is not said again once a newer line went out (NoteLine clears it)
- With ResumeAfterCutInSeconds on, the TwiML asks for partialPrompts (Deepgram Flux sends them): an unfinalized
  transcript is the caller still talking, so the cut line isn't said over them; its words are not kept, as the final
  prompt carries the whole turn
- A line said again is marked so as it goes out, under the lock: a cut-in into it, however soon, isn't followed by a
  third time
- The last question of a line is found without ending a sentence at a decimal ("$12.50") or an abbreviation ("St.")
- A tail is only an acknowledgement with a word that acknowledges and no question in it: "Is that it?" is kept
- Only words of the last line heard before a cut-in count as having heard some of it, not a filler's

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@yileicn

yileicn commented Nov 10, 2026

Copy link
Copy Markdown
Member

第二轮 Review:PR #1489(新增提交 2626dacc5)

结论:上一轮的 3 条严重问题和 4 条警告都已修好,可以合并。 只剩下面两点:第 1 点是第一版就有、我上轮漏看的,但影响面窄,可以合并前修,也可以另开 PR 跟进;第 2 点是小事。

上一轮问题逐条核对

# 问题 结果
1 填充语匹配到历史同文本 ✅ 改成只看最新一条匹配 playing 的 utterance(LastOrDefault),只有它在 after-turn line 之前才算
2 旧句子过期后还会被重说 ✅ NoteLine 里清掉 _resumeLine;流式回复和 after-turn line 都会走 NoteLine
3 来电者还没说完就重说,盖过对方 ✅ 开了 ResumeAfterCutInSeconds 就在 TwiML 里加 partialPrompts,并在注释写明非 Flux 时 N 要大于一句话的时长
4 _lastWasResume 设置太晚,有竞态 ✅ 移进 canSpeak,放在 NoteLine 之后,先后顺序正确
5 按 . 切句会切坏地址和金额 ✅ 改成"标点后跟空白才切,排除常见缩写"。$12.50 和 p.m. 中间都没有空白,不会被切开
6 确认词表过宽 ✅ 拆成确认词和陪衬词,要求至少有一个确认词、且不含 ?,"Is that it?" 不会再被丢
7 _lastLineHeardSome 统计到填充语 ✅ 现在要求 heard 是 _lastLine 的开头才算

被删掉的 _partialPrompt 拼接逻辑:已按 Twilio 文档确认,可以删。

  • 关掉 partialPrompts(默认)时,只会收到 last: true 的 prompt,所以原来的拼接代码一直没生效过。
  • 打开后,last: false 的消息是未定稿的转写和 eager end-of-turn。文档要求应用在最终转写与 eager 猜测一致时重发回复,所以最终那条 prompt 就是完整一轮的话,不该和前面的片段拼起来。
  • 文档也写明 partialPrompts 只对 Deepgram Flux 生效,和 PR 注释一致。
  • 有一点没找到文档说明:每条 voicePrompt 是累计的全文,还是只有最新一段。如果是累计的,原来的拼接会把同一句话重复好几遍,所以删掉更对。这一点最好在真实通话上看一眼确认。

🟡 剩下的问题

1. 被打断的流式回复:NoteLine 在 turn 结束时才调用,可能丢掉来电者的 "Yes"(CloseUtteranceAsync → NoteLine(_lastStreamSegment))。可信度:有可能发生,还没复现。

  • 场景:流式问题 "Is the address 100 Main Street?" 播到一半,来电者说 "Yes" 打断。
    • interrupt 到达时 _lastLine 还是上一句,所以 _lastLineHeardSome 算成 false。
    • 之后 turn 结束,CloseUtteranceAsync → NoteLine 把 _lastLineSentUtc 设为当前时间,并把 _lastLineHeardSome 重置为 false。
    • 如果 "Yes" 的 prompt 在这之后 1 秒内到达,IsBackchannelTail 会认为它"在问题发出前就说了",于是丢掉。
  • 建议:_dropStream 为 true(流已因打断被丢弃)时,CloseUtteranceAsync 不要用当前时间刷新 _lastLineSentUtc、也不要重置 _lastLineHeardSome。或者改用第一段发出的时间(类似已有的 _utteranceStartMark)。
  • 这是第一版就有的代码,我上轮漏了。只在同时开了 DropBackchannelTails 和 StreamReplies 时才会触发。

2. 陪衬词组合在没有问号时仍会被丢(低)

  • "Is that right":is、that 是陪衬词,right 是确认词,满足条件。如果 STT 没打出问号,又在问题发出后 1 秒内到达,就会被当成尾巴丢掉。
  • Deepgram 一般会加标点,而且窗口只有 1 秒,影响很小,可以接受。

🟢 建议

  • ConversationRelayLines.LastQuestion 还是没有单元测试。这次的切句正则改动较大,建议补几条用例:St.、$12.50、p.m.?、"And …" 开头、句前是确认语。

Sources:

… and a question without its mark is no tail

- The caller said "Yes" over a streamed question that had all gone out; the turn closed it after that, and NoteLine dated
  it from the close and forgot what was heard, so the "Yes." was dropped as a tail of something said before it. What the
  caller heard is now checked against the line as far as it was streamed, and a line cut into while it streamed keeps
  that and the time its first piece went out (only with StreamReplies and DropBackchannelTails on)
- "Is that right" without its question mark is a question, not an acknowledgement

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@yileicn

yileicn commented Nov 10, 2026

Copy link
Copy Markdown
Member

reviewed

@yileicn
yileicn merged commit 8f3b8ea into SciSharp:master Nov 10, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants