Skip to content

fix(sdk): resolve async response secrets outside the event loop - #4967

Open
neubig wants to merge 6 commits into
mainfrom
fix/async-secret-masking
Open

neubig wants to merge 6 commits into
mainfrom
fix/async-secret-masking

Conversation

@neubig

@neubig neubig commented Sep 12, 2026

Copy link
Copy Markdown
Member

HUMAN:

User request, quoted verbatim from @neubig: “OK, create the new PRs and stack 3403 on top of the conversation-scoped API one.”

AGENT:

Why

Uncached secret resolution may perform blocking callbacks during async agent response processing; avoid blocking the event loop while holding conversation state.

Summary

  • Run non-tool response secret masking in a worker thread while releasing the conversation-state lock around the I/O boundary.
  • Preserve sync/async tool-event parity by classifying tool responses before offloading masking.
  • Stack this focused behavior change on ci: check Python API breakage on release PRs #4971, which moves Python API breakage checks to release PRs and removes the accepted-removal exception list.

Issue Number

Split from existing PR #3403 at the author’s request; no separate issue was created.

How to Test

  • Edited-file pre-commit hooks passed, including Ruff and Pyright.
  • uv run pytest -q tests/sdk/agent/test_async_secret_masking.py — 2 passed using real agent execution paths.

Video/Screenshots

Not applicable; this changes asynchronous SDK execution behavior.

Design Doc

Not needed for this focused bug fix.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

This is the top PR in the official two-PR stack with #4971.

This PR description was updated by an AI agent (OpenHands) on behalf of @neubig.

Live Agent Canvas evidence

Live isolated Canvas comparison: the same provider reply took 31.014 seconds before and 0.957 seconds after. Before, the saved-secret loopback froze Canvas and timed out; after, the reply was masked and Settings remained responsive.

Before GIF · After GIF · Report and reproduction · Measured evidence. These immutable links retain the artifacts if .pr/ is later cleaned up.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:6d4fe23-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-6d4fe23-python \
  ghcr.io/openhands/agent-server:6d4fe23-python

All tags pushed for this build

ghcr.io/openhands/agent-server:6d4fe23-golang-amd64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-golang-amd64
ghcr.io/openhands/agent-server:fix-async-secret-masking-golang-amd64
ghcr.io/openhands/agent-server:6d4fe23-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:6d4fe23-golang-arm64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-golang-arm64
ghcr.io/openhands/agent-server:fix-async-secret-masking-golang-arm64
ghcr.io/openhands/agent-server:6d4fe23-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:6d4fe23-java-amd64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-java-amd64
ghcr.io/openhands/agent-server:fix-async-secret-masking-java-amd64
ghcr.io/openhands/agent-server:6d4fe23-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:6d4fe23-java-arm64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-java-arm64
ghcr.io/openhands/agent-server:fix-async-secret-masking-java-arm64
ghcr.io/openhands/agent-server:6d4fe23-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:6d4fe23-python-amd64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-python-amd64
ghcr.io/openhands/agent-server:fix-async-secret-masking-python-amd64
ghcr.io/openhands/agent-server:6d4fe23-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:6d4fe23-python-arm64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-python-arm64
ghcr.io/openhands/agent-server:fix-async-secret-masking-python-arm64
ghcr.io/openhands/agent-server:6d4fe23-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:6d4fe23-python-slim-amd64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-python-slim-amd64
ghcr.io/openhands/agent-server:fix-async-secret-masking-python-slim-amd64
ghcr.io/openhands/agent-server:6d4fe23-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-amd64
ghcr.io/openhands/agent-server:6d4fe23-python-slim-arm64
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-python-slim-arm64
ghcr.io/openhands/agent-server:fix-async-secret-masking-python-slim-arm64
ghcr.io/openhands/agent-server:6d4fe23-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-arm64
ghcr.io/openhands/agent-server:6d4fe23-golang
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-golang
ghcr.io/openhands/agent-server:fix-async-secret-masking-golang
ghcr.io/openhands/agent-server:6d4fe23-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:6d4fe23-java
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-java
ghcr.io/openhands/agent-server:fix-async-secret-masking-java
ghcr.io/openhands/agent-server:6d4fe23-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:6d4fe23-python-slim
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-python-slim
ghcr.io/openhands/agent-server:fix-async-secret-masking-python-slim
ghcr.io/openhands/agent-server:6d4fe23-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim
ghcr.io/openhands/agent-server:6d4fe23-python
ghcr.io/openhands/agent-server:6d4fe233ed9d93dd89d529ffc301362290ef5ee1-python
ghcr.io/openhands/agent-server:fix-async-secret-masking-python
ghcr.io/openhands/agent-server:6d4fe23-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 6d4fe23-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 6d4fe23-python-amd64) are also available if needed

@all-hands-bot

Copy link
Copy Markdown
Collaborator

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/agent
   agent.py4583692%110, 292, 431, 435, 738–739, 746–747, 866, 870–871, 876–878, 880, 899–900, 906, 911, 946–947, 954–955, 1000, 1007, 1021–1022, 1054–1055, 1307, 1392–1393, 1395, 1425, 1474, 1481
   response_dispatch.py88693%156, 210, 237, 330–332
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py10988792%168, 346, 412, 679–680, 722, 764, 1092, 1116–1117, 1122, 1137, 1139, 1243, 1259, 1307, 1333, 1413, 1417–1423, 1488, 1508–1510, 1559, 1578–1580, 1891–1892, 2130, 2133–2134, 2159, 2190, 2196, 2331, 2335, 2338, 2341, 2345–2346, 2350–2351, 2354, 2361, 2381, 2385, 2388, 2407, 2459, 2462, 2494, 2501–2502, 2510, 2514–2516, 2523, 2560–2566, 2569, 2572, 2579, 2680, 2685, 2805–2806, 2824–2825, 2858, 3064, 3068, 3138, 3145–3146
TOTAL423431178172% 

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: e718c3b2762a5fcef3981d0474029839d88d1184
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/f6499ab4-d1dc-4f03-a1de-dca5c567d5c4

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

