Skip to content

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

Open
1fanwang wants to merge 15 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 15 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 (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 retires that cache for new executions: repeats are legitimate work, single-run replay needs no cache (history supplies recorded results), and under resume-from-transcript continue-as-new semantics (#1804) a carried cache entry could only ever serve a stale result. Patch-gated so in-flight histories replay unchanged.

What was changed

  • call_tool, call_model, and call_backend_op no longer consult or populate the result cache on new executions — every dispatch runs its own Activity. The continue-as-new snapshot stops carrying __temporal_cache__.
  • workflow.patched("deepagents.cache-key-per-occurrence") selects between legacy-cache and no-cache: histories recorded under the old behavior contain dedup decisions (a repeated call answered with no Activity scheduled), so their replay keeps the cache. Verified by recording a dedup history on main and replaying it through this branch (the ungated version fails with TMPRL1102/1100-class nondeterminism), plus a new-code round-trip.
  • Evolution note for reviewers: this PR went call-id keys → per-run occurrence keys → no cache, each step driven by review findings (call-id is a uuid nonce; occurrence counters reset across CAN and served cross-boundary stale hits). The commit history preserves the chain.

Why?

Dedup was wrong at every seam: a side-effecting tool requested twice ran once (with the second call's ToolMessage carrying 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; and post-continue-as-new, a genuinely new identical call could be served the previous run's result.

How was this tested

Per-seam regression e2e (two identical tool calls → two invoke_tool activities; read→write→read → three backend_op activities seeing fresh disk state; two identical prompts → two invoke_model activities with distinct responses), the cross-boundary e2e (an identical backend op on both sides of a real continue-as-new executes on both sides, first run pinned to CONTINUED_AS_NEW), and record-and-replay verification in both directions. Full deepagents suite: 36 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.

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.
Under max_cached_workflows=0 the workflow re-registers a fresh
MutatingBackend on every replayed activation, so an instance-level
write counter could be reset between the write activity and the second
read (observed on the faster CI cells: read-at-write-count:0 twice).
Keep the fake's state on the class, as real backends keep theirs
externally.
The previous commit's class-level counter did not fix the replay race:
under max_cached_workflows=0 each replayed activation constructs the
fake in a FRESH sandbox, whose re-imported module carries its own copy
of the class, so a replay landing between the write and the second
read re-registers an instance with reset in-memory state (reproduced
locally: read-at-write-count:0 twice). Disk state survives sandbox
re-imports and re-registrations, exactly as a real backend's external
state does.

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

Resetting occurrence counters can still return stale cached results after continue-as-new and during migration from old cache keys.

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

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread temporalio/contrib/deepagents/_serde.py Outdated
Review follow-up: resetting occurrence counters at each continue-as-new
reintroduced cross-boundary staleness — the continued run resumes from
the carried transcript and never re-executes prior dispatches, so a
genuinely new identical call computed occurrence 0 and hit the previous
run's entry, and the carried payload-only entries from pre-upgrade runs
were orphaned either way. The coherent end state: new executions do not
cache at all (repeated identical calls are legitimate work; replay
needs no cache because history supplies recorded results), and the
snapshot stops carrying the cache. The patch gate now selects between
legacy-cache and no-cache so pre-change histories, whose recorded
dedup decisions depend on the cache, replay unchanged (re-verified by
record-and-replay in both directions). Adds the cross-boundary e2e:
an identical backend op on both sides of a real continue-as-new
executes on both sides, with the first run's close event pinned to
CONTINUED_AS_NEW.
…cy coverage

- Patch id renamed to deepagents.retire-result-cache: the old name
  described superseded semantics, and intermediate-build histories
  carrying the same marker with per-occurrence meaning would replay
  nondeterministically under the reused id. The changelog now states
  that deferring the patch retains the full legacy dedup behavior.
- Inbound snapshot cache entries are seeded only on legacy executions;
  on new executions they were dead weight, and the changelog documents
  the upgrade-hop behavior (a repeated identical call re-executes
  rather than reusing a possibly-stale carried result).
- The unpatched branch gets executable coverage: a checked-in history
  recorded on pre-change main (two identical tool calls deduped to one
  activity) replays through the Replayer with the plugin installed —
  provenance and deprecate_patch-bounded lifetime documented in the
  test. The three dispatchers' copy-pasted legacy dance is factored
  into one _legacy_lookup helper.
- Cross-boundary e2e hardened: integer completion gate (lexicographic
  string compare broke at count 10) and maximum_attempts=1 so the
  per-attempt disk counter cannot be skewed by activity retries.
…-cache branch

- The module docstring, run_deep_agent docstring, and README still
  promised the retired dedup/cache-carry (a duplicate-side-effect trap
  for users relying on the documented guarantee); all three now state
  that repeated identical calls run their own Activities and nothing is
  deduplicated, with the legacy behavior scoped to pre-change replays.
- Second checked-in fixture: a continued-run history recorded
  pre-change whose post-boundary identical call was served entirely
  from the CARRIED cache (zero invoke_tool activities) — replaying it
  exercises the gated rehydrate-and-hit branch end to end, which the
  first fixture (single-run dedup) never reached.
- test_state_snapshot_roundtrip and the CAN test module docstring are
  annotated as legacy-branch plumbing tied to the patch's
  deprecate_patch cleanup; the backend-freshness e2e gets the same
  maximum_attempts=1 hardening round 1 gave the cross-boundary test;
  the replay-fixture module uses distinct Python symbols with defn-name
  overrides matching the recorded histories.
Under cache eviction (e.g. max_cached_workflows=0) a backend_op activity
scheduled just before the eviction can start after the evicted
TemporalBackend wrapper is garbage-collected, and the replay that would
re-register the ref only happens once that activity completes. The GC
finalizer now retires the entry into a bounded store that the activity's
lookup falls back to, instead of dropping it outright.
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