Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .agents/skills/add-binding-feature/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ Lock these before implementing:
- Does the new API need manual lifecycle and managed execute variants, or only
one of them?
- Does the new behavior change event fields, metadata, or scope expectations?
- If tool execution is affected, does every callback, continuation, managed
return, and manual end surface use the canonical `ToolExecutionResult`
contract and preserve its opaque annotation?
- Are docs/examples required because the intended usage changed?

## Key References
Expand Down
9 changes: 9 additions & 0 deletions .agents/skills/add-middleware/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ Refer to `docs/about-nemo-relay/concepts/middleware.mdx` for the full diagrams.
specialized tool or LLM sanitizer (when applicable) -> mark or scope event
sanitizer -> subscriber and exporter dispatch

Tool execution callbacks and each execution-intercept `next` continuation
return the canonical `ToolExecutionResult { result, annotation }`. A forwarding
intercept must preserve both fields in `ToolExecutionInterceptOutcome`; Relay
retains `pending_marks` separately. Tool sanitize-response guardrails receive
only `result`. Scope-end event sanitizers govern the annotation after Relay
projects it to `category_profile.tool_result_annotation`.

## Core Steps

1. Define or reuse the callback type alias in
Expand Down Expand Up @@ -96,6 +103,8 @@ Follow the `add-binding-feature` skill for the cross-binding implementation chec
- [ ] Callback failure policy, including fail-open behavior when required
- [ ] Scope-local registration, inheritance, and cleanup on pop
- [ ] Event payload semantics after middleware mutation
- [ ] Tool execution result and annotation preservation, replacement, and
removal when the middleware touches tool execution
- [ ] Mark and scope event field semantics, including immutable identity fields
- [ ] Parity coverage in every affected binding

Expand Down
5 changes: 3 additions & 2 deletions .agents/skills/contribute-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ Use this skill for docs-only or example-heavy changes.
- When detailed dynamic plugin guides exist, keep Rust native plugin examples,
Python worker plugin examples, and `grpc-v1` protocol details on separate
pages.
- Dynamic plugin manifests in docs/examples should use
`compat.relay = ">=0.5,<1.0"` unless deliberately narrower.
- Dynamic plugin manifests must exclude Relay versions before 0.8. Recommend
`compat.relay = ">=0.8.0,<1.0"`; open-ended or narrower 0.8-or-newer ranges
are valid when intentional.
- In MDX files, top-of-file comments must use JSX comment delimiters:
`{/*` to open and `*/}` to close. Do not use HTML comments for MDX SPDX
headers.
Expand Down
3 changes: 3 additions & 0 deletions .agents/skills/contribute-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ as LangChain, LangGraph, Deep Agents, or OpenClaw through its public APIs.

- [ ] Integration pattern follows `docs/integrate-into-frameworks/adding-scopes.mdx`
- [ ] Integration uses public framework or plugin APIs
- [ ] Managed tool adapters return `ToolExecutionResult` to Relay and unwrap
`.result` only at the framework boundary; opaque annotations are
preserved through forwarding execution intercepts
- [ ] Relevant integration tests or smoke path pass
- [ ] Docs updated if activation or usage changed

