镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(voice): add DecisionGuardrail to check turns with a decision model - #8504

Open
tinalenguyen wants to merge 3 commits into
mainfrom
tina/decisionguardrail
Open

tinalenguyen wants to merge 3 commits into
mainfrom
tina/decisionguardrail

Conversation

@tinalenguyen

@tinalenguyen tinalenguyen commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Adds DecisionGuardrail, which runs DecisionModel checks at three points in a session. Each check is a decorated handler that gets the results and returns what should happen next.

guardrail = DecisionGuardrail(session, model="openai/gpt-6-luna-decisions")

@guardrail.on_user_turn(decisions={
    "abusive": Probability("The user is abusive or threatening."),
    "resolved": Probability("The caller's issue is resolved."),
})
def before_reply(ev: DecisionEvaluated) -> Block | Steer | None:
    if ev.value("abusive", 0.0) > 0.8:
        return Block(say="I'm going to end the call here.")
    if ev.value("resolved", 1.0) < 0.3:
        return Steer(instructions="The issue is not resolved. Ask what is still wrong.")
    return None

@guardrail.on_agent_sentence(decisions={"off_policy": Probability("...")}, on_error="block")
def during_reply(ev: DecisionEvaluated) -> Block | None: ...

@guardrail.on_agent_turn(decisions={"empathetic": Probability("...")})
def after_reply(ev: DecisionEvaluated) -> None: ...

Hooks

Hook Where it runs Can return
on_user_turn After the agent's on_user_turn_completed, before the reply is scheduled Block, Steer, None
on_agent_sentence Between the LLM text and the TTS / transcript, per sentence Block, None
on_agent_turn On conversation_item_added for assistant messages None
  • on_user_turn
    • Eager evaluation (default): each final transcript re-evaluates the turn so far, cancelling the previous request, so the result is usually ready at end of turn. If the final transcript differs, it's evaluated again.
    • Block: no reply. The user's message stays in the history, and say is spoken if given.
    • Steer: adds a system message to this turn's context only. A preemptive reply is then discarded by the existing equivalence check and regenerated. With a realtime model, it is passed as the reply's instructions instead.
  • on_agent_sentence
    • Splitting: sentences are split with retain_format=True, so the released text is exactly what the LLM produced. They're checked concurrently and released in order.
    • Block: cancels the LLM, so there's no more text and no tool calls after the blocked sentence. Sentences already allowed finish playing and are the only ones kept in the history. say is spoken after the reply, unless it was interrupted or never played.
  • Errors: on_error ("allow" / "block") decides what a failed check means: the evaluation fails or times out, any decision comes back without a result, or the handler raises. The timeout covers the evaluation and an async handler together.
  • Multiple results: the first Block wins; otherwise every Steer applies.
  • One per session: a check can evaluate any mix of Probability, Choice and Score, and a hook can have several handlers, so one guardrail covers a session. It is exposed as session.guardrail; creating a second one raises.

Not in this PR

  • Holding playout until a check passes, and a reference-counted playout gate shared with AMD.
  • say() text and realtime-model replies don't go through on_agent_sentence.

Tests

  • tests/test_guardrail.py: unit tests for results, errors, timeouts, eager reuse, and the sentence gate.
  • tests/test_guardrail_session.py: through AgentSession with the fake models. Covers Block and Steer on a user turn, a sentence Block mid-reply, and on_agent_turn.

DecisionGuardrail runs DecisionModel checks at three points in a session:

- on_user_turn, before the agent replies: handlers return Block (no reply,
  optionally say something instead) or Steer (instructions for this reply).
  Final transcripts are evaluated eagerly so the result is usually ready at
  end of turn.
- on_agent_sentence, between the LLM and the TTS / transcript: each sentence
  is checked before it is spoken. Block cancels the LLM, lets the sentences
  already allowed finish, and says the replacement after the reply.
- on_agent_turn, after each agent message: checks only.

on_error decides what a failed or timed-out check means (allow or block).
@tinalenguyen
tinalenguyen marked this pull request as ready for review November 10, 2026 23:22
@tinalenguyen
tinalenguyen requested a review from a team as a code owner November 10, 2026 23:22
devin-ai-integration[bot]

This comment was marked as resolved.

- Steer reaches realtime replies through the reply's instructions, since a
  realtime reply doesn't read the chat context.
- An eager evaluation is reused only when the model saw the same input, so
  history checks see edits made in on_user_turn_completed. Eager history now
  starts from the agent's context rather than the session history.
- The timeout covers the evaluation and an async handler together.
- A decision without a result fails the whole check (on_error applies), so a
  handler reading a default can't let through what on_error="block" stops.
- Guardrails close after the activity drains, so speech still playing out
  keeps its checks.
- Logging an unsupported result records only its type, not its text.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +317 to +323
def _agent_history(self) -> llm.ChatContext:
# the context the reply is generated from, before on_user_turn_completed edits it
try:
return self._session.current_agent.chat_ctx
except RuntimeError:
return self._session.history

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.

🟡 Agent history helper lacks required documentation

The new _agent_history method has no docstring. Repository rules require documentation for every new method.

Suggested change
def _agent_history(self) -> llm.ChatContext:
# the context the reply is generated from, before on_user_turn_completed edits it
try:
return self._session.current_agent.chat_ctx
except RuntimeError:
return self._session.history
def _agent_history(self) -> llm.ChatContext:
"""Return the active agent's chat context, or session history if inactive."""
try:
return self._session.current_agent.chat_ctx
except RuntimeError:
return self._session.history

Devin Review


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

Every check can already evaluate any mix of probabilities, choices and scores,
and a hook can have several handlers, so one guardrail covers a session. The
session holds it in a single slot, exposed as session.guardrail, and creating a
second one raises instead of running next to the first unnoticed. Closing a
guardrail frees the slot.

This branch has not been deployed

No deployments
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.

1 participant