Skip to content

AgentLoop is not stop-aware mid-iteration | Stop during a tool turn doesn't halt the loop #6

Description

@ASahu16

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

  1. 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.
  2. 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.
  3. 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

  • Tapping Stop during a multi-tool agent turn halts after the current generation; no further generateStream/tool calls fire.
  • A partial final answer is preserved as the assistant message.
  • The turn's ModelIoTrace.outcome is stopped.
  • Plain (tools-off) chat Stop behavior is unchanged.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggood first issueGood for newcomersv1.2.0Target release v1.2.0

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions