Goal
Make the agent loop honor a user Stop the same way plain chat does: when the user taps Stop during a tool-enabled turn, generation halts promptly, no further tools or model calls run, and the partial answer already streamed is kept as the assistant message.
Background
#2 added a working Stop for plain streaming chat (ChatNotifier.stop() → backend.stop() → native g_should_stop), and the partial reply is saved via onDone and tagged TraceOutcome.stopped.
#3 added the tool-calling agent loop (AgentLoop in lib/features/agent/application/agent_loop.dart), driven by ChatNotifier._runAgentTurn. Stop was intentionally left out of scope for the loop.
Current behavior (the bug)
ChatNotifier.stop() sets _stopRequested = true and calls backend.stop(). During an agent turn this only ends the current generation early — the await for in generateStream completes with partial output.
The loop then:
- proceeds to parseToolCall(partial) on the truncated output, and
- may execute a tool and/or run another iteration,
because AgentLoop never checks _stopRequested and _runAgentTurn passes no cancellation signal into it. So Stop doesn't actually stop the agent — it just truncates one step. The trace is also tagged done,
not stopped.
Expected behavior
- Stop during an agent turn ends the loop after the in-flight generation returns.
- No further tool executions or generations occur.
- If the loop was in answer mode (streaming the final answer), the partial text streamed so far is saved as the assistant message.
- If in tool mode (no answer streamed yet), end with whatever answer text exists (likely empty → no message, or a short "stopped" note).
- The I/O trace for the turn is tagged TraceOutcome.stopped.
Proposed implementation
- AgentLoop — add an injected bool Function()? isCancelled (or a CancellationToken). Check it:
- at the top of each iteration (before calling generateStream), and
- immediately after each generation completes (before parsing/executing a tool).
On cancel, return an AgentResult with answer = the accumulated answer-mode text (or empty), steps collected so far, and a new flag e.g. stopped: true.
- ChatNotifier._runAgentTurn — pass isCancelled: () => _stopRequested into AgentLoop. On a stopped result, save the partial answer (if non-empty) as the assistant message and record the trace as
TraceOutcome.stopped.
- Optional: thread the cancellation into the generateStream await for so an in-flight generation is abandoned immediately rather than drained.
Affected files
- lib/features/agent/application/agent_loop.dart
- lib/features/chat/presentation/chat_notifier.dart (_runAgentTurn, stop())
- test/agent_loop_test.dart
Acceptance criteria
Testing
- Unit test in test/agent_loop_test.dart: drive AgentLoop with isCancelled returning true after the first generation; assert it makes no further generateStream calls, returns the partial answer, and
reports stopped.
- Manual: 3B model, tools on, ask a multi-step question, tap Stop mid-loop → verify it halts and keeps the partial.
Goal
Make the agent loop honor a user Stop the same way plain chat does: when the user taps Stop during a tool-enabled turn, generation halts promptly, no further tools or model calls run, and the partial answer already streamed is kept as the assistant message.
Background
#2 added a working Stop for plain streaming chat (ChatNotifier.stop() → backend.stop() → native g_should_stop), and the partial reply is saved via onDone and tagged TraceOutcome.stopped.
#3 added the tool-calling agent loop (AgentLoop in lib/features/agent/application/agent_loop.dart), driven by ChatNotifier._runAgentTurn. Stop was intentionally left out of scope for the loop.
Current behavior (the bug)
ChatNotifier.stop() sets _stopRequested = true and calls backend.stop(). During an agent turn this only ends the current generation early — the await for in generateStream completes with partial output.
The loop then:
because AgentLoop never checks _stopRequested and _runAgentTurn passes no cancellation signal into it. So Stop doesn't actually stop the agent — it just truncates one step. The trace is also tagged done,
not stopped.
Expected behavior
Proposed implementation
- at the top of each iteration (before calling generateStream), and
- immediately after each generation completes (before parsing/executing a tool).
On cancel, return an AgentResult with answer = the accumulated answer-mode text (or empty), steps collected so far, and a new flag e.g. stopped: true.
TraceOutcome.stopped.
Affected files
Acceptance criteria
Testing
reports stopped.