Skip to content

Fix repeated Deep Agents tool calls with identical arguments - #1806

Open
1fanwang wants to merge 7 commits into
temporalio:mainfrom
1fanwang:fix/deepagents-distinct-tool-call-cache
Open

Fix repeated Deep Agents tool calls with identical arguments#1806
1fanwang wants to merge 7 commits into
temporalio:mainfrom
1fanwang:fix/deepagents-distinct-tool-call-cache

Conversation

@1fanwang

@1fanwang 1fanwang commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

TLDR: Under run_deep_agent, the continue-as-new result cache deduped repeated identical calls — a second tool call with the same name+args (and likewise a repeated backend op or identical live model call) was served the first call's cached result instead of running its own Activity. This PR keys the cache by per-run occurrence index at all three dispatch seams, so repeats each run independently while completed calls still reuse across continue-as-new. Patch-gated (deepagents.cache-key-per-occurrence) so in-flight histories replay unchanged.

What was changed

  • call_tool, call_model, and call_backend_op share one occurrence-keyed cache-key helper: key = (kind, payload identity, per-run occurrence index). Replay and post-continue-as-new runs regenerate the carried conversation's calls in order, so the same keys reproduce — repeats get their own Activities AND cross-CAN reuse survives (a per-invocation id in the key would orphan every carried entry; tool_call_id in the activity input is a workflow.uuid4() nonce).
  • Gated behind workflow.patched("deepagents.cache-key-per-occurrence"): histories recorded under the old payload-only keys replay with the old semantics (verified by recording a history on main and replaying it through this branch; the ungated version fails with TMPRL1100).

Why?

Dedup was wrong at every seam: a side-effecting tool requested twice ran once (and the second call's ToolMessage carried the first call's tool_call_id); a repeated read after an intervening write returned stale contents and a repeated execute never ran; identical live model calls defeat deliberate resampling.

How was this tested

Per-seam regression e2e (two identical tool calls → two invoke_tool activities; read→write→read → three backend_op activities with fresh contents; two identical prompts → two invoke_model activities with distinct responses), a cross-CAN key-regeneration test, and record-and-replay verification in both directions. Full deepagents suite: 35 passed.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang requested review from a team as code owners September 2, 2026 07:58
@tconley1428 tconley1428 added the ai-sdk Related to AI integrations label Sep 2, 2026
@DABH DABH self-assigned this Sep 10, 2026
@DABH
DABH requested a balanced review from Copilot September 10, 2026 06:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The cache-key change needs workflow patching to preserve deterministic replay of existing histories.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes duplicate Deep Agents tool calls being incorrectly served from cache.

Changes:

  • Includes tool call IDs in cache keys.
  • Adds regression coverage.
  • Documents corrected behavior.
File summaries
File Description
temporalio/contrib/deepagents/workflow.py Updates tool-result caching.
tests/contrib/deepagents/test_tools.py Tests repeated identical calls.
CHANGELOG.md Records the fix.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread temporalio/contrib/deepagents/workflow.py Outdated
Replaying a history recorded under the old (name, args) key with the
new key raises TMPRL1100: the previously-deduped second call now emits
an Activity command the history does not contain (reproduced with a
recorded history and Replayer). patched() keeps old histories on the
old key while new executions run repeated calls independently.
tool_call_id in the activity input is a per-invocation
workflow.uuid4() nonce (nothing supplies __tool_call_id__), so keying
the cache by it made every entry permanently unhittable: repeats ran,
but cross-continue-as-new reuse died and the carried snapshot grew
without bound. Key by (tool identity, per-run occurrence index)
instead: replay and post-CAN runs regenerate the carried
conversation's calls in order, so the same keys reproduce — repeats
each get their own Activity AND completed calls are still reused
after continue-as-new. Occurrence counters reset with the cache in
set_result_cache. Patch id renamed accordingly (the prior id was
never released). Also: the test fixture now honors run_deep_agent's
(input, state_snapshot=None) signature contract, and a new test pins
key regeneration across a simulated snapshot carry.
@DABH

