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 : Object — New 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/Else → Case 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).
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 objectwrapper) 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.Changes — all in [OpenAIChatHelper.4dm](Project/Sources/Classes/OpenAIChatHelper.4dm)
1. Token + API
property _cancellation : Object—New 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— readsBool(This._cancellation.requested); also returnsTrueif an optionalonCancelCheck : 4D.Functioncallback returnsTrue.Function _resetCancellation()— clears the flag (insideUse).2. Per-turn reset — in
prompt(), alongside the existing_iteration:=0/stopReason:="", call_resetCancellation()(eachprompt()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 overmax_iterations. The no-stream branch becomes:The
If/Else→Case ofswap absorbs the new cancel priority cleanly; the same shape applies to the stream branch.4. Mid-chain check in
_processToolCalls(~L600For each ($toolCall; $toolCalls)): at the top of each iteration, if_isCancelled(), stop executing and push a"Tool call cancelled"tool message for every remainingtool_call.id. This keeps the message history valid (eachtool_callsid gets a matchingtoolresponse) for a laterprompt(), and — combined with the post-_handleToolCallscheck 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); oncancel()from the loop's own process, callrequest.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:cancel()→_isCancelled()true;_resetCancellation()→ false._processToolCallswith 2 registered tools: flip cancel after the first runs → assert it stops and the secondtool_call.idgets a"cancelled"response._manageResponseboundary: craft a fake result withfinish_reason="tool_calls"+ a registered tool, set cancel, call_manageResponse→ assertstopReason="cancelled"and no continuation happened (no network — proves the short-circuit).