Expand Down
19 changes: 14 additions & 5 deletions .agents/skills/maintain-dynamic-plugins/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,23 @@ Use this skill for `plugin.kind = "rust_dynamic"`, `plugin.kind = "worker"`,
strings across the native dynamic-library boundary.
- Typed native middleware futures run on the SDK-owned Tokio executor. Keep
subscribers synchronous and preserve raw synchronous ABI registrations.
- Keep worker protocol DTOs in `JsonEnvelope`; protobuf owns control flow, not
duplicated Relay data models.
- Define closed worker transport structures in protobuf when generated clients
must enforce their fields. Keep open application payloads lossless by using
`JsonValue` or `JsonEnvelope` rather than `google.protobuf.Value`.
- Keep `relay-plugin.toml` dynamic records separate from generic runtime
components. Enabled dynamic records may synthesize internal component specs;
disabled records stay inspectable but unloaded.
- Treat plugin Relay compatibility as normal SemVer. Typed async native plugin
examples require `>=0.8.0,<1.0`; other examples use `>=0.5,<1.0` unless a
plugin intentionally declares a narrower range.
- Relay 0.8 establishes the native API 1 and `grpc-v1` canonical
`ToolExecutionResult` baseline. Require every dynamic plugin to rebuild and
declare a `compat.relay` range that excludes versions before 0.8. Recommend
`>=0.8.0,<1.0`; open-ended or narrower 0.8-or-newer ranges are valid.
- Treat `compat.relay` as the plugin author's compatibility assertion, not
proof that an artifact was rebuilt. Do not add a legacy raw-result adapter.
- Relay 0.8 retains the `grpc-v1` identifier and
`nemo.relay.worker.v1` package while changing the tool-result protobuf types;
every worker must regenerate its bindings and rebuild. Native ABI v4 remains
unchanged. After this baseline reset, future incompatible native JSON or
worker protobuf changes must bump `native_api` or `worker_protocol`.
- Do not add tests under `src`; Rust tests belong in crate `tests/` trees and
Python SDK tests belong under `python/tests`.
- Native and worker plugins are trusted extensions. Document that native plugins
Expand Down
5 changes: 5 additions & 0 deletions .agents/skills/maintain-observability/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ OpenTelemetry projections.
- [ ] Does Relay derive compliant trace and span IDs consistently across typed
OpenTelemetry endpoints while preserving lifecycle parentage?
- [ ] Are mark events, start/end events, and orphan cases still handled correctly?
- [ ] Does a sanitized tool result annotation remain opaque under
`category_profile.tool_result_annotation`, ATIF observation-result
`extra.tool_result_annotation`, and the single
`nemo_relay.tool.result.annotation` attribute in `full` and
`openinference`, while `gen_ai` omits it?
- [ ] Do examples and docs use each exporter's documented flush/deregister
order before shutdown?
- [ ] Are span or trajectory fields still derived from the intended event data?
Expand Down
5 changes: 3 additions & 2 deletions .agents/skills/validate-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ If the change is large or public-facing, also verify:
- README and docs entry points still match current package names and paths
- Examples still run with the documented commands
- Any renamed public surfaces are reflected consistently in manifests and docs
- Dynamic plugin examples use `compat.relay = ">=0.5,<1.0"` unless deliberately
narrower.
- Dynamic plugin examples exclude Relay versions before 0.8. The recommended
range is `compat.relay = ">=0.8.0,<1.0"`; open-ended or narrower
0.8-or-newer ranges are valid when intentional.

## References

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ async fn test_adaptive_plugin_registers_and_passes_calls_through() {
.unwrap();
assert_eq!(llm_result, json!({"response": "ok"}));

let tool_func: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args) }));
let tool_func: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args.into()) }));
let tool_result = tool_call_execute(
nemo_relay::api::tool::ToolCallExecuteParams::builder()
.name("search")
Expand All @@ -628,7 +628,7 @@ async fn test_adaptive_plugin_registers_and_passes_calls_through() {
)
.await
.unwrap();
assert_eq!(tool_result, json!({"query": "test"}));
assert_eq!(tool_result.result, json!({"query": "test"}));

clear_plugin_configuration().unwrap();
}
Expand Down Expand Up @@ -822,7 +822,7 @@ async fn test_top_level_plugin_registers_request_and_execution_intercepts() {
.unwrap();
assert_eq!(request.request.headers.get("x-plugin"), Some(&json!("set")));

let tool_func: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args) }));
let tool_func: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args.into()) }));
let tool_result = tool_call_execute(
nemo_relay::api::tool::ToolCallExecuteParams::builder()
.name("search")
Expand All @@ -832,7 +832,7 @@ async fn test_top_level_plugin_registers_request_and_execution_intercepts() {
)
.await
.unwrap();
assert_eq!(tool_result["x-tool-plugin"], json!(true));
assert_eq!(tool_result.result["x-tool-plugin"], json!(true));

let llm_func: LlmExecutionNextFn =
Arc::new(|_req: LlmRequest| Box::pin(async move { Ok(json!({"response": "ok"})) }));
Expand Down
8 changes: 4 additions & 4 deletions crates/adaptive/tests/unit/intercepts_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async fn test_tool_intercept_calls_next() {
let intercept = create_tool_execution_intercept(hot_cache);

let next: ToolExecutionNextFn =
Arc::new(|_args| Box::pin(async move { Ok(json!({"result": "ok"})) }));
Arc::new(|_args| Box::pin(async move { Ok(json!({"result": "ok"}).into()) }));

let result = intercept("test", json!({"input": 1}), next).await;
assert!(result.is_ok());
Expand All @@ -121,7 +121,7 @@ async fn test_tool_intercept_with_populated_cache() {
let intercept = create_tool_execution_intercept(hot_cache);

let next: ToolExecutionNextFn =
Arc::new(|_args| Box::pin(async move { Ok(json!({"from_next": true})) }));
Arc::new(|_args| Box::pin(async move { Ok(json!({"from_next": true}).into()) }));

// Should not panic and should return next's result
let result = intercept("test", json!({"tool_input": "data"}), next).await;
Expand All @@ -143,7 +143,7 @@ async fn test_tool_intercept_passes_args_to_next() {
let intercept = create_tool_execution_intercept(hot_cache);

// next captures and returns the args it received, proving pass-through
let next: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args) }));
let next: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args.into()) }));