DABH commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for the find - this is a real bug and a good catch.

It's true that under run_deep_agent, two tool calls with identical name+args deduped to one Activity. Although it's actually worse than the PR description states: the cached result is a serialized ToolMessage carrying the first call's tool_call_id, so the second call's pairing was corrupted too.

However, it looks like there were two latent problems in the original approach, both verified empirically - and now fixed on this branch:

  1. Replay break (Copilot's comment - confirmed). Replaying an old-key history under the new key raised TMPRL1100 (the previously-deduped second call emits an Activity command history doesn't have). Fixed in d05b0dc by gating behind workflow.patched(...); the recorded old history now replays cleanly and new executions get the fix.
  2. tool_call_id is a nonce, not the model's call id. Nothing supplies __tool_call_id__ (its kwargs.pop in _tools.py has no producer; the args schema excludes it), so the activity input's id is a fresh workflow.uuid4().hex per invocation — verified from scheduled-activity payloads. Keying by it made every cache entry permanently unhittable: repeats ran, but cross-continue-as-new reuse (the cache's whole purpose) silently died and the carried snapshot grew without bound. Fixed in f8fbea1: the key is now (tool identity, per-run occurrence index) — replay and post-CAN runs regenerate the carried conversation's calls in order, so repeats each get their own Activity and completed calls still reuse across CAN. Occurrence counters reset with the cache; a new test pins key regeneration across a simulated snapshot carry, and the fixture now honors run_deep_agent's (input, state_snapshot=None) contract.

Also merged latest main (conflict was stale) and noted the patch gate in the changelog entry. Full deepagents suite: 34 passed; lint/type/docs gates clean; record-and-replay verified in both directions.

Follow-ups deliberately not in this PR (pre-existing, same family): call_backend_op still dedups by (ref, op, args) - a repeated identical side-effecting backend op (e.g. execute("pytest") after edits) is served the stale first result; call_model has the same shape. Worth its own issue/PR with the same occurrence treatment. A CI-committed old-key replay fixture would also harden the patch gate beyond my ad-hoc experiment.

The same served-stale-result bug existed at both sibling dispatch
seams: a repeated identical backend op returned the first op's cached
result (a read after an intervening write saw stale contents; a
repeated execute never ran), and a repeated identical live model call
was served the first response, defeating deliberate resampling. All
three dispatchers now share one occurrence-keyed helper behind a
single patch id (deepagents.cache-key-per-occurrence; the earlier
tool-only id was never released). Regression e2e per seam:
read-write-read sees fresh state with three backend_op activities, and
two identical prompts produce two invoke_model activities with
distinct responses. Record-and-replay verified: a main-recorded
old-key history replays cleanly, and a new-code history round-trips.
@DABH

DABH commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up on the "deliberately not in this PR" items: on reflection they belong here — same bug class, same release train, and shipping the tool fix alone would leave backend reads serving stale file contents. Pushed in fbcd84e:

  • All three dispatch seams (call_tool, call_model, call_backend_op) now share one occurrence-keyed helper behind a single patch id, deepagents.cache-key-per-occurrence. Backend ops are order-sensitive I/O (a repeated read after an intervening write must see fresh state; a repeated execute must run), and identical live model calls can be deliberate resampling — dedup was wrong at every seam, not just tools.
  • Regression e2e per seam: read→write→read sees fresh state with three backend_op activities; two identical prompts yield two invoke_model activities with distinct responses; the tool repeats test and the cross-CAN key-regeneration test stand from earlier commits.
  • Replay verified both directions with recorded histories: a main-recorded old-key history replays cleanly under the gate, and a new-code history round-trips.

That closes out everything from the review except the CI-committed old-key replay fixture, which needs a checked-in history file — happy to add if maintainers want it in this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-sdk Related to AI integrations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants