Conversation
ask_agent awaited the target's entire turn, so a question that turned out to be minutes of work held the asking agent's turn gate for all of it and the user was locked out of their own tab with no way to convert or cancel. The split between "ask" and "delegate" was wrong in kind. Both run one full turn on the target through the same AskAsync; the only difference was whether the caller awaited. That made the model responsible for predicting, before asking, whether a question would turn out to be quick — the judgement it is worst at, with the user paying for a wrong guess. ask_agent now opens a Delegation exactly as delegate_to_agent does, marked DelegationKind.Question. The kind changes only wording: "replied" rather than "finished", and a far larger slice of the reply on the completion card, because for a question the reply is the deliverable rather than a note about work that already lives in the files. The inbox copy is never trimmed, since that is what the model reads on its next turn. Two consequences worth naming: The busy check moved before the hand-off. While asking blocked, the peer's atomic claim decided and this layer only worded the refusal; with nobody waiting to hear it, a busy target has to be caught up front or the model is told its question is on its way and finds out otherwise from a failure card. The claim inside AskAsync is still the authority, and a race now completes the question as unanswered rather than returning a refusal. The loop guard now crosses a thread boundary. AgentCallChain is an AsyncLocal and the chain reaches the target only because Task.Run captures ExecutionContext. That would fail silently if it ever stopped holding, so it is pinned by a test asserting the chain is visible on the far side of the hand-off rather than left to inspection.
Owner
Author
|
Closing unmerged — deferring to a later release rather than adding it to 0.15.0. The branch feature/non-blocking-ask-agent is kept, not deleted, so this can be reopened as-is. It is complete and green (496 tests, clean build); it is only the timing that is wrong. Worth carrying forward separately: this work surfaced a pre-existing bug it does not fix. ChatController.cs:654 drains the inbox destructively before the turn runs, with no catch that restores it, so a failed or cancelled turn loses whatever was waiting there. AgentInbox.Peek() already exists and is unused in production; peek-then-remove-on-success is the fix. That matters more once every ask result travels through the inbox, which is what this branch does. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Asking another agent a question no longer holds the asking agent hostage. It hands the question off
and returns immediately — the same way handing over a job already did — so you keep talking to your
agent while the other one works out an answer.
What was happening
ask_agentwaited for the target's entire turn. If the question turned out to be several minutes ofwork, your agent was pinned for all of it: you could not type into your own tab, and there was no
way to convert the wait into a background job or cancel it.
The split between "ask" and "delegate" was wrong in kind, which is why this kept biting. Both run
one full turn on the target through the same
AskAsync. The only difference was whether thecaller awaited the result. That put the model in charge of predicting, before asking, whether a
question would turn out to be quick — the judgement it is worst at — and the user paid for a wrong
guess with a locked agent.
What changed
ask_agenthands off and returns at once. It opens aDelegationexactly asdelegate_to_agentdoes, markedDelegationKind.Question.in passing coming back as finished reads as though work was done on your behalf.
is that it finished; the work is in the files. For a question the reply is the deliverable,
and clipping it to a job's length would send you to the other agent's tab to read two sentences.
Trimming there would make the clipped version the only one it ever sees.
wait for Ninja" — or invent what Ninja is about to say.
Scope and risk
Medium. The change itself is small, but it moves a call that used to be awaited onto a
background task, and two things that were previously true by construction now depend on that.
The busy check moved before the hand-off. While asking blocked, the target's atomic claim
decided and this layer only worded the refusal. With nobody waiting to hear it, a busy target has to
be caught up front — otherwise the model is told its question is on its way and finds out otherwise
from a failure card much later. The claim inside
AskAsyncis still the authority; a race betweenthe two now completes the question as unanswered rather than returning a refusal. There is a test
for exactly that race.
The loop guard now crosses a thread boundary.
AgentCallChainis anAsyncLocal, and the chainreaches the target's turn only because
Task.RuncapturesExecutionContext. If that ever stoppedholding, the guard would fail silently — no exception, no other failing test, just agents
talking in circles until the budget is gone.
delegate_to_agentalready relied on this, so thebehaviour is not new, but
ask_agentdid not, so the exposure is wider. It is now pinned by a testthat asserts the chain is visible on the far side of the hand-off.
A deliberate limitation, stated plainly: the asking agent does not wake up when the reply
lands. You see the reply in the transcript immediately, at no model cost, and the agent is given it
on its next turn — but it will not act on the answer until you say something. That keeps this change
from making agents autonomous, which was the explicit requirement. If an agent needs a reply to
continue work it was mid-way through, it cannot; making it wake on its own is a separate change with
a real cost, and should be judged on its own.
Verification
dotnet build src/MandoCode.Desktop— succeeded, 0 warnings, 0 errors.dotnet test src/MandoCode.Desktop.Tests— 496 passed, 0 failed (up from 489).agent's name; a busy agent is refused before anything starts; an agent that goes busy after the
hand-off completes the question unanswered; the loop guard survives the hand-off; an answered
question reads as a reply; a finished job still reads as finished; an answer gets a longer card
than a job; the inbox carries the whole answer.
AsyncLocalconcurrency tests were kept and still pass.Not covered: no end-to-end run against real models — the tests capture the hand-off rather than
executing a background turn, deliberately, so assertions do not race a live task. The wording
changes are pinned by tests, but how the model actually behaves now that
ask_agentreturnsinstantly is a judgement only real use will settle. Worth a manual pass: ask a busy agent, ask an
idle one, and confirm your own agent stays usable throughout.