Skip to content

Commit 3005e3c

Browse files
Introduce ChatStreamInterruptedException: handle broken streams with retries, teammate failure reporting, and related updates to tests and documentation.
1 parent dc9c2bd commit 3005e3c

7 files changed

Lines changed: 129 additions & 20 deletions

File tree

‎docs/34-asides-and-team-messages.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ model's account of the stall to the main branch as a `blocker` from that branch,
168168
lead (see the stall rule in [Hidden model instructions](22-hidden-model-instructions-and-run-completion.md)).
169169
A teammate's branch is recognised by its first message: a team message the main branch sent.
170170

171+
### Failures
172+
173+
A team runs with nobody watching each branch, so a failure must neither stop the team silently
174+
nor wait for someone to press Retry:
175+
176+
- **A broken stream is retried by the agent.** When a model step's stream breaks off after it has
177+
started — falls silent past the idle timeout, ends in the middle of a tool call, or loses its
178+
connection (`ChatStreamInterruptedException`, `IOException`) — the agent asks for the step again,
179+
up to three times in a row, after 2, 4 and 6 seconds. Nothing of the broken step was persisted
180+
or executed, so this is the same as Retry. This applies to every chat, not only teams.
181+
- **A teammate's failed turn is reported to the lead** as a `blocker` beginning with "Failed:";
182+
`team-coordinate` resumes that branch once with `app_runs` Resume and tells the user if it fails
183+
again. A turn stopped for lack of progress is reported the same way.
184+
171185
### Known limits
172186

173187
- Branches share the project's working directory. The charter assigns owned paths to each role;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace AI.Application.Chat;
2+
3+
/// <summary>
4+
/// A response stream that started and then broke off before its finish: it fell silent, or ended
5+
/// in the middle of a tool call. Nothing the model said in it has been acted on, so the step can be
6+
/// asked for again; see <see cref="Tools.ChatAgent"/>.
7+
/// </summary>
8+
public sealed class ChatStreamInterruptedException(string message) : InvalidOperationException(message);

‎src/AI.Server/Application/Runs/ChatRunDispatcher.cs‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,12 @@ private static List<QueuedRunMessage> TakenAsides(ChatDetails chat, Guid? head,
386386
}
387387

