Skip to content

Refuse a blank question put to a person, the way a blank task handed to a Bot is refused - #476

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:fix/escalation-blank-question
Sep 10, 2026
Merged

Refuse a blank question put to a person, the way a blank task handed to a Bot is refused#476
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:fix/escalation-blank-question

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

A Bot calls ask_person with {"question": ""}. It is told:

That was put to the person in this conversation. Ask it in your own words now, plainly, and stop
there: do not answer it yourself and do not hand it to another Bot.

so it stops, and its turn ends. On the Audit screen there is now an agent.escalated row — the row
an administrator counts escalations by — whose question is empty. It says a person was asked
something that was never said.

On a deployment whose EscalationRoute is a duty desk or an on-call rota rather than the person
already in the conversation, which is the seam the module is explicitly built around, that is a page
to somebody with no question on it.

Why

server/src/agents/escalation.ts already has the sentence for this case:

const parsed = parameters.safeParse(args);
if (!parsed.success) {
  return "That was not put to anybody: say what you need a person to answer.";
}

and server/tests/agent-escalation.test.ts already asserts it, under
a call with nothing in it is refused as a sentence, by calling execute({}).

But question is z.string(), which accepts "" and a run of spaces. So the refusal fired only
when the field was missing. Present and empty is the same call spelled the other way, and it went
straight through.

The tool this sits beside and competes with for the same decision does check. handoff.ts:

const task = envelope.task?.trim() ?? "";
if (!task) {
  return refuse(from, target, "no_task",
    "Nothing was sent: a handoff has to say what the other Bot is being asked to do.");
}

A blank task handed to a Bot is refused, recorded as no_task, and answered with a sentence. A blank
question put to a person was not.

The fix

Trim the question, and refuse it with the sentence that was already written for this. The trimmed
text is then what travels to the route and onto the row — for the same reason a handoff sends the
trimmed task: what is recorded should be what was asked, not the spacing around it.

Deliberately not changed: why stays optional and is left as the model wrote it, and every other
path through the tool is untouched.

Measured

bun test server/tests/agent-escalation.test.ts

  • Against origin/main with only the new tests applied: 8 pass, 4 fail.
    • an empty question is refused as a sentence — returns the "That was put to…" sentence.
    • a question of spaces is refused as a sentence — same.
    • a blank question reaches nobody and leaves no row saying it did — the route is called and an
      agent.escalated row is written.
    • a question with room around it is recorded as the question — the row carries
      " which account? ".
  • With the change: 12 pass, 0 fail.

The pins are the eight cases already in that file, which pass before and after: a real question still
reaches the route and comes back with the PUT_TO marker the transcript matches on, the row still
carries the question and the why, a routine's escalation is still filed under its initiator, a
route that reaches nobody is still recorded as agent.escalation_failed, and execute({}) is still
refused.

Also run, all green:

  • bun test server/tests/agent-handoff-tool.test.ts server/tests/agent-handoff.test.ts server/tests/control-refusal-end-to-end.test.ts server/tests/human-input-end-to-end.test.ts — 41 pass, 0 fail
  • bun run --filter server typecheck — exit 0
  • bunx biome check on both changed files — clean

Note on the CHANGELOG

A turn that used to end now does not, so there is an entry. It goes at the top of ## Unreleased,
the one line every entry goes at, so it will conflict with any other PR open against that anchor.
Happy to rebase whenever it suits you.

…to a Bot is refused

`ask_person` already answers a call with nothing in it by telling the model to
say what it needs, but `z.string()` accepts "" and a run of spaces, so that only
fired when the field was missing. Spelled the other way the turn ended on a
question nobody was asked, and the trail took an `agent.escalated` row with
nothing in its question.

The tool this competes with for the same decision refuses a blank task and says
so; this is the other half of that. The trimmed text is what travels, so what is
recorded is what was asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@davidmckayv
davidmckayv requested a review from mxmzb as a code owner September 10, 2026 16:41

@davidmckayv davidmckayv 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.

Deep-reviewed against live code (correctness, governance, no vendor/secret/scale issues). Composed build+tests green. CHANGELOG/format rebase on CI-validated substance.

@davidmckayv
davidmckayv merged commit a0a745c into CopilotKit:main Sep 10, 2026
14 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.

2 participants