cloudflare / cloudflare/agents

[Think] Durable action approval continuation retains stale post-pause assistant text

Open
#2,054 0 comments 0 reactions 1 assignee Claimed by @threepointone View on GitHub
bug think
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

**Describe the bug**

When a Think `action()` with `kind: "durable-pause"` is approved or rejected, Think replaces the paused tool output with the resolved outcome but preserves text and reasoning generated after that tool part in the same assistant message.

Think then calls `continueLastTurn()` using a transcript that contains the resolved tool result followed by stale prose such as “Once approved, the change will be applied.”

This can cause the continuation to behave as though the action is still awaiting approval, even though it executed successfully.

**To Reproduce**

1. Register a Think action using `kind: "durable-pause"`.
2. Use a deterministic mock `LanguageModel`; no external provider is required.
3. Have the first model step call the durable action.
4. After receiving the paused tool output, have the model emit:
`Once approved, the change will be applied.`
5. Call `approveExecution(executionId)`.
6. Read the stored messages after approval.
7. Observe that:
- the tool part contains the successful execution result;
- the stale “Once approved…” text remains after it in the same assistant message;
- the automatic continuation receives this contradictory transcript.

No exception is thrown. The resulting continuation is simply incorrect or misleading.

**Expected behavior**

Resolving a durable pause should not leave the model with a chronologically contradictory transcript.

The continuation should receive a valid transcript containing:

- content generated before the action;
- the action input and descriptor;
- the resolved action output;
- no prose or reasoning generated from the obsolete pending state.

Unrelated tool or artifact parts should remain intact.

Ideally, a durable pause would act as a model-turn boundary:

1. Finish the current batch of tool calls.
2. Stop before starting another model step.
3. Persist the paused tool output and finish the current turn normally.
4. Start a new continuation only after approval or rejection.

**Screenshots**

N/A

**Version**

- `agents`: `0.20.1`
- `@cloudflare/think`: `0.15.1`

The same outcome-application behavior also appears to remain on the current `cloudflare/agents` main branch.

**Additional context**

The relevant flow appears to be:

1. `_applyExecutionOutcome()` locates the paused tool call.
2. `_applyToolUpdateToMessages()` replaces only that tool part.
3. Text and reasoning generated after the paused part remain unchanged.
4. `continueLastTurn()` starts another inference using this contradictory transcript.

We currently carry a small local `pnpm` patch for `@cloudflare/think@0.15.1`.

Our patch:

- marks updates originating from `_applyExecutionOutcome()` as resolved durable-pause updates;
- removes `text` and `reasoning` parts occurring after the resolved tool part;
- preserves earlier content and later non-generated parts, including other tool and artifact parts;
- applies the same correction to both the streaming accumulator and persisted message history.

Conceptually, the cleanup is:

```ts
const resolvedParts = result.parts.filter(
(part, index) =>
index <= result.index ||
(part.type !== "text" && part.type !== "reasoning"),
);
```

We also added a provider-free workerd regression test using a deterministic mock `LanguageModel`. The test:

1. Creates a durable-pause action.
2. Records pending prose after the paused tool output.
3. Approves the execution.
4. Verifies that the pending prose is removed.
5. Verifies that the automatic continuation sees the resolved result and emits a completed acknowledgement.

The regression fails on the unmodified `0.15.1` package and passes with the patch. We have also validated the corrected behavior through a real web-to-messenger approval flow.

Although the resolution-time cleanup is a small and effective fix, we believe the preferred framework behavior may be to treat a durable pause as a model-turn boundary:

1. Finish the current batch of tool calls.
2. If any result is a durable pause, stop before starting another model step.
3. Persist the paused tool output and finish the current turn normally.
4. Resume with a new continuation only after approval or rejection.

This would prevent pending-state prose and reasoning from being generated in the first place. Resolution-time cleanup may still be useful as a defensive guard for provider interleaving, races, and existing transcripts.

A turn-boundary implementation should preserve the existing distinction between a durable pause and a client interaction. In particular, it should not require treating durable pauses as `hasPendingInteraction()`, since that could interfere with Think’s recovery behavior. It only needs to stop the current inference loop after the completed tool batch.

Would be happy to open a PR against `cloudflare/agents` with either the minimal cleanup patch and regression test or the deeper turn-boundary implementation.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.