The PR has two independent changes:

  1. agent.py: Moves async response secret masking into a worker thread (asyncio.to_thread) while releasing the conversation state lock, preventing event-loop blocking when uncached LookupSecret sources need network I/O. This reuses the existing _released_state_lock_during_io pattern already used for the LLM completion call.

  2. check_sdk_api_breakage.py: Converts _ACCEPTED_REMOVED_MEMBERS from a frozenset to a dict with per-member justification strings, and adds LLM.modify_params (removed in #4954) as an accepted removal. Clean refactor, well-tested.

Behavioral asymmetry (eval flag)

The async path now masks the message before classify_response and passes the already-masked message to the response handlers. The sync path (_step) does not mask before classify_response — masking happens later in _emit_message_event.

This means for tool-call responses in the async path, the "thought" content stored in ActionEvent is now masked (<secret-hidden>), while in the sync path it remains unmasked. This is arguably more correct (secrets shouldn't appear in action event thoughts), but it is a behavioral difference between sync and async execution paths.

The agent's actual decision-making (tool calls, arguments) is unaffected since _mask_secrets only touches content and reasoning_content, not tool_calls. However, since this changes what gets recorded in events, I'm flagging it for a human maintainer to evaluate whether lightweight evals are needed before merge.

Test quality

The new test is solid — it uses a real loopback HTTP server, a real TestLLM, and exercises the full async agent path to verify masking end-to-end. Not just mocks.

CI script change

The frozensetdict conversion is clean. The per-member justification strings are a nice improvement over the previous hardcoded message. The test verifies the exact acceptance behavior including negative cases.

[RISK ASSESSMENT]

  • Overall PR: 🟡 MEDIUM — The agent.py change introduces a behavioral asymmetry between sync and async paths (masked vs unmasked thought content in tool-call action events). The change is unlikely to affect benchmark performance since tool call arguments are untouched, but it does change what gets persisted in events. No eval evidence is provided. A human maintainer should decide whether lightweight evals are warranted.

VERDICT: Needs human maintainer judgment on eval impact before merge.

KEY INSIGHT: The async path now masks earlier than sync, creating a subtle asymmetry in action event thought content — functionally harmless but worth a deliberate eval decision.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

Comment thread openhands-sdk/openhands/sdk/agent/agent.py Outdated
@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot September 12, 2026 00:53
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: a8518813a89b8d37c48881784d5f028764829a78
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/7bebd25a-7d9c-4e65-920f-98bf2c1359cc

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Review: fix(sdk): resolve async response secrets outside the event loop

Summary

This PR makes two changes:

  1. agent.py: For non-tool-call responses in the async path (_astep), secret masking now runs in a worker thread via asyncio.to_thread while the conversation state lock is released via _released_state_lock_during_io(). This prevents blocking the event loop when uncached LookupSecret sources need to resolve via HTTP.

  2. check_sdk_api_breakage.py: Converts _ACCEPTED_REMOVED_MEMBERS from a frozenset to a dict with per-member justification strings, and adds LLM.modify_params as an accepted removal (removed upstream in PR #4954 after its deprecation runway).

Analysis

agent.py change: The approach is sound. _released_state_lock_during_io() is already used for the LLM network call at line 937, and the same pattern is correctly applied here. The SecretRegistry._exported_values dict is protected by its own RLock, so concurrent access from the worker thread while the state lock is released is safe. After the first masking call caches resolved secret values, the subsequent synchronous _mask_secrets call inside _emit_message_event (response_dispatch.py:305) uses the cache and doesn't trigger blocking I/O. Text masking is idempotent, so the double-masking is a minor inefficiency, not a correctness issue.

check_sdk_api_breakage.py change: Clean refactor. The dict lookup preserves the existing _is_accepted_removed_member behavior (in check), and the per-member justification is surfaced in the diagnostic notice. The new test correctly verifies that only modify_params (not drop_params) is accepted, and that the diagnostic references PR #4954.

Tests: Both new tests exercise real code paths — the loopback server test validates actual HTTP resolution + masking through the real agent execution path, and the parity test confirms sync/async behavioral equivalence for tool thoughts.

Risk Assessment

🟡 MEDIUM — This PR changes agent execution behavior (the async response masking path). Per the repo review policy, PRs that change agent behavior should not be approved without eval evidence. The PR description does not include a link to the eval monitor showing a completed benchmark run, and no human maintainer has confirmed eval results.

Recommendation: Flagging for human maintainer review. Please run lightweight evals to confirm no regression in agent response handling before merging.

Verdict

No material bugs found. The code is correct, well-tested, and the approach is consistent with existing patterns. Leaving a COMMENT (not APPROVE) per the eval-risk policy for agent behavior changes.

@neubig
neubig force-pushed the fix/async-secret-masking branch from a851881 to 3786250 Compare September 12, 2026 01:42
@neubig
neubig changed the base branch from main to fix/remove-accepted-removed-members September 12, 2026 01:44
@neubig
neubig added this pull request to stack #4972 September 12, 2026 01:44
@all-hands-bot

all-hands-bot commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@neubig
neubig force-pushed the fix/remove-accepted-removed-members branch from 1eb2747 to 846a78f Compare September 12, 2026 02:25
@neubig
neubig force-pushed the fix/async-secret-masking branch from 3786250 to 1a84afa Compare September 12, 2026 02:26
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 1a84afa61b007d9d20fc840e268a68345fe79fe8
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/33e36d8f-5773-4102-8912-a754c0d855c7

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR moves non-tool-call response secret masking in _astep off the event loop: it runs _mask_secrets in a worker thread (asyncio.to_thread) while releasing the conversation state lock via _released_state_lock_during_io(). This avoids blocking the event loop when uncached LookupSecret resolution performs network I/O (potentially calling back into the same agent-server). Tool-call responses are correctly excluded, preserving sync/async parity for tool thought handling.

The tests exercise real agent execution paths (loopback HTTP server, TestLLM, real Conversation.arun/run) rather than mocks — good.

Findings

Redundant double-masking for non-tool-call responses (minor, non-blocking)

For CONTENT and REASONING_ONLY | EMPTY responses, _astep now masks the message at line 1029, then passes the already-masked message into _handle_content_response / _handle_no_content_response, which call _emit_message_event -> _mask_secrets a second time. The second call is idempotent and cheap (secrets are already cached from the first call, so no I/O), so this is not a correctness bug — just a redundant pass. Not worth blocking on, but worth being aware of.

Eval/benchmark risk — flagging for human maintainer review

This PR changes the async agent execution path (secret masking timing and thread context). Per the repo review policy, changes to agent behavior should be validated with lightweight evals before merging. No eval monitor link or human-confirmed benchmark results are present in the PR description or comments. I am leaving a COMMENT rather than APPROVE so a human maintainer can decide whether to run evals before merging.

Risk Assessment

MEDIUM — The change is well-scoped and the approach is sound (releasing the lock for I/O, offloading to a worker thread). The primary risk is behavioral: it alters the async execution path timing for secret resolution, which could plausibly affect benchmark performance. Recommend a human maintainer run lightweight evals before merging.

Verdict: Worth merging after eval validation. The code is correct and the tests are solid.

Comment thread openhands-sdk/openhands/sdk/agent/agent.py Outdated
@neubig

neubig commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

All review threads are now resolved, including the non-blocking double-masking observation. The focused regression tests and full CI pass, and the official stack is repaired. Please approve this top PR if the async secret-masking change looks good. I will re-request review now.\n\n_This comment was created by an AI agent (OpenHands) on behalf of @neubig._

@all-hands-bot
all-hands-bot removed their request for review September 12, 2026 03:27
Base automatically changed from fix/remove-accepted-removed-members to main September 12, 2026 03:46
openhands+astra requested by @neubig

Created by an AI agent (OpenHands) on behalf of @neubig.

Co-authored-by: openhands <openhands@all-hands.dev>
openhands+astra requested by @neubig; created by an AI agent (OpenHands) on behalf of @neubig.

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig force-pushed the fix/async-secret-masking branch from 1a84afa to 8cef037 Compare September 12, 2026 03:46

@enyst enyst left a comment

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.

Review from Astra:

P2 — The final masking pass can still block the event loop and leave a secret unmasked. The new code⁠ masks in a worker thread, but _emit_message_event⁠ subsequently calls the same resolving masker synchronously.

If update_secrets() adds a LookupSecret while the first lookup is pending, the first pass’s snapshot misses it. The final pass discovers it and performs its HTTP request on the event loop.

I reproduced this with real TestLLM, Conversation.arun(), and loopback HTTP servers:

  • The event loop stalled for 30.03 seconds.
  • After the lookup timed out, the emitted response contained <secret-hidden> beta-dummy-credential-456: the newly registered dummy credential remained unmasked.

That contradicts the resolved review thread’s assumption that the second pass necessarily performs no external I/O. Async emission should never invoke an I/O-capable masker synchronously.

@neubig
neubig removed this pull request from stack #4972 September 12, 2026 19:09
@neubig
neubig marked this pull request as draft September 12, 2026 21:26
@neubig

neubig commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Addressed the asynchronous lookup race in 24679a6. Async response emission no longer calls an I/O-capable masker synchronously. It resolves in a worker thread, rechecks the registry under the conversation lock, and repeats when another secret was registered. Secret updates now acquire the same state lock.

A real TestLLM/Conversation.arun regression chains three loopback lookups, registering the next secret during each previous lookup. The run finishes within the five-second guard and masks all three values. All three focused tests pass in 1.65 seconds; type checking and pre-commit checks pass. Requested all-hands-bot review on the new head.

all-hands-bot
all-hands-bot previously approved these changes Sep 12, 2026

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Taste Rating: 🟢 Good taste

The PR cleanly offloads blocking secret resolution (e.g. LookupSecret HTTP calls) from the event loop to a worker thread during async response processing, while releasing the conversation state lock around the I/O boundary.

Key design observations:

  1. Retry loop for concurrent secret registration — The while True loop in _astep snapshots secret_sources before masking, releases the lock for the asyncio.to_thread call, then re-acquires the lock and checks if the registry is stable. If update_secrets registered a new source during the lookup, the loop re-masks. This correctly handles the edge case where a LookupSecret callback registers additional secrets. Both reads of secret_sources happen under the lock, so the comparison is race-free.

  2. mask_secrets=False flag — Passing this to _handle_content_response / _handle_no_content_response in the async path prevents a redundant second masking pass in _emit_message_event, since masking already happened in the worker thread. The sync _step path still uses the default mask_secrets=True, preserving sync/async behavioral parity (both produce a fully masked message).

  3. update_secrets lock acquisition — Wrapping self._state.secret_registry.update_secrets(secrets) in with self._state: is the correct fix. Before this PR, update_secrets modified secret_sources without the state lock — safe in the sync-only world but now necessary since the async path releases the lock during masking, and a worker-thread lookup callback can call update_secrets concurrently.

  4. Tool-call responses are not pre-masked — The if response_type is not LLMResponseType.TOOL_CALLS: guard ensures tool-call thoughts go through the existing shared path (_get_action_event), matching sync behavior. The parity test verifies this.

  5. Tests are solid — All three tests use real loopback HTTP servers and real agent execution paths (TestLLM + Conversation.arun), not mocks. The chained-lookup test directly exercises the retry loop by registering new LookupSecret sources during each HTTP response.

Prior review concerns (behavioral asymmetry from pre-masking before classification, redundant double-masking) are resolved in this commit: classification happens first, and mask_secrets=False skips the second pass.

Risk Assessment: 🟢 LOW

This PR changes the timing and mechanism of secret masking (worker thread vs event loop) but does not change agent decision-making, tool calling, prompt templates, or loop logic. The LLM sees the same message; only the emitted MessageEvent has secrets masked, which was already the case before. No eval/benchmark impact is expected.

Verdict: ✅ Worth merging

Key Insight: The retry-until-stable pattern is the right primitive for reconciling unlocked I/O with a mutable secret registry — it avoids per-method locking on the registry while guaranteeing the final emitted message is fully masked.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@neubig
neubig marked this pull request as ready for review September 12, 2026 22:09
@all-hands-bot

Copy link
Copy Markdown
Collaborator

Code review for PR #4967 (fix(sdk): resolve async response secrets outside the event loop) has been posted to GitHub as an APPROVE review.

Summary of review findings:

  • The PR cleanly offloads blocking secret resolution (e.g., LookupSecret HTTP calls) from the event loop to a worker thread via asyncio.to_thread, while releasing the conversation state lock around the I/O boundary
  • The while True retry loop correctly handles concurrent secret registration during lookups — both reads of secret_sources happen under the lock, making the comparison race-free
  • The mask_secrets=False flag prevents redundant double-masking in _emit_message_event while preserving sync/async behavioral parity
  • The update_secrets lock acquisition (with self._state:) fixes a pre-existing concurrency gap that became relevant with the async path
  • Tool-call responses are correctly excluded from pre-masking, matching sync behavior
  • Tests are solid — all three use real loopback HTTP servers and real agent execution paths, not mocks
  • Prior review concerns from the bot and enyst are resolved in this commit

Risk Assessment: 🟢 LOW — changes masking timing/mechanism but not agent decision-making, tool calling, or loop logic. No eval/benchmark impact expected.

Review posted: #4967 (review)

This comment was posted by an AI agent (OpenHands).

@all-hands-bot
all-hands-bot dismissed their stale review September 12, 2026 22:41

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot September 13, 2026 05:55
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: bcd72405db0be3c7d8823920eef49870ce9d98d4
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/8e78e4e0-d10d-4a8d-817f-a80fa058d8c1

This comment was posted by an AI agent (OpenHands).

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: bcd72405db0be3c7d8823920eef49870ce9d98d4
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/d5ceb04a-b78a-438b-ab98-a2410fc32113

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR offloads non-tool-call response secret masking in _astep to a worker thread via asyncio.to_thread, releasing the conversation state lock during the I/O boundary. A retry-until-stable loop handles the case where a LookupSecret callback registers new sources during the lookup. The mask_secrets=False flag passed to the response handlers prevents the redundant synchronous second masking pass in _emit_message_event. update_secrets is now properly wrapped in with self._state:.

Analysis

Retry loop (agent.py:1026-1037): Correct. Snapshots secret_sources under the lock, releases the lock for the asyncio.to_thread masking call, re-acquires the lock and compares. If new sources were registered during the lookup, re-masks. Both reads of secret_sources happen under the state lock, so the comparison is race-free.

mask_secrets=False (response_dispatch.py:324-326): Correctly prevents the synchronous second masking pass that the prior P2 review (enyst, commit 8cef037) flagged as a blocking-I/O risk on the event loop. The retry loop ensures the message is fully masked before being passed to the handlers.

update_secrets lock (local_conversation.py:2772-2773): Correct fix. Before this PR, update_secrets modified secret_sources without the state lock — safe in a sync-only world but now necessary since the async path releases the lock and the retry loop relies on consistent lock-protected snapshots.

Tool-call exclusion: The if response_type is not LLMResponseType.TOOL_CALLS guard correctly preserves sync/async parity for tool thoughts. The parity test verifies this.

Tests: All three tests use real loopback HTTP servers, TestLLM, and real Conversation.arun/run — not mocks. The third test directly exercises the retry loop by chaining LookupSecret registrations during HTTP responses.

Risk Assessment

🟡 MEDIUM — The code is correct and well-tested. The prior P2 concern (synchronous blocking on the event loop) is fully resolved. However, this changes the async agent execution path (masking timing and thread context). Per the repo eval-risk policy, changes to agent behavior should be validated with lightweight evals before merging. No eval monitor link or human-confirmed benchmark results are present in the PR description or comments.

Recommendation: Flagging for human maintainer review. The change is unlikely to affect benchmark performance since it only alters masking timing, not agent decision-making — but a human maintainer should confirm.

Verdict: No material bugs found. Worth merging after eval validation.

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

The PR moves async response secret masking into a worker thread (asyncio.to_thread) while releasing the conversation state lock, preventing event-loop blocking when uncached LookupSecret sources require network I/O. It also fixes update_secrets to correctly hold the state lock when mutating the secret registry, aligning with the repo concurrency convention.

The design is sound:

  • Registry stability loop: The while True loop takes a snapshot of secret_sources under the lock, releases the lock for the masking I/O, then reacquires and checks if new sources were registered during the await. This correctly handles the case where a LookupSecret callback registers additional secrets (demonstrated by the third test).
  • Sync/async parity for tool calls: TOOL_CALLS responses are explicitly excluded from pre-masking (if response_type is not LLMResponseType.TOOL_CALLS), so both paths pass unmasked message content to the tool-call handlers. The mask_secrets=False flag passed to content/no-content handlers prevents double-masking. The second test verifies sync/async thought parity.
  • Concurrency safety: mask_secrets_in_output takes list(self.secret_sources) (snapshot), and _exported_values is protected by its own RLock. update_secrets only adds entries (never removes), so concurrent reads during the lock-released window can miss new secrets but not crash — the stability loop catches this.
  • update_secrets locking fix: Correctly wraps the registry mutation in with self._state:, matching the ~13 existing call sites that protect state access.

Test quality

Tests use real loopback HTTP servers, real TestLLM, and exercise full async agent paths — not mocks. The third test (secrets registered during lookup) directly validates the while True stability loop with a cascading lookup pattern.

Eval risk

This touches async agent execution paths (when/where masking happens), but the masking result is identical — secrets end up masked in MessageEvent either way. Agent decision-making is unaffected: tool call arguments are never masked, and classify_response runs on the unmasked message in both paths. The update_secrets locking change could slightly affect concurrency timing but not correctness. Flagging for a human maintainer to decide whether lightweight evals are warranted.

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW — The change relocates existing masking logic to a thread and fixes a locking bug. The end-to-end masking behavior is preserved. The while True loop has no max-iteration guard, but unbounded looping would require a pathological recursive secret-registration pattern that is not realistic.

VERDICT: Worth merging. Core logic is sound, concurrency approach is well-designed, and tests cover the key scenarios including the dynamic-registration edge case.

KEY INSIGHT: The registry stability loop elegantly handles the self-referential case where resolving a secret triggers registration of new secrets, without requiring a separate notification mechanism.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

⚠️ OpenHands gave up on this review for commit bcd72405db0b after 2h (last state: active).

This comment was posted by an AI agent (OpenHands).

@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor
  ✅ **PR Artifacts Cleaned Up**

  The `.pr/` directory is no longer present.

@neubig

neubig commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Please review the current head and its linked live evidence. If there are no remaining actionable findings, please submit an APPROVE review rather than a COMMENT-only review. If changes are still needed, identify them explicitly. This request is for code-review approval; it does not waive any separate human-note, CI, or dependency requirements.

@neubig
neubig requested review from all-hands-bot and removed request for all-hands-bot September 13, 2026 18:06

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Review: fix(sdk): resolve async response secrets outside the event loop

Taste Rating: 🟢 Good taste

The change moves non-tool-response secret masking to a worker thread in the async path (_astep), releasing the conversation state lock around the I/O boundary so loopback LookupSecret HTTP calls don't deadlock the event loop. The convergence loop correctly handles cascading lookups (where resolving one secret registers another). The update_secrets lock fix is a legitimate thread-safety improvement — the previous code accessed self._state.secret_registry without holding the state lock.

Analysis

  • Concurrency design: The while True convergence loop snapshots secret_sources under the lock, releases the lock for the to_thread masking call, then re-checks under the lock. Snapshot and comparison are both under the lock — no TOCTOU. The _released_state_lock_during_io context manager is deadlock-safe (no-op if the thread doesn't own the lock).

  • Sync/async parity: Tool-call responses skip pre-masking in both paths (tool masking happens separately via mask_secrets_in_model). Non-tool responses: sync path masks inline in _emit_message_event (default mask_secrets=True), async path pre-masks and passes mask_secrets=False. Equivalent behavior. Test test_async_tool_thought_matches_sync_behavior verifies this.

  • Thread safety of _mask_secrets in worker thread: mask_secrets_in_output uses list(self.secret_sources) (key snapshot) and _exported_values_lock for the values. CPython GIL makes dict reads safe against concurrent update_secrets writes. Acceptable.

  • update_secrets lock fix: Previously unlocked access to self._state.secret_registry; now properly wrapped in with self._state:. This aligns with the repo convention that all self._state.* accesses must hold the lock.

No material issues found

No bugs, security concerns, or design flaws identified. The change is focused and proportional to the problem (31s → <1s Canvas stall from loopback secret deadlock). Tests cover the three key scenarios: loopback resolution, sync/async parity, and cascading registration during lookup.

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW
    • Changes async execution mechanics for secret masking only; no change to agent reasoning, prompts, tool calling, or planning logic.
    • No version bumps, no dependency changes, no breaking API changes.

VERDICT: ✅ Worth merging

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

The PR moves non-tool-call response secret masking in _astep off the event loop: _mask_secrets runs in a worker thread (asyncio.to_thread) while the conversation state lock is released via _released_state_lock_during_io(). A retry-until-stable loop handles the case where a LookupSecret callback registers new sources during the lookup. The mask_secrets=False flag passed to _handle_content_response / _handle_no_content_response prevents the redundant synchronous second masking pass in _emit_message_event — this resolves the P2 blocking-I/O concern raised by @enyst. update_secrets is now correctly wrapped in with self._state:.

Analysis

Retry loop (agent.py:1026–1037): Correct. Snapshots secret_sources under the lock, releases the lock for the asyncio.to_thread masking call, reacquires the lock and compares. Both reads of secret_sources happen under the state lock, so the comparison is race-free. mask_secrets_in_output iterates list(self.secret_sources) (snapshot), and _exported_values is protected by its own RLock, so concurrent update_secrets calls during the lock-released window are safe.

mask_secrets=False (response_dispatch.py:257, 282, 311): Correctly prevents the synchronous second masking pass that the prior P2 review flagged. The retry loop ensures the message is fully masked before being passed to the handlers.

update_secrets lock (local_conversation.py:2772–2773): Correct fix. FIFOLock is reentrant per thread, so sync-path calls from within run() are safe. Async-path calls from callback threads can acquire the released lock.

Sync/async parity: Tool-call responses are excluded from pre-masking (if response_type is not LLMResponseType.TOOL_CALLS), preserving parity. The second test verifies this. Both paths produce fully masked messages for non-tool-call responses.

Tests: All three use real loopback HTTP servers, TestLLM, and full agent execution paths — not mocks. The third test directly exercises the retry loop with cascading LookupSecret registrations.

No material bugs found. The code is unchanged since the last review at bcd7240; the only new commits are docs evidence and automated .pr/ cleanup.

Risk Assessment

🟡 MEDIUM — The code is correct and well-tested. However, this changes the async agent execution path (masking timing and thread context). Per the repo eval-risk policy, changes to agent behavior should be validated with lightweight evals before merging. No eval monitor link or human-confirmed benchmark results are present. The change is unlikely to affect benchmark performance since it only alters masking timing, not agent decision-making — but a human maintainer should confirm.

Verdict: No material findings. Worth merging after eval validation.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

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.

4 participants