388388
/// <summary>
389-
/// A teammate whose turn stopped for lack of progress tells the lead, who would otherwise learn
390-
/// of it only by looking. A teammate's branch is one that a team message of the main branch
389+
/// A teammate whose turn stopped — for lack of progress, or failed — tells the lead, who would
390+
/// otherwise learn of it only by looking, and a team waits on a lead that does not look. A
391+
/// teammate's branch is one that a team message of the main branch
391392
/// started (docs/34-asides-and-team-messages.md); any other branch keeps the report to itself.
392393
/// </summary>
393-
private async Task ReportStalledTeammateAsync(Runtime runtime, string report, CancellationToken token)
394+
private async Task ReportToLeadAsync(Runtime runtime, string report, CancellationToken token)
394395
{
395396
var (projectId, chatId, branchId) = (runtime.State.ProjectId, runtime.State.ChatId, runtime.State.BranchId);
396397
if (branchId == chatId) return;
@@ -405,7 +406,7 @@ private async Task ReportStalledTeammateAsync(Runtime runtime, string report, Ca
405406
try
406407
{
407408
await SubmitCoreAsync(projectId, chatId, new SubmitChatMessageRequest(id, id,
408-
"Stopped: several steps in a row produced nothing new.\n\n" + report, ChatSubmitMode.Send, chatId),
409+
report, ChatSubmitMode.Send, chatId),
409410
true, token, new ChatMessageSender(chatId, branchId, "blocker"));
410411
}
411412
catch (Exception error) when (error is InvalidOperationException or ArgumentException or IOException
@@ -695,7 +696,8 @@ await contextBuilder.BuildAsync(chat, ResumeHead(chat, runtime.State.BranchId, q
695696
draftToolCall: (name, ct) => ReportDraftToolCallAsync(runtime, name, ct),
696697
overlayPromptsAllowed: queued.Interactive,
697698
asides: ct => TakeAsidesIntoTurnAsync(runtime, ct),
698-
stalledReport: (report, ct) => ReportStalledTeammateAsync(runtime, report, ct));
699+
stalledReport: (report, ct) => ReportToLeadAsync(runtime,
700+
"Stopped: several steps in a row produced nothing new.\n\n" + report, ct));
699701
var suggestTitle = false;
700702
Guid? answeredHead = null;
701703
using (await synchronization.EnterAsync(runtime.State.ChatId, token))
@@ -771,6 +773,15 @@ await contextBuilder.BuildAsync(chat, ResumeHead(chat, runtime.State.BranchId, q
771773
try { await chatMutations.MarkActivityCoreAsync(runtime.State.ProjectId, runtime.State.ChatId, CancellationToken.None); }
772774
catch (Exception markError) when (markError is IOException or UnauthorizedAccessException
773775
or InvalidOperationException or Domain.Common.DomainException) { }
776+
// Started outside this lease: telling the lead submits into the main branch, which
777+
// takes the same chat lock.
778+
if (!_shutdown.IsCancellationRequested)
779+
{
780+
var failure = Describe(error);
781+
_ = Task.Run(() => ReportToLeadAsync(runtime,
782+
$"Failed: {failure}\nThe branch is stopped until someone retries or discards its turn.",
783+
_shutdown.Token));
784+
}
774785
}
775786
if (error is OperationCanceledException && runtime.ResumeRequested && !_shutdown.IsCancellationRequested) runtime.State.Resume();
776787
try { await SaveAsync(runtime, await chats.GetAsync(runtime.State.ProjectId, runtime.State.ChatId, CancellationToken.None), CancellationToken.None); }

‎src/AI.Server/Application/Tools/ChatAgent.cs‎

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ await persist(ToolMessage(batch[remaining].Id,
159159
}
160160

161161
var empty = 0;
162+
var interrupted = 0;
162163
var truncated = 0;
163164
var continuedAnswer = new StringBuilder();
164165
var stalledSteps = 0;
@@ -377,17 +378,30 @@ async Task<string> StalledReportAsync(IReadOnlyList<ChatCompletionMessage> messa
377378
}
378379
await Usage(plan);
379380
if (!plan.Fits) throw new ContextWindowExceededException(plan);
380-
await foreach (var chunk in completion.StreamAsync(
381-
request with { ContextMessages = plan.Messages, Tools = available }, token))
381+
try
382382
{
383-
chunkCount++;
384-
if (chunk.ToolCalls is { } received) calls.AddRange(received);
385-
if (chunk.FinishReason is { Length: > 0 } reason) finish = reason;
386-
if (chunk is { ToolCallsStarted: true, ToolCallName: { Length: > 0 } starting } && draftToolCall is not null)
387-
await draftToolCall(starting, token);
388-
if (chunk.Content.Length == 0) continue;
389-
content.Append(chunk.Content);
390-
await Draft(chunk.Content);
383+
await foreach (var chunk in completion.StreamAsync(
384+
request with { ContextMessages = plan.Messages, Tools = available }, token))
385+
{
386+
chunkCount++;
387+
if (chunk.ToolCalls is { } received) calls.AddRange(received);
388+
if (chunk.FinishReason is { Length: > 0 } reason) finish = reason;
389+
if (chunk is { ToolCallsStarted: true, ToolCallName: { Length: > 0 } starting } && draftToolCall is not null)
390+
await draftToolCall(starting, token);
391+
if (chunk.Content.Length == 0) continue;
392+
content.Append(chunk.Content);
393+
await Draft(chunk.Content);
394+
}
395+
interrupted = 0;
396+
}
397+
// A stream that broke off part-way has left nothing behind: the step is persisted only
398+
// once it is complete, so none of its calls ran. Asking for the step again is what the
399+
// person would do with Retry, and a team branch has nobody watching to press it.
400+
catch (Exception error) when (StreamBroke(error, token) && ++interrupted <= MaxInterruptedSteps)
401+
{
402+
await Draft(null);
403+
await Task.Delay(TimeSpan.FromSeconds(2 * interrupted), token);
404+
continue;
391405
}
392406
if (calls.Count == 0 && content.Length == 0)
393407
{
@@ -594,6 +608,20 @@ await AbandonAsync(calls, index,
594608
/// </summary>
595609
private const int MaxEmptyTurns = 2;
596610

611+
/// <summary>
612+
/// How many times in a row a step whose stream broke off is asked for again before the run
613+
/// fails. A provider that keeps dropping the same request is reported rather than retried forever.
614+
/// </summary>
615+
private const int MaxInterruptedSteps = 3;
616+
617+
/// <summary>
618+
/// Whether a step failed because its stream broke off after it had started — fell silent, ended
619+
/// mid tool call, or lost its connection — rather than because the endpoint refused it.
620+
/// </summary>
621+
private static bool StreamBroke(Exception error, CancellationToken token) =>
622+
!token.IsCancellationRequested && error is ChatStreamInterruptedException or IOException
623+
or HttpRequestException { StatusCode: null };
624+
597625
/// <summary>
598626
/// Summarizes what fills a context that is filling up: the earlier turns, kept as a history
599627
/// checkpoint, or — when they are already summarized or too small — the completed steps of a

‎src/AI.Server/Infrastructure/Chat/ChatCompletionSseParser.cs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public async IAsyncEnumerable<ChatCompletionChunk> ParseAsync(
6767

6868
if (line is null)
6969
{
70-
if (calls.Count > 0) throw new InvalidOperationException("Tool call stream ended before completion.");
70+
if (calls.Count > 0) throw new ChatStreamInterruptedException("Tool call stream ended before completion.");
7171
break;
7272
}
7373

@@ -83,7 +83,7 @@ public async IAsyncEnumerable<ChatCompletionChunk> ParseAsync(
8383
var data = line[5..].TrimStart();
8484
if (data == "[DONE]")
8585
{
86-
if (calls.Count > 0) throw new InvalidOperationException("Tool call stream has no completion marker.");
86+
if (calls.Count > 0) throw new ChatStreamInterruptedException("Tool call stream has no completion marker.");
8787
break;
8888
}
8989

@@ -182,8 +182,8 @@ public async IAsyncEnumerable<ChatCompletionChunk> ParseAsync(
182182
/// whatever text had arrived — often only "Now I'll create the files." — the run's final
183183
/// answer, and the run reported success with the work not done.
184184
/// </summary>
185-
private InvalidOperationException Stalled(int pendingToolCalls) => pendingToolCalls > 0
186-
? new InvalidOperationException("Tool call stream timed out before completion.")
187-
: new InvalidOperationException(
185+
private ChatStreamInterruptedException Stalled(int pendingToolCalls) => pendingToolCalls > 0
186+
? new ChatStreamInterruptedException("Tool call stream timed out before completion.")
187+
: new ChatStreamInterruptedException(
188188
$"The model stopped sending its answer for {policy.StreamIdleTimeout.TotalSeconds:0} seconds before finishing it.");
189189
}

‎src/AI.Server/Skills/BuiltIn/team-coordinate/SKILL.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ owned paths, deliverables and the protocol (docs/34-asides-and-team-messages.md)
2323
sender's branch, intent `Answer`, mode Send: it wakes the teammate.
2424
- `blocker`: unblock it — answer, adjust the scope, or move the work to another teammate. A
2525
change to scope, owned paths or a contract is a decision (below).
26+
A `blocker` that starts with "Failed:" is the application reporting that the teammate's turn
27+
failed (an endpoint or stream error, not the teammate's judgement): resume that branch once
28+
with `app_runs` Resume; if it fails again, tell the user which branch is stopped and why.
2629
- `decision` proposed by a teammate: accept or reject it as a decision.
2730
- `status`: nothing to send. Note it for the status table.
2831
- `done`: check the deliverable against its definition of done — read the changes, run the

‎tests/AI.Server.Tests/Infrastructure/Storage/ChatExecutionTests.cs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,51 @@ await fixture.Dispatcher.SubmitFromRunAsync(fixture.ProjectId, fixture.ChatId,
441441
chat!.Messages.Single(message => message.Id == question).Delivery.ShouldBe(MessageDelivery.Turn);
442442
}
443443

444+
[Fact]
445+
public async Task AStepWhoseStreamBrokeOffShouldBeAskedForAgainWithoutFailingTheRun()
446+
{
447+
await using var fixture = await Fixture.CreateAsync();
448+
await fixture.SetChatAutomationAsync(new ChatAutomationSettings(AutoTitle: false, SuggestReplies: false));
449+
fixture.Completion.NextPrelude = "Reading the files";
450+
await fixture.SubmitAsync(new SubmitChatMessageRequest(Guid.NewGuid(), Guid.NewGuid(), "Summarize the repository"));
451+
452+
(await fixture.NextCallAsync()).Answer.SetException(
453+
new AI.Application.Chat.ChatStreamInterruptedException("Tool call stream timed out before completion."));
454+
(await fixture.NextCallAsync()).Answer.SetResult("The summary.");
455+
456+
await fixture.WaitAsync(run => run.Status == ChatRunStatus.Completed);
457+
var chat = await fixture.Chats.GetAsync(fixture.ProjectId, fixture.ChatId, CancellationToken.None);
458+
chat!.Messages[^1].Content.ShouldBe("The summary.");
459+
// The broken attempt left nothing behind, its half-streamed text included.
460+
chat.Messages.ShouldNotContain(message => message.Content.Contains("Reading the files"));
461+
}
462+
463+
[Fact]
464+
public async Task ATeammatesFailedTurnShouldBeReportedToTheLead()
465+
{
466+
await using var fixture = await Fixture.CreateAsync();
467+
await fixture.SetChatAutomationAsync(new ChatAutomationSettings(AutoTitle: false, SuggestReplies: false));
468+
await fixture.SubmitAsync(new SubmitChatMessageRequest(Guid.NewGuid(), Guid.NewGuid(), "Team charter"));
469+
(await fixture.NextCallAsync()).Answer.SetResult("Forking the team.");
470+
await fixture.WaitAsync(run => run.Status == ChatRunStatus.Completed);
471+
var head = (await fixture.Chats.GetAsync(fixture.ProjectId, fixture.ChatId, CancellationToken.None))!.Messages[^1].Id;
472+
var brief = Guid.NewGuid();
473+
await fixture.Dispatcher.SubmitFromRunAsync(fixture.ProjectId, fixture.ChatId,
474+
new SubmitChatMessageRequest(brief, brief, "You are backend.", ChatSubmitMode.Fork, fixture.ChatId,
475+
MessageParentMode.Explicit, head),
476+
new AI.Domain.Chats.ChatMessageSender(fixture.ChatId, fixture.ChatId, "decision"), CancellationToken.None);
477+
478+
(await fixture.NextCallAsync()).Answer.SetException(new InvalidOperationException("The endpoint refused the request."));
479+
480+
// The lead is woken by the report rather than waiting for a teammate that will never answer.
481+
var lead = await fixture.NextCallAsync();
482+
var report = lead.Request.ContextMessages!.Last(item => item.Role == "user" && !item.IsContextSummary);
483+
report.ForModel.ShouldContain("(blocker)");
484+
report.ForModel.ShouldContain("Failed: The endpoint refused the request.");
485+
lead.Answer.SetResult("Resuming the backend branch.");
486+
await fixture.WaitAsync(run => run.BranchId == fixture.ChatId && run.Status == ChatRunStatus.Completed);
487+
}
488+
444489
[Fact]
445490
public async Task MessageFromAnotherBranchsRunShouldCarryItsSender()
446491
{

0 commit comments

Comments
 (0)