let input = json!({"tool_arg": "value", "count": 42});
let result = intercept("test", input.clone(), next).await;
Expand Down Expand Up @@ -317,7 +317,7 @@ async fn test_schedule_mode_intercept_waits_for_primer_before_running_follower()
"follower should not call next until the primer has released the cohort"
);
}
Ok(args)
Ok(args.into())
})
})
};
Expand Down
4 changes: 2 additions & 2 deletions crates/adaptive/tests/unit/runtime_features_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ async fn tool_parallelism_feature_registers_execution_intercept() {
feature.register(&mut ctx).await.unwrap();
assert_tool_execution_intercept_registered(&name);

let next: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args) }));
let next: ToolExecutionNextFn = Arc::new(|args| Box::pin(async move { Ok(args.into()) }));
let result = tool_call_execute(
nemo_relay::api::tool::ToolCallExecuteParams::builder()
.name("search")
Expand All @@ -633,7 +633,7 @@ async fn tool_parallelism_feature_registers_execution_intercept() {
)
.await
.unwrap();
assert_eq!(result["query"], json!("coverage"));
assert_eq!(result.result["query"], json!("coverage"));

let mut registrations = ctx.finish();
rollback_registrations(&mut registrations);
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/sessions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1249,7 +1249,7 @@ impl Session {
tool_call_end(
ToolCallEndParams::builder()
.handle(&handle)
.result(json!({ "status": reason }))
.execution_result(json!({ "status": reason }).into())
.metadata(json!({ "status": reason }))
.build(),
)?;
Expand Down Expand Up @@ -1570,7 +1570,7 @@ impl Session {
tool_call_end(
ToolCallEndParams::builder()
.handle(&handle)
.result(event.result.clone())
.execution_result(event.result.clone().into())
.metadata(merge_metadata(
event_metadata,
json!({ "status": event.status }),
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ id = {plugin_id}
kind = "worker"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down Expand Up @@ -181,7 +181,7 @@ id = {plugin_id}
kind = "worker"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/tests/coverage/shared/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ id = "acme.coverage-worker"
kind = "worker"

[compat]
relay = ">=0.5,<1.0"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down Expand Up @@ -506,7 +506,7 @@ id = "{plugin_id}"
kind = "worker"

[compat]
relay = "0.1"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down
10 changes: 5 additions & 5 deletions crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ id = "{plugin_id}"
kind = "worker"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down Expand Up @@ -362,7 +362,7 @@ id = "{plugin_id}"
kind = "worker"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down Expand Up @@ -600,7 +600,7 @@ id = "{plugin_id}"
kind = "rust_dynamic"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
native_api = "1"

[defaults]
Expand Down Expand Up @@ -751,7 +751,7 @@ id = "acme.snapshot-race"
kind = "worker"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down Expand Up @@ -907,7 +907,7 @@ id = "acme.external-native-closure"
kind = "rust_dynamic"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
native_api = "1"

[defaults]
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/tests/coverage/shared/plugins_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ id = "{plugin_id}"
{name}kind = "worker"

[compat]
relay = "0.5"
relay = ">=0.8.0,<1.0"
worker_protocol = "grpc-v1"

[defaults]
Expand Down
6 changes: 4 additions & 2 deletions crates/core/src/api/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,8 @@ global_execution_registry_api!(
/// Register a global tool execution intercept.
/// Execution intercepts can wrap or replace the tool callback. Each
/// callback returns a canonical tool execution outcome, while its
/// continuation resolves to the raw downstream result JSON.
/// continuation resolves to the downstream
/// [`ToolExecutionResult`](crate::api::tool::ToolExecutionResult).
register_tool_execution_intercept,
/// Deregister a global tool execution intercept.
deregister_tool_execution_intercept,
Expand Down Expand Up @@ -670,7 +671,8 @@ scope_execution_registry_api!(
/// Register a scope-local tool execution intercept.
/// Execution intercepts can wrap or replace the tool callback inside the
/// owning scope. Each callback returns a canonical tool execution outcome,
/// while its continuation resolves to the raw downstream result JSON.
/// while its continuation resolves to the downstream
/// [`ToolExecutionResult`](crate::api::tool::ToolExecutionResult).
scope_register_tool_execution_intercept,
/// Deregister a scope-local tool execution intercept.
scope_deregister_tool_execution_intercept,
Expand Down
13 changes: 7 additions & 6 deletions crates/core/src/api/runtime/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use tokio_stream::Stream;

use crate::api::event::{Event, EventSanitizeFields};
use crate::api::llm::{LlmRequest, LlmRequestInterceptOutcome};
use crate::api::tool::ToolExecutionInterceptOutcome;
use crate::api::tool::{ToolExecutionInterceptOutcome, ToolExecutionResult};
use crate::codec::request::AnnotatedLlmRequest;
use crate::codec::traits::{LlmCodec, LlmResponseCodec};
use crate::error::Result;
Expand Down Expand Up @@ -104,9 +104,9 @@ pub type ToolInterceptFn =
/// chain.
///
/// # Returns
/// A future resolving to the downstream tool result JSON. Pending marks from
/// downstream intercepts are retained by the runtime and are not exposed
/// through this continuation.
/// A future resolving to the downstream tool result and optional opaque
/// annotation. Pending marks from downstream intercepts are retained by the
/// runtime and are not exposed through this continuation.
///
/// # Errors
/// The future resolves to an error when the remaining execution chain fails.
Expand All @@ -116,8 +116,9 @@ pub type ToolInterceptFn =
/// execution-intercept callback is still running. Each invocation receives an
/// isolated snapshot of the scopes visible when `next` is called. Calls that
/// remain unfinished or begin after the interceptor settles are rejected.
pub type ToolExecutionNextFn =
Arc<dyn Fn(Json) -> Pin<Box<dyn Future<Output = Result<Json>> + Send>> + Send + Sync>;
pub type ToolExecutionNextFn = Arc<
dyn Fn(Json) -> Pin<Box<dyn Future<Output = Result<ToolExecutionResult>> + Send>> + Send + Sync,
>;
/// Wrap or replace tool execution.
///
/// A tool execution intercept receives the tool name, the current argument
Expand Down
13 changes: 8 additions & 5 deletions crates/core/src/api/runtime/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,8 @@ impl NemoRelayContextState {
///
/// # Parameters
/// - `name`: Tool name associated with the response.
/// - `result`: Raw tool result to sanitize for observability.
/// - `result`: Application-owned tool result JSON to sanitize for
/// observability.
/// - `entries`: Sanitizer snapshots to evaluate.
///
/// # Returns
Expand Down Expand Up @@ -1161,7 +1162,7 @@ impl NemoRelayContextState {
Box::pin(async move {
default_fn(args)
.await
.map(ToolExecutionInterceptOutcome::new)
.map(ToolExecutionInterceptOutcome::from)
})
});
let name = name.to_string();
Expand All @@ -1185,12 +1186,14 @@ impl NemoRelayContextState {
let invocation = continuation.begin();
let downstream_marks = downstream_marks.clone();
Box::pin(async move {
let outcome = invocation?.invoke(move || current_next(args)).await?;
let mut outcome =
invocation?.invoke(move || current_next(args)).await?;
let pending_marks = std::mem::take(&mut outcome.pending_marks);
downstream_marks
.lock()
.expect("tool pending mark accumulator lock poisoned")
.push((sequence, outcome.pending_marks));
Ok(outcome.result)
.push((sequence, pending_marks));
Ok(outcome.into_execution_result())
})
})
};
Expand Down
Loading
Loading