Skip to content

Feature: asking another agent no longer blocks the agent that asked - #74

Closed
DevMando wants to merge 1 commit into
mainfrom
feature/non-blocking-ask-agent
Closed

DevMando wants to merge 1 commit into
mainfrom
feature/non-blocking-ask-agent

Conversation

@DevMando

Copy link
Copy Markdown
Owner

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_agent waited for the target's entire turn. If the question turned out to be several minutes of
work, 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 the
caller 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_agent hands off and returns at once. It opens a Delegation exactly as
    delegate_to_agent does, marked DelegationKind.Question.
  • Replies are worded as replies. "Ninja replied" rather than "Ninja finished" — a question asked
    in passing coming back as finished reads as though work was done on your behalf.
  • A reply gets far more room on the card than a job's outcome. For a job the interesting thing
    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.
  • The inbox copy is never trimmed, because that is the copy the model reads on its next turn.
    Trimming there would make the clipped version the only one it ever sees.
  • The tool result tells the model not to wait or guess. A model will otherwise happily say "I'll
    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 AskAsync is still the authority; a race between
the 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. AgentCallChain is an AsyncLocal, and the chain
reaches the target's turn only because Task.Run captures ExecutionContext. If that ever stopped
holding, the guard would fail silently — no exception, no other failing test, just agents
talking in circles until the budget is gone. delegate_to_agent already relied on this, so the
behaviour is not new, but ask_agent did not, so the exposure is wider. It is now pinned by a test
that 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.Desktopsucceeded, 0 warnings, 0 errors.
  • dotnet test src/MandoCode.Desktop.Tests496 passed, 0 failed (up from 489).
  • New coverage: asking returns without the peer being called; the hand-off carries the asking
    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.
  • The existing loop-guard and AsyncLocal concurrency 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_agent returns
instantly 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.

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

Copy link
Copy Markdown
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.

@DevMando DevMando closed this Sep 11, 2026
@DevMando
DevMando deleted the feature/non-blocking-ask-agent branch September 11, 2026 04:50
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