Skip to content

feat: add opaque tool result annotations - #575

Draft
bbednarski9 wants to merge 1 commit into
NVIDIA:mainfrom
bbednarski9:feat/tool-result-annotations-445
Draft

feat: add opaque tool result annotations#575
bbednarski9 wants to merge 1 commit into
NVIDIA:mainfrom
bbednarski9:feat/tool-result-annotations-445

Conversation

@bbednarski9

@bbednarski9 bbednarski9 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Base

Rebased directly onto main after #570 and #571 merged. This PR no longer depends on the closed #572 branch.

Summary

This is a breaking tool API change. It replaces the parallel tool-result APIs previously proposed in this PR with one canonical, protocol-neutral contract used by every managed and manual tool completion:

ToolExecutionResult {
    result: Json,
    annotation: Option<Json>,
}

ToolExecutionInterceptOutcome {
    result: Json,
    annotation: Option<Json>,
    pending_marks: Vec<PendingMarkSpec>,
}
  • tool producers, execution continuations, managed execution, and manual completion use ToolExecutionResult
  • execution intercepts return the flat ToolExecutionInterceptOutcome
  • Rust, Python, Node.js, C/FFI, Go, native plugins, and Rust/Python worker SDKs expose the same contract
  • result annotations flow through the existing lifecycle sanitization and ATOF, ATIF, OpenTelemetry, and OpenInference exporters
  • pending marks remain Relay-owned and are not exposed through next

Ownership boundary

The harness owns tool dispatch, receipt, and the schema of result. Relay transports result and annotation but interprets neither. This is intentionally different from the normalized AnnotatedLLMRequest and AnnotatedLLMResponse APIs.

Relay does not introduce a canonical tool-result schema, another registry, a dispatcher, a service, or a sidecar. The existing post-tool-call intercept chain remains intact.

MCP

MCP calls use the same ToolExecutionResult; Relay has no MCP-specific result type.

  • the complete harness-visible CallToolResult remains opaque in result, including content, structuredContent, _meta, and isError
  • Relay annotation remains separate from MCP _meta
  • a valid CallToolResult with isError: true remains successful application-visible result data
  • JSON-RPC, transport, and dispatch failures continue through Relay's error path
  • Relay does not infer MCP identity or remote attributes from tool names

The existing nemo-relay mcp lifecycle/bootstrap behavior is unchanged.

Breaking compatibility gates

  • native ABI advances to v4; native manifests must declare compat.native_api = "2"
  • worker compatibility advances to grpc-v2
  • the protobuf service package remains nemo.relay.worker.v1 because the control-flow service layout is unchanged
  • ToolNext carries nemo.relay.ToolExecutionResult@1
  • execution responses carry nemo.relay.ToolExecutionInterceptOutcome@2
  • example manifests require Relay >=0.7

Older native binaries and grpc-v1 workers are rejected during activation. There are no deprecated aliases, dual callback registries, or public raw-result adapters.

Semantics

  • missing and JSON null annotations normalize to absence and are omitted during serialization
  • every intercept sees the downstream annotation and decides explicitly whether to preserve, replace, or remove it
  • repeated and concurrent next calls retain Relay-owned pending marks internally
  • end-event data comes only from ToolExecutionResult.result
  • the sanitized annotation is stored under category_profile.tool_result_annotation

Validation

  • cargo check --workspace --all-targets
  • Rust serialization, middleware, observability, native plugin, and worker SDK contract suites
  • C/FFI unit suite: 89 passed
  • C/FFI integration suite: 81 passed
  • Python: the 609-test full suite passed before the final producer-rejection
    case was added; both legacy-result rejection tests pass independently
    (610 current tests)
  • Node.js: the 341-test full suite passed before the final producer-rejection
    case was added; both legacy-result rejection tests pass independently
    (342 current tests)
  • focused Go tool, callback, and intercept suites
  • cargo fmt --all -- --check
  • git diff --check

Closes #445

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This change adds annotation-aware tool execution frames, shared legacy/frame interception chains, lifecycle and observability propagation, worker and native plugin transport support, and public C, Go, Node.js, and Python APIs with serialization and integration coverage.

Changes

Tool execution frame support

Layer / File(s) Summary
Frame contract and shared execution chain
crates/types/..., crates/core/src/api/...
Adds ToolExecutionFrame and ToolExecutionFrameOutcome, unifies legacy and frame intercepts in one priority chain, and propagates pending marks and annotations.
Lifecycle and observability
crates/core/src/api/tool.rs, crates/core/src/observability/*
Adds frame-aware execution/end APIs and exports opaque annotations through lifecycle events and telemetry attributes.
Native and worker transports
crates/ffi/*, crates/plugin/*, crates/worker*
Adds serialized frame callbacks, continuations, registration APIs, ABI-v3 wiring, worker RPC messages, and schema-validated outcomes.
Node.js and Python bindings
crates/node/*, crates/python/*, python/nemo_relay/*
Exposes frame types, lifecycle helpers, global and scope-local registration, plugin hooks, and sync/async callback adapters.
Validation and documentation
crates/*/tests/*, go/*_test.go, python/tests/*, docs/reference/*
Covers serialization, mixed chains, deregistration, continuation lifetime, plugin transports, annotations, and binding contracts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Relay
  participant Intercepts
  participant Plugin
  participant Observability
  Caller->>Relay: execute frame
  Relay->>Intercepts: run shared legacy/frame chain
  Intercepts->>Plugin: invoke registered frame middleware
  Plugin->>Intercepts: call downstream frame continuation
  Intercepts->>Relay: return frame outcome and pending marks
  Relay->>Observability: emit result and opaque annotation
  Relay->>Caller: return ToolExecutionFrame
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Title check ❌ Error The title follows the required format and describes the change, but the description identifies this as a breaking tool API change. Add the breaking-change marker before the colon, for example: feat!: add opaque tool result annotations.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The diff delivers the opaque frame annotation channel and shared registry/continuation behavior required by [#445], and aligns with the async C/Go foundation in [#572].
Out of Scope Changes check ✅ Passed The changes are focused on frame-aware tool execution, bindings, tests, and exporter propagation, with no clear unrelated feature work beyond supporting plumbing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed The description is detailed and covers scope, semantics, compatibility, validation, and the related issue, but omits the checklist and reviewer-start section.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the size:XXL PR is very large label Jul 28, 2026
@github-actions github-actions Bot added Feature a new feature lang:go PR changes/introduces Go code lang:js PR changes/introduces Javascript/Typescript code lang:python PR changes/introduces Python code lang:rust PR changes/introduces Rust code labels Jul 28, 2026
@bbednarski9
bbednarski9 force-pushed the feat/tool-result-annotations-445 branch from 1b1ebb7 to 6ea55f1 Compare July 28, 2026 19:45
@github-actions

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

License Diff

Compared against origin/wkk_relay-509-async-ffi-go.

Lockfile license changes

Lockfile License Changes

Rust

Added

  • None

Removed

  • None

Updated/Changed

  • None

Node

Added

  • None

Removed

  • None

Updated/Changed

  • None

Python

Added

  • None

Removed

  • None

Updated/Changed

  • None
Status output
[license-diff] selected languages: rust, node, python
[license-diff] generating current inventory
[license-diff] current: generating Rust inventory
[license-diff] current: Rust inventory complete (426 packages)
[license-diff] current: generating Node inventory
[license-diff] current: Node inventory complete (367 packages)
[license-diff] current: generating Python inventory
[license-diff] current: Python inventory complete (105 packages)
[license-diff] current inventory complete
[license-diff] checking out base ref origin/wkk_relay-509-async-ffi-go into a temporary worktree
[license-diff] base: generating Rust inventory
[license-diff] base: Rust inventory complete (426 packages)
[license-diff] base: generating Node inventory
[license-diff] base: Node inventory complete (367 packages)
[license-diff] base: generating Python inventory
[license-diff] base: Python inventory complete (105 packages)
[license-diff] base inventory complete
[license-diff] removing temporary base worktree
[license-diff] comparing inventories
[license-diff] rendering Markdown output
[license-diff] done

@willkill07 willkill07 added this to the 0.7 milestone Jul 28, 2026
@bbednarski9
bbednarski9 force-pushed the feat/tool-result-annotations-445 branch from 6ea55f1 to 78931ea Compare July 28, 2026 20:40
@bbednarski9
bbednarski9 changed the base branch from main to wkk_relay-509-async-ffi-go July 28, 2026 20:40
@bbednarski9
bbednarski9 force-pushed the feat/tool-result-annotations-445 branch from 78931ea to 09e7362 Compare July 28, 2026 20:47
@willkill07
willkill07 force-pushed the wkk_relay-509-async-ffi-go branch 11 times, most recently from 6b8b633 to 4002875 Compare July 28, 2026 23:32
@willkill07
willkill07 force-pushed the wkk_relay-509-async-ffi-go branch 6 times, most recently from 514769d to c4ce063 Compare July 29, 2026 03:44
@bbednarski9
bbednarski9 force-pushed the feat/tool-result-annotations-445 branch from 09e7362 to 80efe8f Compare July 30, 2026 21:48
@copy-pr-bot

copy-pr-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@bbednarski9
bbednarski9 changed the base branch from wkk_relay-509-async-ffi-go to main July 30, 2026 21:49
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9
bbednarski9 force-pushed the feat/tool-result-annotations-445 branch from 80efe8f to c9e8580 Compare July 30, 2026 21:51
@bbednarski9
bbednarski9 marked this pull request as ready for review July 30, 2026 21:54
@bbednarski9
bbednarski9 requested review from a team as code owners July 30, 2026 21:54
@bbednarski9

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/core/tests/integration/native_plugin_tests.rs`:
- Around line 420-423: Wrap the long indexing expression in the assertion for
tool_frame_end so it conforms to rustfmt’s 100-column formatting, matching the
equivalent assertion in middleware_tests.rs. Run cargo fmt --all and preserve
the assertion’s behavior.

In `@crates/ffi/src/callable.rs`:
- Around line 551-578: Document in the header comment for
NemoRelayToolExecFrameInterceptCb that next_ctx and the associated next_fn are
valid only during the callback invocation and must not be retained or called
after the callback returns. Match the existing lifetime-contract wording used by
other raw intercept bindings, without changing the trampoline implementation.
- Around line 449-472: Update wrap_tool_exec_frame_fn in
crates/ffi/src/callable.rs lines 449-472 to call clear_last_error() before cb,
bind json_result_from_ptr without immediately propagating its error, free
result_ptr, then apply ?. Apply the same clear-last-error and free-before-?
ordering to the second frame wrapper in crates/ffi/src/callable.rs lines
580-594.

In `@crates/node/src/api/mod.rs`:
- Around line 2270-2291: Restore ambient scope and publication-context wiring
for frame-aware tool lifecycle calls. In crates/node/src/api/mod.rs:2270-2291,
wrap tool_call_end_frame’s core call with with_effective_scope_stack; in
crates/node/src/api/mod.rs:2353-2409, use effective_scope_context and
with_publication_callback_context around tool_call_execute_frame. In
crates/python/src/py_api/mod.rs:672-693, wrap tool_call_end_frame with
with_python_publication_context; in crates/python/src/py_api/mod.rs:792-834,
capture the publication context and buffer and apply
with_task_nested_publication_buffer and with_task_publication_context, matching
each raw sibling’s behavior.

In `@crates/node/tests/tools_tests.mjs`:
- Around line 344-361: Extend the test after locating the manual end event to
also find the `node_sync_frame_tool` scope-end event and assert its
`category_profile.tool_result_annotation.producer` is `node-sync`. Keep the
existing `waitForSubscriberCallbacks` condition and manual-event assertion
unchanged while covering the promised annotation parity.

In `@crates/types/tests/serialization_tests.rs`:
- Around line 262-286: Extend the ToolExecutionFrameOutcome test coverage
alongside its existing round-trip test: deserialize an object with a frame but
omitted pending_marks and an unknown field, assert pending_marks defaults to
empty, and assert deserialization fails when frame is omitted. Use the existing
ToolExecutionFrameOutcome symbols and preserve the raw-outcome contract pattern.

In `@docs/reference/tool-execution-intercept-outcomes.mdx`:
- Around line 112-120: Update the binding contract section to document
frame-aware return shapes for Python, Rust, Go, and Node.js, including
ToolExecutionFrameOutcome and Node.js’s { frame, pendingMarks? } form. Name the
grpc-v1 frame envelope schema explicitly alongside the existing raw schema, and
clarify that frame.result and optional frame.annotation are preserved opaquely
across these bindings.

In `@go/nemo_relay/callbacks.go`:
- Line 678: Replace the direct json.Marshal call in the frame trampoline with
the shared jsonMarshal helper, preserving its existing error handling and
ensuring the test hook is used consistently with the other serialization paths.

In `@go/nemo_relay/tools_test.go`:
- Around line 263-291: Protect the retainedNext access in the intercept test
with the same synchronization mechanism used for the native callback, guarding
both the assignment inside the intercept closure and the subsequent nil check
and invocation. Ensure the lock establishes a race-detector-visible
synchronization edge between the callback thread and the test goroutine while
preserving the existing continuation behavior.

In `@python/plugin/src/nemo_relay_plugin/_api.py`:
- Around line 1191-1205: Update _push_registration and the registration paths
for TOOL_EXECUTION_INTERCEPT and TOOL_EXECUTION_FRAME_INTERCEPT so both surfaces
share one deduplication namespace for tool-execution names. Reject cross-surface
duplicates immediately with the existing local WorkerSdkError behavior, while
preserving separate deduplication for unrelated surfaces; apply the same
handling to register_tool_execution_frame_intercept and the corresponding legacy
registration flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 1b949d06-abd7-493a-823f-ef28957759af

📥 Commits

Reviewing files that changed from the base of the PR and between 85be560 and c9e8580.

📒 Files selected for processing (70)
  • crates/core/src/api/registry.rs
  • crates/core/src/api/runtime.rs
  • crates/core/src/api/runtime/callbacks.rs
  • crates/core/src/api/runtime/state.rs
  • crates/core/src/api/runtime/subscriber_dispatcher.rs
  • crates/core/src/api/tool.rs
  • crates/core/src/context/registries.rs
  • crates/core/src/observability/atif.rs
  • crates/core/src/observability/mod.rs
  • crates/core/src/observability/openinference.rs
  • crates/core/src/observability/otel.rs
  • crates/core/src/plugin.rs
  • crates/core/src/plugin/dynamic/native.rs
  • crates/core/src/plugin/dynamic/worker.rs
  • crates/core/tests/fixtures/native_plugin/src/lib.rs
  • crates/core/tests/fixtures/worker_plugin/src/main.rs
  • crates/core/tests/integration/middleware_tests.rs
  • crates/core/tests/integration/native_plugin_tests.rs
  • crates/core/tests/integration/worker_plugin_tests.rs
  • crates/core/tests/unit/atif_tests.rs
  • crates/core/tests/unit/observability/openinference_tests.rs
  • crates/core/tests/unit/observability/otel_tests.rs
  • crates/ffi/nemo_relay.h
  • crates/ffi/src/api/mod.rs
  • crates/ffi/src/api/plugin.rs
  • crates/ffi/src/api/scope_registry.rs
  • crates/ffi/src/api/tool_lifecycle.rs
  • crates/ffi/src/api/tool_registry.rs
  • crates/ffi/src/callable.rs
  • crates/ffi/tests/unit/api/execution_tests.rs
  • crates/ffi/tests/unit/api_tests.rs
  • crates/node/plugin.d.ts
  • crates/node/src/api/mod.rs
  • crates/node/src/callable.rs
  • crates/node/src/types/mod.rs
  • crates/node/tests/scope_local_tests.mjs
  • crates/node/tests/tools_tests.mjs
  • crates/plugin/src/lib.rs
  • crates/plugin/tests/typed_callbacks.rs
  • crates/python/src/py_api/mod.rs
  • crates/python/src/py_callable.rs
  • crates/python/src/py_plugin.rs
  • crates/python/src/py_types/core.rs
  • crates/python/src/py_types/mod.rs
  • crates/types/src/api/tool.rs
  • crates/types/tests/serialization_tests.rs
  • crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto
  • crates/worker-proto/tests/proto_tests.rs
  • crates/worker/src/lib.rs
  • crates/worker/tests/worker_sdk_tests.rs
  • docs/reference/tool-execution-intercept-outcomes.mdx
  • go/nemo_relay/callbacks.go
  • go/nemo_relay/deregister_test.go
  • go/nemo_relay/nemo_relay.go
  • go/nemo_relay/plugin.go
  • go/nemo_relay/tools_test.go
  • python/nemo_relay/__init__.py
  • python/nemo_relay/__init__.pyi
  • python/nemo_relay/_native.pyi
  • python/nemo_relay/intercepts.py
  • python/nemo_relay/plugin.py
  • python/nemo_relay/plugin.pyi
  • python/nemo_relay/scope_local.py
  • python/nemo_relay/tools.py
  • python/plugin/src/nemo_relay_plugin/__init__.py
  • python/plugin/src/nemo_relay_plugin/_api.py
  • python/tests/plugin/test_public_api_docstrings.py
  • python/tests/plugin/test_worker_sdk.py
  • python/tests/test_scope_local.py
  • python/tests/test_tools.py

Comment on lines +420 to +423
assert_eq!(
tool_frame_end.category_profile().unwrap().extra[TOOL_RESULT_ANNOTATION_PROFILE_KEY]["producer_annotation"],
true
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Line 421 exceeds rustfmt's 100-column default and will fail cargo fmt --check.

Break the index like the equivalent assertion in crates/core/tests/integration/middleware_tests.rs (lines 820-824).

As per coding guidelines, "Any Rust change must run cargo fmt --all".

🎨 Proposed rustfmt-compatible wrapping
     assert_eq!(
-        tool_frame_end.category_profile().unwrap().extra[TOOL_RESULT_ANNOTATION_PROFILE_KEY]["producer_annotation"],
+        tool_frame_end.category_profile().unwrap().extra[TOOL_RESULT_ANNOTATION_PROFILE_KEY]
+            ["producer_annotation"],
         true
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert_eq!(
tool_frame_end.category_profile().unwrap().extra[TOOL_RESULT_ANNOTATION_PROFILE_KEY]["producer_annotation"],
true
);
assert_eq!(
tool_frame_end.category_profile().unwrap().extra[TOOL_RESULT_ANNOTATION_PROFILE_KEY]
["producer_annotation"],
true
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/core/tests/integration/native_plugin_tests.rs` around lines 420 - 423,
Wrap the long indexing expression in the assertion for tool_frame_end so it
conforms to rustfmt’s 100-column formatting, matching the equivalent assertion
in middleware_tests.rs. Run cargo fmt --all and preserve the assertion’s
behavior.

Source: Coding guidelines

Comment on lines +449 to +472
/// Wrap a C annotation-aware tool execution callback into an async Rust closure.
pub fn wrap_tool_exec_frame_fn(
cb: NemoRelayToolExecFrameCb,
user_data: *mut libc::c_void,
free_fn: NemoRelayFreeFn,
) -> Box<
dyn Fn(Json) -> Pin<Box<dyn Future<Output = Result<ToolExecutionFrame>> + Send>> + Send + Sync,
> {
let ud = make_user_data(user_data, free_fn);
Box::new(move |args: Json| {
let ud = ud.clone();
Box::pin(async move {
let c_args = json_to_c_string(&args);
let result_ptr = unsafe { cb(ud.ptr, c_args) };
unsafe { nemo_relay_string_free_internal(c_args) };
let frame_json =
json_result_from_ptr(result_ptr, "tool execution frame callback failed")?;
unsafe { nemo_relay_string_free_internal(result_ptr) };
serde_json::from_value::<ToolExecutionFrame>(frame_json).map_err(|error| {
FlowError::Internal(format!("invalid tool execution frame JSON: {error}"))
})
})
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Both frame wrappers drop the error-slot and free discipline used by the existing raw wrappers. Neither calls clear_last_error() before invoking the C callback (so json_result_from_ptr's null path can report a stale error), and both propagate the parse error with ? before freeing the callback-returned string, leaking it on invalid JSON.

  • crates/ffi/src/callable.rs#L449-L472: add clear_last_error() at the top of the async block and bind json_result_from_ptr(...) without ?, freeing result_ptr before applying ?.
  • crates/ffi/src/callable.rs#L580-L594: add clear_last_error() before the cb call and apply the same free-then-? ordering around result_ptr.
📍 Affects 1 file
  • crates/ffi/src/callable.rs#L449-L472 (this comment)
  • crates/ffi/src/callable.rs#L580-L594
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ffi/src/callable.rs` around lines 449 - 472, Update
wrap_tool_exec_frame_fn in crates/ffi/src/callable.rs lines 449-472 to call
clear_last_error() before cb, bind json_result_from_ptr without immediately
propagating its error, free result_ptr, then apply ?. Apply the same
clear-last-error and free-before-? ordering to the second frame wrapper in
crates/ffi/src/callable.rs lines 580-594.

Comment on lines +551 to +578
unsafe extern "C" fn tool_frame_next_trampoline(
args_json: *const c_char,
next_ctx: *mut libc::c_void,
) -> *mut c_char {
let next_arc = unsafe { &*(next_ctx as *const ToolExecutionFrameNextFn) };
let next = next_arc.clone();
let args = if args_json.is_null() {
Json::Null
} else {
let value = unsafe { CStr::from_ptr(args_json) }.to_string_lossy();
serde_json::from_str(&value).unwrap_or(Json::Null)
};
let handle = tokio::runtime::Handle::current();
let result = tokio::task::block_in_place(|| handle.block_on(next(args)));
match result {
Ok(frame) => match serde_json::to_value(frame) {
Ok(value) => json_to_c_string(&value),
Err(error) => {
set_last_error(&error.to_string());
std::ptr::null_mut()
}
},
Err(error) => {
set_last_error(&error.to_string());
std::ptr::null_mut()
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Verify the C-side contract that next_ctx must not be retained past callback return.

The boxed ToolExecutionFrameNextFn is dropped immediately after cb returns (Line 583), so any retained next_fn/next_ctx pair invoked later dereferences freed memory. The Go binding guards this with callbackLifetime, but this raw C surface has no such guard — the header doc for NemoRelayToolExecFrameInterceptCb (Lines 123-132) should state the invocation-scoped lifetime explicitly, as the raw intercept type does for other bindings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ffi/src/callable.rs` around lines 551 - 578, Document in the header
comment for NemoRelayToolExecFrameInterceptCb that next_ctx and the associated
next_fn are valid only during the callback invocation and must not be retained
or called after the callback returns. Match the existing lifetime-contract
wording used by other raw intercept bindings, without changing the trampoline
implementation.

Comment on lines +2270 to +2291
/// End a manual tool span with an optional opaque result annotation.
#[napi]
pub fn tool_call_end_frame(
handle: &ToolHandle,
frame: ToolExecutionFrame,
data: Option<Json>,
metadata: Option<Json>,
timestamp: Option<f64>,
) -> Result<()> {
let timestamp = parse_timestamp_micros(timestamp)?;
core_tool_api::tool_call_end_frame(
core_tool_api::ToolCallEndFrameParams::builder()
.handle(&handle.inner)
.frame(frame.into())
.data_opt(opt_json(data))
.metadata_opt(opt_json(metadata))
.timestamp_opt(timestamp)
.build(),
)
.map_err(to_napi_err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Frame-aware tool lifecycle entrypoints drop the ambient scope/publication-context wiring their raw siblings establish. In both Node.js and Python bindings, tool_call_end_frame and tool_call_execute_frame invoke the shared core pipeline without the context setup their tool_call_end/tool_call_execute counterparts perform, even though frame-aware and legacy execution intercepts/guardrails now run through the same priority-ordered chain.

  • crates/node/src/api/mod.rs#L2270-L2291: wrap the core_tool_api::tool_call_end_frame call in with_effective_scope_stack(&env, || {...}), matching tool_call_end (Line 2256-2266).
  • crates/node/src/api/mod.rs#L2353-L2409: resolve publication_context_id/publication_buffer via effective_scope_context(&env) and wrap the future in with_publication_callback_context(...), matching tool_call_execute (Lines 2313-2314, 2324-2346).
  • crates/python/src/py_api/mod.rs#L672-L693: wrap the core_tool_api::tool_call_end_frame call in with_python_publication_context(|| {...}), matching tool_call_end (Line 648).
  • crates/python/src/py_api/mod.rs#L792-L834: capture publication_context/publication_buffer and wrap the future in with_task_nested_publication_buffer(...)/with_task_publication_context(...), matching tool_call_execute (Lines 750-751, 753-756).

Please confirm whether this is an intentional simplification for frame-based calls or a regression; if unintentional, wiring all four sites the same way as their raw siblings avoids inconsistent guardrail/subscriber behavior between the raw and frame-aware tool lifecycle paths.

📍 Affects 2 files
  • crates/node/src/api/mod.rs#L2270-L2291 (this comment)
  • crates/node/src/api/mod.rs#L2353-L2409
  • crates/python/src/py_api/mod.rs#L672-L693
  • crates/python/src/py_api/mod.rs#L792-L834
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/node/src/api/mod.rs` around lines 2270 - 2291, Restore ambient scope
and publication-context wiring for frame-aware tool lifecycle calls. In
crates/node/src/api/mod.rs:2270-2291, wrap tool_call_end_frame’s core call with
with_effective_scope_stack; in crates/node/src/api/mod.rs:2353-2409, use
effective_scope_context and with_publication_callback_context around
tool_call_execute_frame. In crates/python/src/py_api/mod.rs:672-693, wrap
tool_call_end_frame with with_python_publication_context; in
crates/python/src/py_api/mod.rs:792-834, capture the publication context and
buffer and apply with_task_nested_publication_buffer and
with_task_publication_context, matching each raw sibling’s behavior.

Comment on lines +344 to +361
assert.deepEqual(frame.annotation, {
producer: 'node-sync',
});
await waitForSubscriberCallbacks(
() =>
events.some(
(event) =>
event.name === 'node_manual_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
) &&
events.some(
(event) =>
event.name === 'node_sync_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
),
);
const manualEnd = events.find(
(event) => event.name === 'node_manual_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
);
assert.equal(manualEnd.category_profile.tool_result_annotation.producer, 'node-manual');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test waits for the node_sync_frame_tool end event but never asserts it.

The name promises the two APIs "marshal the same frame shape", yet only the manual end event and the sync return value are checked. Assert the sync end-event annotation so the wait at lines 353-356 has a purpose and the parity claim is actually covered.

💚 Suggested assertion
       const manualEnd = events.find(
         (event) => event.name === 'node_manual_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
       );
       assert.equal(manualEnd.category_profile.tool_result_annotation.producer, 'node-manual');
+      const syncEnd = events.find(
+        (event) => event.name === 'node_sync_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
+      );
+      assert.deepEqual(syncEnd.category_profile.tool_result_annotation, frame.annotation);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert.deepEqual(frame.annotation, {
producer: 'node-sync',
});
await waitForSubscriberCallbacks(
() =>
events.some(
(event) =>
event.name === 'node_manual_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
) &&
events.some(
(event) =>
event.name === 'node_sync_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
),
);
const manualEnd = events.find(
(event) => event.name === 'node_manual_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
);
assert.equal(manualEnd.category_profile.tool_result_annotation.producer, 'node-manual');
assert.deepEqual(frame.annotation, {
producer: 'node-sync',
});
await waitForSubscriberCallbacks(
() =>
events.some(
(event) =>
event.name === 'node_manual_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
) &&
events.some(
(event) =>
event.name === 'node_sync_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
),
);
const manualEnd = events.find(
(event) => event.name === 'node_manual_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
);
assert.equal(manualEnd.category_profile.tool_result_annotation.producer, 'node-manual');
const syncEnd = events.find(
(event) => event.name === 'node_sync_frame_tool' && event.kind === 'scope' && event.scope_category === 'end',
);
assert.deepEqual(syncEnd.category_profile.tool_result_annotation, frame.annotation);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/node/tests/tools_tests.mjs` around lines 344 - 361, Extend the test
after locating the manual end event to also find the `node_sync_frame_tool`
scope-end event and assert its
`category_profile.tool_result_annotation.producer` is `node-sync`. Keep the
existing `waitForSubscriberCallbacks` condition and manual-event assertion
unchanged while covering the promised annotation parity.

Comment on lines +262 to +286
let decoded: ToolExecutionFrameOutcome =
serde_json::from_value(encoded).expect("frame outcome should deserialize");
assert_eq!(decoded, outcome);
}

#[test]
fn tool_execution_frame_requires_raw_result_and_defaults_to_no_annotation() {
let frame: ToolExecutionFrame = serde_json::from_value(json!({
"result": ["plain", "json"]
}))
.expect("annotation should be optional");
assert_eq!(frame.result, json!(["plain", "json"]));
assert!(frame.annotation.is_none());

assert!(
serde_json::from_value::<ToolExecutionFrame>(json!({
"annotation": {
"version": 1,
"status": "succeeded"
}
}))
.is_err(),
"raw result remains required"
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add ToolExecutionFrameOutcome decode-contract coverage to match the raw-outcome test.

The raw-outcome test (lines 207-221) pins defaulted pending_marks, unknown-field tolerance, and the required field. The frame outcome only gets a round-trip; frame being required and pending_marks defaulting are unpinned wire guarantees for worker/C envelopes.

♻️ Suggested addition
let defaults: ToolExecutionFrameOutcome = serde_json::from_value(json!({
    "frame": {"result": "plain"},
    "future_field": true
}))
.expect("omitted pending marks and unknown fields should be accepted");
assert!(defaults.pending_marks.is_empty());
assert!(
    serde_json::from_value::<ToolExecutionFrameOutcome>(json!({"pending_marks": []})).is_err(),
    "frame is required"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/types/tests/serialization_tests.rs` around lines 262 - 286, Extend the
ToolExecutionFrameOutcome test coverage alongside its existing round-trip test:
deserialize an object with a frame but omitted pending_marks and an unknown
field, assert pending_marks defaults to empty, and assert deserialization fails
when frame is omitted. Use the existing ToolExecutionFrameOutcome symbols and
preserve the raw-outcome contract pattern.

Comment on lines +112 to +120
- Public C callbacks return Relay outcome JSON with `result` and optional
`pending_marks`.
- `grpc-v1` worker SDKs return a `ToolExecutionInterceptOutcome` in a
`JsonEnvelope` with schema `nemo.relay.ToolExecutionInterceptOutcome@1`.

Canonical JSON uses `pending_marks` and `category_profile` across bindings.
Relay's outcome JSON uses `pending_marks` and `category_profile` across
bindings. Frame-aware worker and C envelopes additionally carry the opaque
`frame.result` and optional `frame.annotation` values without interpreting
them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Binding Contract omits the frame-aware return shapes for Python, Rust, Go, and Node.js.

Lines 117-120 mention only "worker and C envelopes" for frame values, yet this PR exposes ToolExecutionFrameOutcome (Python/Rust/Go) and { frame, pendingMarks? } (Node.js). The grpc-v1 frame envelope schema name is also undocumented while the raw one is spelled out at line 115.

As per coding guidelines, "Update README.md, fern/, package READMEs, and binding-support notes when public behavior, package names, examples, or supported bindings change."

📝 Suggested addition
 Relay's outcome JSON uses `pending_marks` and `category_profile` across
 bindings. Frame-aware worker and C envelopes additionally carry the opaque
 `frame.result` and optional `frame.annotation` values without interpreting
 them.
+
+Frame-aware callbacks return the analogous per-binding shape: Python, Rust,
+native plugins, and Go return `ToolExecutionFrameOutcome`; Node.js returns
+`{ frame, pendingMarks? }`; `grpc-v1` worker SDKs return a
+`ToolExecutionFrameOutcome` in a `JsonEnvelope` with schema
+`nemo.relay.ToolExecutionFrameOutcome@1`.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Public C callbacks return Relay outcome JSON with `result` and optional
`pending_marks`.
- `grpc-v1` worker SDKs return a `ToolExecutionInterceptOutcome` in a
`JsonEnvelope` with schema `nemo.relay.ToolExecutionInterceptOutcome@1`.
Canonical JSON uses `pending_marks` and `category_profile` across bindings.
Relay's outcome JSON uses `pending_marks` and `category_profile` across
bindings. Frame-aware worker and C envelopes additionally carry the opaque
`frame.result` and optional `frame.annotation` values without interpreting
them.
- Public C callbacks return Relay outcome JSON with `result` and optional
`pending_marks`.
- `grpc-v1` worker SDKs return a `ToolExecutionInterceptOutcome` in a
`JsonEnvelope` with schema `nemo.relay.ToolExecutionInterceptOutcome@1`.
Relay's outcome JSON uses `pending_marks` and `category_profile` across
bindings. Frame-aware worker and C envelopes additionally carry the opaque
`frame.result` and optional `frame.annotation` values without interpreting
them.
Frame-aware callbacks return the analogous per-binding shape: Python, Rust,
native plugins, and Go return `ToolExecutionFrameOutcome`; Node.js returns
`{ frame, pendingMarks? }`; `grpc-v1` worker SDKs return a
`ToolExecutionFrameOutcome` in a `JsonEnvelope` with schema
`nemo.relay.ToolExecutionFrameOutcome@1`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/reference/tool-execution-intercept-outcomes.mdx` around lines 112 - 120,
Update the binding contract section to document frame-aware return shapes for
Python, Rust, Go, and Node.js, including ToolExecutionFrameOutcome and Node.js’s
{ frame, pendingMarks? } form. Name the grpc-v1 frame envelope schema explicitly
alongside the existing raw schema, and clarify that frame.result and optional
frame.annotation are preserved opaquely across these bindings.

Source: Coding guidelines

setLastErrorMessage(err.Error())
return nil
}
value, err := json.Marshal(frame)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=go -C6 'func jsonMarshal\('

Repository: NVIDIA/NeMo-Relay

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## jsonMarshal references\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' '\bjsonMarshal\b' .

printf '\n## callbacks.go slices\n'
nl -ba go/nemo_relay/callbacks.go | sed -n '650,720p'
printf '\n---\n'
nl -ba go/nemo_relay/callbacks.go | sed -n '840,930p'

Repository: NVIDIA/NeMo-Relay

Length of output: 2740


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## test_hooks.go\n'
sed -n '1,80p' go/nemo_relay/test_hooks.go

printf '\n## callbacks.go around the cited call sites\n'
awk 'NR>=660 && NR<=700 {printf "%d:%s\n", NR, $0}' go/nemo_relay/callbacks.go
printf '\n---\n'
awk 'NR>=850 && NR<=890 {printf "%d:%s\n", NR, $0}' go/nemo_relay/callbacks.go
printf '\n---\n'
awk 'NR>=970 && NR<=995 {printf "%d:%s\n", NR, $0}' go/nemo_relay/callbacks.go

Repository: NVIDIA/NeMo-Relay

Length of output: 4313


Use the shared marshal helper here too. This keeps the frame trampoline aligned with the other paths and preserves the jsonMarshal test hook.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/nemo_relay/callbacks.go` at line 678, Replace the direct json.Marshal call
in the frame trampoline with the shared jsonMarshal helper, preserving its
existing error handling and ensuring the test hook is used consistently with the
other serialization paths.

Comment on lines +263 to +291
var retainedNext func(json.RawMessage) (ToolExecutionFrame, error)
err := RegisterToolExecutionFrameIntercept(
interceptName,
1,
func(_ json.RawMessage, next func(json.RawMessage) (ToolExecutionFrame, error)) (ToolExecutionFrameOutcome, error) {
retainedNext = next
return ToolExecutionFrameOutcome{
Frame: ToolExecutionFrame{Result: json.RawMessage(`{"short_circuit":true}`)},
}, nil
},
)
if err != nil {
t.Fatalf("RegisterToolExecutionFrameIntercept failed: %v", err)
}
defer DeregisterToolExecutionIntercept(interceptName)

_, err = ToolCallExecuteFrame(
"go_frame_retained_next_tool",
json.RawMessage(`{}`),
func(args json.RawMessage) (ToolExecutionFrame, error) {
return ToolExecutionFrame{Result: args}, nil
},
)
if err != nil {
t.Fatalf("ToolCallExecuteFrame failed: %v", err)
}
if retainedNext == nil {
t.Fatal("frame intercept did not retain the continuation")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard retainedNext — the callback may run on a non-Go-owned thread, so go test -race can flag this write/read pair.

The intercept closure is invoked from the native runtime; unlike events above (mutex-protected), retainedNext is written there and read in the test goroutine with no synchronization edge the race detector can see.

🔒 Proposed fix
+	var mu sync.Mutex
 	var retainedNext func(json.RawMessage) (ToolExecutionFrame, error)
 	err := RegisterToolExecutionFrameIntercept(
 		interceptName,
 		1,
 		func(_ json.RawMessage, next func(json.RawMessage) (ToolExecutionFrame, error)) (ToolExecutionFrameOutcome, error) {
+			mu.Lock()
 			retainedNext = next
+			mu.Unlock()
 			return ToolExecutionFrameOutcome{
 				Frame: ToolExecutionFrame{Result: json.RawMessage(`{"short_circuit":true}`)},
 			}, nil
 		},
 	)

and take the same lock before the retainedNext == nil check and the call.

Based on path instructions to review Go binding changes for race safety and consider race-test behavior.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/nemo_relay/tools_test.go` around lines 263 - 291, Protect the retainedNext
access in the intercept test with the same synchronization mechanism used for
the native callback, guarding both the assignment inside the intercept closure
and the subsequent nil check and invocation. Ensure the lock establishes a
race-detector-visible synchronization edge between the callback thread and the
test goroutine while preserving the existing continuation behavior.

Source: Path instructions

Comment on lines +1191 to +1205
def register_tool_execution_frame_intercept(
self,
name: str,
callback: ToolExecutionFrameCallback,
*,
priority: int = 0,
) -> None:
"""Register middleware that carries an opaque annotation with the tool result.

The callback participates in the same host registry and priority order
as legacy tool execution intercepts.
"""
self._push_registration(name, pb.TOOL_EXECUTION_FRAME_INTERCEPT, priority, False)
self._handlers.tool_execution_frames[name] = callback

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

_push_registration dedup doesn't cover the shared tool-execution name namespace.

_push_registration only rejects duplicates within the same surface. But nemo_relay_deregister_tool_execution_intercept in the FFI tests shows raw (TOOL_EXECUTION_INTERCEPT) and frame-aware (TOOL_EXECUTION_FRAME_INTERCEPT) intercepts share one host-side name registry. A plugin registering the same name via register_tool_execution_intercept and register_tool_execution_frame_intercept will pass this client-side check but still collide at Register() time on the host, surfacing a less-specific, delayed error instead of an immediate local WorkerSdkError.

♻️ Proposed fix: treat both surfaces as one namespace for dedup
     def _push_registration(self, name: str, surface: int, priority: int, break_chain: bool) -> None:
+        conflicting_surfaces = (
+            {pb.TOOL_EXECUTION_INTERCEPT, pb.TOOL_EXECUTION_FRAME_INTERCEPT}
+            if surface in (pb.TOOL_EXECUTION_INTERCEPT, pb.TOOL_EXECUTION_FRAME_INTERCEPT)
+            else {surface}
+        )
         if any(
-            registration.local_name == name and registration.surface == surface
+            registration.local_name == name and registration.surface in conflicting_surfaces
             for registration in self._handlers.registrations
         ):
             raise WorkerSdkError(f"handler {name!r} is already registered for surface {surface}")

Please confirm the host's core registry indeed enforces one shared namespace across raw/frame tool-execution intercepts (per crates/core/src/api/registry.rs) to validate this fix is warranted.

Also applies to: 1335-1348

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/plugin/src/nemo_relay_plugin/_api.py` around lines 1191 - 1205, Update
_push_registration and the registration paths for TOOL_EXECUTION_INTERCEPT and
TOOL_EXECUTION_FRAME_INTERCEPT so both surfaces share one deduplication
namespace for tool-execution names. Reject cross-surface duplicates immediately
with the existing local WorkerSdkError behavior, while preserving separate
deduplication for unrelated surfaces; apply the same handling to
register_tool_execution_frame_intercept and the corresponding legacy
registration flow.

@bbednarski9 bbednarski9 modified the milestones: 0.7, 0.8 Jul 30, 2026
@willkill07
willkill07 marked this pull request as draft July 31, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature a new feature lang:go PR changes/introduces Go code lang:js PR changes/introduces Javascript/Typescript code lang:python PR changes/introduces Python code lang:rust PR changes/introduces Rust code size:XXL PR is very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enhancement]: Add an opaque annotation channel to tool-execution intercepts

2 participants