Skip to content

[ChatHelper] Cancellation? #27

Description

@mesopelagique

Context

Once prompt() starts, the loop runs to completion — there's no way to interrupt a long tool chain or a runaway agent.

We need an abort signal checked at each loop boundary, ending the turn with stopReason = "cancelled"

Design decision (the one real fork)

How is cancellation signalled? The realistic case is a "Stop" button in a different process from the one running the loop (e.g. a form process cancels a worker running the agent). A plain boolean on the helper can't be flipped cross-process, and the helper object itself isn't shared. So the signal must live in a shared object that can be handed to another process.

Recommended: a small, reusable cancellation token (a New shared object wrapper) that the helper owns but can also be injected, so a layer or several helpers can share one token and cancel them together. helper.cancel() stays as a same-process convenience.

// same process
$helper.cancel()

// cross process: hand the token to the worker, cancel from the UI
$token:=$helper.cancellation        // shared object, safe to pass across processes
// … later, from another process …
$token.cancel()

Changes — all in [OpenAIChatHelper.4dm](Project/Sources/Classes/OpenAIChatHelper.4dm)

1. Token + API

  • property _cancellation : ObjectNew shared object("requested"; False:C215), created in the constructor (or injected via parameters, e.g. parameters.cancellation).
  • Function get cancellation : Object — exposes the token for cross-process sharing.
  • Function cancel()Use(This._cancellation) … requested:=True End use.
  • Function _isCancelled() : Boolean — reads Bool(This._cancellation.requested); also returns True if an optional onCancelCheck : 4D.Function callback returns True.
  • Function _resetCancellation() — clears the flag (inside Use).

2. Per-turn reset — in prompt(), alongside the existing _iteration:=0 / stopReason:="", call _resetCancellation() (each prompt() is a fresh cancel scope). Caveat to confirm: if an external token is injected, don't auto-reset it — let the caller own its lifecycle.

3. Boundary checks in _manageResponse (both the stream branch ~L418 and the no-stream branch ~L478). Cancel takes priority over max_iterations. The no-stream branch becomes:

If (This.autoHandleToolCalls) && ($result.choice.message.tool_calls#Null)
    Case of
        : (This._isCancelled())
            This._setStopReason($result; "cancelled")
        : (This._reachedMaxIterations())
            This._setStopReason($result; "max_iterations")
        Else
            This._iteration+=1
            This._handleToolCalls($result)
            If ($result._newResult#Null)
                return $result._newResult
            End if
            // tools may have been cancelled mid-chain
            If (This._isCancelled())
                This._setStopReason($result; "cancelled")
            Else
                This.stopReason:=$result.stopReason
            End if
    End case
Else
    This.stopReason:=$result.stopReason
End if

The If/ElseCase of swap absorbs the new cancel priority cleanly; the same shape applies to the stream branch.

4. Mid-chain check in _processToolCalls (~L600 For each ($toolCall; $toolCalls)): at the top of each iteration, if _isCancelled(), stop executing and push a "Tool call cancelled" tool message for every remaining tool_call.id. This keeps the message history valid (each tool_calls id gets a matching tool response) for a later prompt(), and — combined with the post-_handleToolCalls check in step 3 — guarantees no continuation completion fires after a cancel.

5. (Optional, async) abort the in-flight request. Track the active request (_activeRequest, captured in the continue functions / from $result.request); on cancel() from the loop's own process, call request.terminate() to stop a streaming request immediately instead of only at the next boundary. Caveat: terminating must happen in the process that owns the request, so the cross-process story stays boundary-based; this is a same-process latency optimization. I'd ship steps 1–4 first and treat this as a follow-up.

Tests — offline, in [test_openai_chat_helper_agent.4dm](Project/Sources/Methods/test_openai_chat_helper_agent.4dm) (per-check ASSERT)

Cancellation is unusually testable without network because it short-circuits before any create() call:

  • Token: cancel()_isCancelled() true; _resetCancellation() → false.
  • _processToolCalls with 2 registered tools: flip cancel after the first runs → assert it stops and the second tool_call.id gets a "cancelled" response.
  • _manageResponse boundary: craft a fake result with finish_reason="tool_calls" + a registered tool, set cancel, call _manageResponse → assert stopReason="cancelled" and no continuation happened (no network — proves the short-circuit).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions