Skip to content

Keep encoded failure attributes in StrandsPlugin - #155

Merged
campoy merged 6 commits into
mainfrom
fix/strands-python-caller-codec-dropped
Oct 30, 2026
Merged

campoy merged 6 commits into
mainfrom
fix/strands-python-caller-codec-dropped

Conversation

@campoy

@campoy campoy commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What was changed

  • StrandsPlugin keeps the encoding of a caller's DefaultFailureConverterWithEncodedAttributes, or of a subclass of it. It installs a private StrandsFailureConverterWithEncodedAttributes, which subclasses both StrandsFailureConverter and DefaultFailureConverterWithEncodedAttributes. Every other failure converter class still becomes StrandsFailureConverter, as before.
  • README: one sentence on encoded failures in the interrupts section.

Why?

Since 0.1.0 the plugin keeps the caller's codec and external storage, but it still replaces the failure converter class. With DefaultFailureConverterWithEncodedAttributes, failure messages and stack traces were stored as written in History, outside the codec. They can hold tool arguments, model output and provider errors.

The plugin still never keeps a caller's own from_failure: a class with its own from_failure changes the errors Workflow code reads, so a Workflow that branches on the error type can take another branch and fail replay with [TMPRL1100]. Both classes the plugin can install decode with DefaultFailureConverter.from_failure, which reads encoded and plain attributes, so Workflow code reads the same errors and no command changes.

For callers with DefaultFailureConverterWithEncodedAttributes (or a subclass), raw History changes: failure messages now read Encoded failure and stack traces move into the encoded attributes, as with that converter and no plugin. Tools that read History without the codec, such as temporal workflow show, the UI without a codec server, or alerts on failure.message, see that text.

Checklist

  1. Part of [Bug] strands_agents: StrandsPlugin replaces the caller's failure converter, and the codec upgrade needs a note #153 (it stays open for the codec upgrade note in the next release notes)

  2. How was this tested:

    • Tests in tests/test_plugin.py, each for the Client and the Replayer. test_caller_encoded_failure_converter_is_kept runs with DefaultFailureConverterWithEncodedAttributes and with a subclass of it. It fails on main (assert 'secret' == 'Encoded failure') and passes with this change; it also checks that an InterruptException still becomes a non-retryable StrandsInterrupt failure. test_other_failure_converter_is_replaced checks that a class with its own from_failure is still replaced; it passes on both.
    • test_activity_tool_interrupt also runs with a codec and DefaultFailureConverterWithEncodedAttributes: the interrupt still reaches the Workflow, the Activity failure in History reads Encoded failure, and the History replays with the same converter.
    • The reproduction in [Bug] strands_agents: StrandsPlugin replaces the caller's failure converter, and the codec upgrade needs a note #153 prints stored message: 'Encoded failure' and exits 0 with this change.
    • Replay on a local dev server (at 891685d), with an XOR codec and DefaultFailureConverterWithEncodedAttributes: histories from main and from this change (finished, waiting for an Activity-tool interrupt approval, and failed) replay on both. A run left waiting on main and approved on this change completes, and so does one moved the other way; the Workers had no Workflow cache. A renamed tool Activity fails replay with [TMPRL1100] (control). With a caller converter that has its own from_failure, histories from main and this change replay on each other.
    • make test (59 tests) and make lint pass locally on Python 3.13. CI runs the full matrix.
  3. Any docs updates needed?
    One README sentence. The codec change that shipped in 0.1.0 needs a release note; a draft is in the README review thread.

Not in this PR: a caller payload converter that is not the SDK default is still returned unchanged, so it gets no Strands failure handling (#299).

Breaking change (shipped in temporalio-strands-agents 0.1.0): StrandsPlugin now
keeps the Client's payload codec and external storage. temporalio.contrib.strands
in temporalio 1.34.0 and earlier dropped them, so its payloads are plain JSON.
A codec that rejects payloads it did not encode now fails to read results of
runs finished before the upgrade, and fails the Workflow Tasks of runs still
open. Callers with such a codec must let it pass unencoded payloads through
before upgrading. Rolling Workers back to temporalio.contrib.strands needs a
plugin after StrandsPlugin that sets the codec again.

Add an upgrade section to the README that says this.
StrandsPlugin replaced the failure converter of the Client's DataConverter
with StrandsFailureConverter. A caller that set
DefaultFailureConverterWithEncodedAttributes, so that failure messages and
stack traces are encoded with the payload codec, had them stored as written.

Keep the caller's failure converter class and add the Strands exception
handling to it. The SDK default still becomes StrandsFailureConverter.
Decoding is unchanged: every DefaultFailureConverter reads encoded and plain
failure attributes, so old histories replay and older Workers read the new
failures.
Replace the previous follow-up, which kept any caller failure converter
class and subclassed it. That subclass inherited the caller's from_failure,
which StrandsFailureConverter had replaced, so a caller converter that
rebuilds its own exceptions changed the errors Workflow code reads and
broke replay across the change.

Map DefaultFailureConverterWithEncodedAttributes to a private
StrandsFailureConverterWithEncodedAttributes, a StrandsFailureConverter
that encodes common attributes. Every other class still becomes
StrandsFailureConverter, as before. Decoding is unchanged: both classes use
DefaultFailureConverter.from_failure, which reads encoded and plain failure
attributes, so old histories replay and older Workers read the new
failures.

The README rollback example now restores the external storage as well as
the codec: temporalio.contrib.strands drops both, and a Worker without the
storage cannot read offloaded payloads.
@campoy
campoy requested a review from a team as a code owner October 23, 2026 15:31
Comment thread python/strands_agents/README.md Outdated
uv add temporalio-strands-agents
```

### Upgrading from `temporalio.contrib.strands`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since we're in public preview, I don't feel the need to document every change this verbosely. This is also a bug fix: Before, the payload codec was dropped, now it isn't, so I think the release notes are the best place to document this if it isn't included there already. Also, this seems largely unrelated to the rest of the content of this PR (failure converters).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I removed the section in 8ac59bc; the PR keeps only the one sentence on encoded failures in the interrupts section.

A short version for the next release notes, with DABH's corrections folded in:

Since 0.1.0, StrandsPlugin keeps the payload codec and external storage of the Client's DataConverter. temporalio.contrib.strands in temporalio 1.34.0 and earlier dropped them when the payload converter was the SDK default, so the payloads it wrote are plain JSON. If your codec rejects payloads it did not encode, change its decode to pass through payloads without its own encoding before you upgrade, and don't run Workers with the old and the new plugin on the same Task Queue while those runs are open.

With DefaultFailureConverterWithEncodedAttributes (or a subclass), the plugin now keeps the encoding, so failure messages in History read Encoded failure (#155).

@DABH DABH left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of the encoded-failure-attributes change. Each code finding was reproduced on this branch with temporalio 1.34.0 (and the SDK's bundled temporalio.contrib.strands for the old-plugin comparisons); the rolling-upgrade finding was reproduced end to end on a dev server.

Severity: P1 = wrong result or stuck run in realistic use; P2 = correctness or robustness in an edge case; P3 = docs, tests, cleanup.

The two items that matter most: the exact-class identity check silently writes plaintext failure messages and stack traces for any caller subclass that enables encoding (the very outcome #153 reports), and a pydantic_data_converter-based caller converter, which the README names as the plugin's default, bypasses the Strands failure converter entirely so interrupts never work on that path.

Findings on lines outside the diff

  • python/strands_agents/src/temporalio/strands_agents/_plugin.py:157: P1: a non-default payload converter gets no Strands failure handling at all, and the README now implies otherwise. elif converter.payload_converter_class is not DefaultPayloadConverter: return converter returns the caller's converter untouched, so with Client.connect(data_converter=replace(pydantic_data_converter, failure_converter_class=DefaultFailureConverterWithEncodedAttributes, payload_codec=codec), plugins=[StrandsPlugin()]) (pydantic is the converter the README calls the plugin's default) neither the Strands converter nor the new encoded variant is installed. Reproduced through the real encode_failure/decode_failure round trip and TemporalActivityTool.stream(): an activity raising InterruptException is stored as type='InterruptException', non_retryable=False, details=0, the interrupt branch at _temporal_activity_tool.py:75 is never taken, the model is fed the interrupt as a tool error, and the activity is retried under the default policy. Pre-existing pass-through (the SDK's 1.34.0 plugin does the same), and test_custom_payload_converter_is_preserved pins it, but the PR body defers it and the README's new sentence at line 546 scopes only the encoding to 'the default payload converter' without saying the opposite case loses interrupts. The openai_agents plugin raises a ValueError for the equivalent mismatch; either do the same, apply strands_failure_converter_class on this path too, or state the consequence in the README.

Pre-existing items on lines outside the diff, for completeness: terminal Strands exceptions are re-created as fresh ApplicationErrors in _failure_converter.py (around line 65), dropping __traceback__ and __cause__, so the Failure proto has no stack trace and no cause chain (and the encoded variant encodes an empty stack trace), unlike DefaultFailureConverter.to_failure; the comment above _TERMINAL_EXCEPTIONS (around line 32) says TemporalAgent.invoke_async rewraps them as StrandsWorkflowError, a class that does not exist anywhere in the package; and under codec misconfiguration _temporal_activity_tool.py:95 feeds the literal 'Encoded failure' to the model as the tool error, with no fallback to cause.type.

with ``StrandsFailureConverter``, so the failures that Workflow code reads stay
the same.
"""
if base in (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P1: the identity check silently downgrades encoding subclasses to plaintext. base in (DefaultFailureConverterWithEncodedAttributes, StrandsFailureConverterWithEncodedAttributes) is an exact-class test, so class MyEnc(DefaultFailureConverterWithEncodedAttributes): pass, a DefaultFailureConverter subclass whose __init__ passes encode_common_attributes=True, and functools.partial(DefaultFailureConverter, encode_common_attributes=True) all map to plain StrandsFailureConverter. Verified on the branch: to_failure(ApplicationError('card 4111-1111')) then stores message='card 4111-1111' and a 192-character plaintext stack trace in history, with zero warnings or log lines, versus 'Encoded failure' for the exact SDK class. The PR's rationale ('a class with its own from_failure changes the errors Workflow code reads') does not apply to these: their from_failure is DefaultFailureConverter.from_failure, and the SDK decoder reads encoded_attributes unconditionally, so mapping them to the encoded Strands class changes only the write side. Use issubclass, or check base.from_failure is DefaultFailureConverter.from_failure plus the encode flag (the SDK already instantiates the class in DataConverter.__post_init__), and at minimum warn when a caller's class is replaced. The docstring at line 94 should also say the match is by exact identity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in dc2f54c: any subclass of DefaultFailureConverterWithEncodedAttributes now gets StrandsFailureConverterWithEncodedAttributes (issubclass, guarded so a functools.partial doesn't raise). As you say, the plugin never keeps the caller's from_failure, so this changes only the write side. test_caller_encoded_failure_converter_is_kept now also runs with a subclass; those cases fail on 891685d with assert 'secret' == 'Encoded failure'.

A plain DefaultFailureConverter subclass that sets the flag in its own __init__, or a partial, is still replaced by StrandsFailureConverter; the docstring says so. I didn't add a warning: those forms are rare, and a warning would also fire for every custom class that doesn't encode.

Comment thread python/strands_agents/README.md Outdated

`StrandsPlugin` keeps the payload codec and external storage of the `DataConverter` you pass to the Client. The `temporalio.contrib.strands` plugin in `temporalio` 1.34.0 and earlier dropped them, so the payloads it wrote are stored as plain JSON. After the upgrade, your codec also decodes those payloads: results of runs that finished before the upgrade, and the History of runs that are still open. If your codec rejects payloads it did not encode, `WorkflowHandle.result()` raises its error for those runs and their Workflow Tasks fail. Before you upgrade, change the codec's `decode` to return payloads without its encoding unchanged.

Do not roll Workers back to `temporalio.contrib.strands` while runs that this plugin wrote to are open: it drops your codec and external storage, so it cannot read their payloads. If you must roll back, add a plugin after `StrandsPlugin` that sets both again from the `DataConverter` you passed to the Client, for example `SimplePlugin("restore-converter", data_converter=lambda dc: dataclasses.replace(dc, payload_codec=converter.payload_codec, external_storage=converter.external_storage))`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P1: the rollback warning applies equally to the rolling-upgrade window, and the README gives no rollout guidance. The stated condition ('old-plugin Workers running while runs this plugin wrote to are open') is exactly the state of a rolling upgrade on a shared task queue, yet it is attributed only to rolling back. Reproduced end to end on a dev server with a lenient decode exactly as line 15 prescribes: [A] a run started by the upgraded Client could not be progressed by an old-plugin Worker (WorkflowTaskFailed: Failed decoding arguments: 'Unknown payload encoding binary/b64') until that Worker was shut down; [B] with the old Worker polling activities, the activity input encoded by the upgraded Worker failed with a retryable ApplicationError: Failed decoding arguments, and under the bounded RetryPolicy(maximum_attempts=2) the README's own Retries section recommends, the workflow itself failed. A Client still on temporalio.contrib.strands cannot read results or failures written by upgraded Workers either. State that the warning applies while old Workers are still running, and advise draining, a new task queue, Worker Versioning, or a two-phase rollout that first deploys the restore-converter plugin on the old Workers.

The snippet itself also has three problems: it restores the codec and external storage but not failure-attribute encoding (the old plugin hard-codes plain StrandsFailureConverter, so every failure written after a rollback goes to history in plaintext, and restoring failure_converter_class instead loses the old plugin's interrupt translation; a subclass of the old converter with encode_common_attributes=True keeps both, verified); converter is a free variable the prose never defines; and dc is DataConverter | None, so dataclasses.replace(dc, ...) fails type checking and raises TypeError if the restore plugin is ordered first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The Upgrading section is gone in 8ac59bc, per Brian's comment on line 13: this belongs in the release notes. The rollback snippet went with it. I put your rolling-upgrade point (old and new Workers on one Task Queue can't read each other's payloads) into the release-note draft in Brian's thread.

Comment thread python/strands_agents/README.md Outdated

### Upgrading from `temporalio.contrib.strands`

`StrandsPlugin` keeps the payload codec and external storage of the `DataConverter` you pass to the Client. The `temporalio.contrib.strands` plugin in `temporalio` 1.34.0 and earlier dropped them, so the payloads it wrote are stored as plain JSON. After the upgrade, your codec also decodes those payloads: results of runs that finished before the upgrade, and the History of runs that are still open. If your codec rejects payloads it did not encode, `WorkflowHandle.result()` raises its error for those runs and their Workflow Tasks fail. Before you upgrade, change the codec's `decode` to return payloads without its encoding unchanged.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2: 'dropped them' is over-broad, and the decode advice is ambiguous. The 1.28.0 through 1.34.0 temporalio.contrib.strands plugin only replaced the converter when payload_converter_class was the SDK default (or none); a Pydantic-based converter kept its codec and external storage (reproduced: pydantic_data_converter + codec returns the same object with the codec intact; DataConverter.default + codec loses it). A user who passed DataConverter(payload_converter_class=PydanticPayloadConverter, payload_codec=EncryptionCodec()) always had encrypted history and would needlessly weaken decode by following this paragraph. For those users the upgrade-visible change is that interrupts start working, not payload encoding. Also, 'change the codec's decode to return payloads without its encoding unchanged' parses as 'strip the encoding'; the intended contract is to append input payloads whose metadata["encoding"] is not the codec's own marker to the result as-is (the SDK encryption sample pattern).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed with the section in 8ac59bc. The release-note draft in Brian's thread limits the break to the SDK default payload converter and says decode should pass through payloads without its own encoding.

Comment thread python/strands_agents/README.md Outdated
uv add temporalio-strands-agents
```

### Upgrading from `temporalio.contrib.strands`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3: the Upgrading section covers only the move from temporalio.contrib.strands, not the change for users of the previous release of this package. A DefaultFailureConverterWithEncodedAttributes that was previously replaced is now honored, so raw history failure messages flip from plaintext to 'Encoded failure': temporal workflow show, the UI without a codec server, alerting on ActivityTaskFailed.failure.message, or tests asserting on raw history change behavior. Intended, but release notes come from commit subjects, so the README is the only place to flag it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The section is gone (8ac59bc, per Brian), so this went in two other places: the release-note draft in Brian's thread, and the PR description, which the squash commit carries. Both say that with DefaultFailureConverterWithEncodedAttributes, failure messages in History now read Encoded failure.

"""
if base in (
DefaultFailureConverterWithEncodedAttributes,
StrandsFailureConverterWithEncodedAttributes,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3: this tuple entry is unreachable through the plugin, and the test that appears to cover it passes for another reason. _data_converter returns early at _plugin.py:157 for any converter whose payload class is not DefaultPayloadConverter, and the only branch that calls this function simultaneously sets payload_converter_class to pydantic's, so every converter the plugin configured short-circuits on a later pass; the function is never invoked with its own output class (verified by instrumenting both passes: the second call's input list is empty, and removing this entry keeps the assertion at tests/test_plugin.py:192-194 true for both targets). The comment there ('A second plugin pass, as a Worker built from the Client makes') is also inaccurate: SimplePlugin.configure_worker never resolves data_converter and the Worker reads client_config["data_converter"]; a second pass happens only via a Replayer or Client(**client.config()). To cover the entry, pass DataConverter(failure_converter_class=StrandsFailureConverterWithEncodedAttributes) through _configure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, the plugin's own output always returns early on the payload converter check. The tuple is gone in dc2f54c: the check is issubclass, and StrandsFailureConverterWithEncodedAttributes now subclasses DefaultFailureConverterWithEncodedAttributes, so it matches without a special case. I also fixed the test comment; the second _configure call now only claims that running the plugin on its own output keeps the class.

super().to_failure(exception, payload_converter, failure)


class StrandsFailureConverterWithEncodedAttributes(StrandsFailureConverter):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3: not a subclass of DefaultFailureConverterWithEncodedAttributes, so isinstance/issubclass guards keyed on the SDK class stop matching once the plugin has run (verified isinstance(StrandsFailureConverterWithEncodedAttributes(), DefaultFailureConverterWithEncodedAttributes) is False). A later plugin or startup guard that follows the same keep-or-replace pattern would treat the converter as non-encoding or replace it, dropping the Strands interrupt mapping. class StrandsFailureConverterWithEncodedAttributes(StrandsFailureConverter, DefaultFailureConverterWithEncodedAttributes) resolves cleanly and would also let strands_failure_converter_class use issubclass. The super().__init__(encode_common_attributes=True) at line 86 additionally relies on StrandsFailureConverter never defining __init__; a one-line forwarding __init__ on the base would make that explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in dc2f54c: it now subclasses both, so the __init__ override is gone and isinstance checks against the SDK class match. basedpyright flags the two bases (reportUnsafeMultipleInheritance), so the class has a targeted ignore and a comment saying why it's safe: StrandsFailureConverter has no __init__, so the SDK class's __init__ runs. The test's Encoded failure assertion fails if that ever changes.

class StrandsFailureConverterWithEncodedAttributes(StrandsFailureConverter):
"""``StrandsFailureConverter`` that moves message and stack trace to encoded attributes.

It encodes like ``DefaultFailureConverterWithEncodedAttributes`` and decodes like

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 doc: 'decodes like StrandsFailureConverter' is vacuous; neither class overrides from_failure. The relevant fact for a reader checking Replayer symmetry is that DefaultFailureConverter.from_failure restores message and stack trace from encoded_attributes for every subclass, which is why a Replayer mapped to plain StrandsFailureConverter still decodes histories written by the encoded variant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworded in dc2f54c: the docstring now says both Strands classes decode with DefaultFailureConverter.from_failure, which reads encoded and plain attributes, so a Replayer with StrandsFailureConverter still reads what this class writes.



@pytest.mark.parametrize("target", ["client", "replayer"])
def test_caller_encoded_failure_converter_is_kept(target: str) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3: the new tests never run the codec path they exist for. They call to_failure/from_failure directly with configured.payload_converter and no codec, bypassing DataConverter.encode_failure / decode_failure (which apply the codec to encoded_attributes and details) and the workflow-side rebuild of Interrupt from cause.details[0]. A regression where _apply_to_failure_payloads skipped encoded_attributes or details, or where from_failure's bare except: pass left message == 'Encoded failure' for _temporal_activity_tool.py:95, would pass CI. Extending test_activity_tool_interrupt with a codec plus DefaultFailureConverterWithEncodedAttributes on the Client, and replaying with the same converter, would cover it. Also, _configure routes every non-"client" string to the replayer branch, so a misspelled parametrize id silently drops client coverage; a Literal["client", "replayer"] or an else: raise AssertionError(target) makes that visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in dc2f54c. test_activity_tool_interrupt now also runs with a codec and DefaultFailureConverterWithEncodedAttributes on the Client. It checks that the Activity failure in History reads Encoded failure and replays with the same converter. The Workflow rebuilds the interrupt from the codec-decoded details, so a codec that skipped details or encoded attributes would fail it. I moved CompressionCodec to tests/common.py for that, since importing tests.test_plugin from a Workflow module pulls botocore into the sandbox. _configure now raises on an unknown target.

The section documented a change that shipped in 0.1.0, not this fix, and
belongs in the release notes.
…codedAttributes

StrandsPlugin matched only the exact SDK class, so a caller subclass got
plain StrandsFailureConverter and its failure messages were stored as
written. Any subclass now gets StrandsFailureConverterWithEncodedAttributes,
which also subclasses DefaultFailureConverterWithEncodedAttributes. The
plugin still never keeps a caller's from_failure.

The activity-tool interrupt test also runs with a codec and encoded
failure attributes, and replays with the same converter.
@campoy campoy changed the title Keep encoded failure attributes in StrandsPlugin and document the codec upgrade Keep encoded failure attributes in StrandsPlugin Oct 30, 2026
@campoy

campoy commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Replies are in each thread; the code changes are in dc2f54c and the README removal in 8ac59bc.

On the items outside the diff:

@DABH DABH left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Second round, at head dc2f54c (two commits over 891685d: 8ac59bc removes the README section, dc2f54c changes the code and tests). On a worktree at the head: make lint clean and make test 59 of 59 on Python 3.14; the CI matrix is green, the branch merges cleanly with current main, and no strands_agents commit landed on main after that CI run. Probes against temporalio 1.34.0 at the head: converter selection for the SDK class, a subclass that overrides to_failure, and a functools.partial (the first two get StrandsFailureConverterWithEncodedAttributes, the partial gets plain StrandsFailureConverter as the docstring says); isinstance(StrandsFailureConverterWithEncodedAttributes(), DefaultFailureConverterWithEncodedAttributes) is True and the MRO runs the SDK class's __init__; and mixed-version decoding through DataConverter.encode_failure/decode_failure with the test codec in both directions: an InterruptException written by the encoded variant is stored as Encoded failure and read by plain StrandsFailureConverter as interrupt:approval, type StrandsInterrupt, non-retryable, details intact, and the reverse also round-trips, so a rolling upgrade from 0.1.0 is safe on the failure path. Refuted: a History-only change reaching commands. Every Workflow-side reader (_temporal_activity_tool.py, _temporal_sandbox.py, and main's _temporal_model.py overflow rebuild) reads the decoded ApplicationError, never the raw Failure.message. Not repeated: the dev-server cross-version replay in the PR body; the converter probe covers the same decode path.

Severity: P1 = wrong result or stuck run in realistic use; P2 = correctness or robustness in an edge case; P3 = docs, tests, cleanup.

Previous rounds. Addressed: the exact-class check is now issubclass, and test_caller_encoded_failure_converter_is_kept covers a subclass; the unreachable tuple entry is gone and the second-pass test comment now claims only what it checks; the class subclasses both converters, so isinstance guards keyed on the SDK class match; the docstring now gives the from_failure reason for Replayer symmetry; test_activity_tool_interrupt runs with a codec and DefaultFailureConverterWithEncodedAttributes, asserts Encoded failure in History and replays with that converter, and _configure raises on an unknown target. Accepted declines: no warning when a caller's class is replaced, and the own-__init__ and partial forms staying plaintext (rare, documented in the docstring). Superseded: the three README items (rolling upgrade and rollback, 'dropped them', the Upgrading section's scope) went with the section at the maintainer's request and are folded into the release-note draft in that thread; see the PR-body item below for how that draft reaches users. The non-default payload converter gap is pre-existing, unchanged here and filed as #299. The dropped traceback and cause, and Encoded failure reaching the model under a misconfigured codec, remain pre-existing follow-ups; the StrandsWorkflowError comment is fixed on main, and the merge keeps main's text.

Approving: the earlier subclass-encoding item is fixed, the other earlier P1 is pre-existing and tracked in #299, CI is green, and the two remaining items are non-blocking P3s.

Outside the diff

  • P3 (PR body, non-blocking): Closes #153 will close an issue whose second half, the codec note for users moving off temporalio.contrib.strands, now exists only as a draft in a review thread, and the generated release notes will not carry it. release_tool.py release-notes reads commit subjects only (git log --format=%h%x1f%s, scripts/release/release_tool.py:456), so the squash commit's body (this PR description) does not reach the notes either; the next python/strands_agents release will list only this PR's title. The History change for DefaultFailureConverterWithEncodedAttributes users (Encoded failure in place of the message) depends on the same channel. Either write Part of #153 and keep the issue open until a release draft includes the note, or open a small tracking issue for the next strands_agents release notes and link it here.
  • Related PRs: #184 conflicts textually with this branch in tests/common.py (both add imports and a helper at the end of the file; trivial to resolve); #184's MCPToolNameConflictError in _TERMINAL_EXCEPTIONS is inherited by the encoded variant with no change. #190 and #192 merge cleanly with this branch.

Comment thread python/strands_agents/README.md Outdated
```

This relies on the plugin's failure converter, which is installed via the client's data converter. **Attach `StrandsPlugin` to the client** (not just the worker) for activity-tool interrupts to work — workers built from that client pick up the plugin automatically.
This relies on the plugin's failure converter, which is installed via the client's data converter. **Attach `StrandsPlugin` to the client** (not just the worker) for activity-tool interrupts to work — workers built from that client pick up the plugin automatically. If your data converter uses the default payload converter with `DefaultFailureConverterWithEncodedAttributes` or a subclass of it, the plugin's failure converter also encodes failure messages and stack traces with your codec.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (non-blocking): the new sentence names subclasses, but the plugin keeps only the encoding, not the subclass itself. strands_failure_converter_class swaps any DefaultFailureConverterWithEncodedAttributes subclass for StrandsFailureConverterWithEncodedAttributes, so the subclass's own to_failure and from_failure never run. Probe at the head: class Sub(DefaultFailureConverterWithEncodedAttributes) whose to_failure calls super() and then sets failure.source = 'redacted-by-caller'; after StrandsPlugin(models={}).configure_client(...) the active converter is StrandsFailureConverterWithEncodedAttributes, the message is Encoded failure, and source is ''. The replacement is deliberate (dropping from_failure protects replay) and already applied to every other class, but nothing user-facing says so, and someone who subclassed to add behaviour will read this sentence as their class being kept. Suggested wording: "If your data converter uses the default payload converter with DefaultFailureConverterWithEncodedAttributes or a subclass of it, the plugin replaces it with its own failure converter but keeps the encoding, so failure messages and stack traces still go through your codec. Methods your class overrides are not used." The docstring at _failure_converter.py:99 could say the same for to_failure; today it mentions only from_failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied your wording in 4e73b0a. The docstring now also says the caller's own to_failure and from_failure never run.

The README and the docstring now say that only the encoding is kept: a
subclass's own to_failure and from_failure never run.
@campoy

campoy commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. On the PR-body item: the description now says Part of #153 instead of Closes #153. I left a comment on #153 with the two notes the next strands_agents release notes need (the codec upgrade note and the Encoded failure History change), so it stays open until a release carries them. The README wording from your inline comment is in 4e73b0a.

@campoy
campoy merged commit 1a5f4e6 into main Oct 30, 2026
27 checks passed
@campoy
campoy deleted the fix/strands-python-caller-codec-dropped branch October 30, 2026 22:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants