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
17 changes: 9 additions & 8 deletions apps/decodex/src/agent/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5384,14 +5384,15 @@ fn validated_dynamic_tool_call_payload(
if let Some(target_turn_id) = target_turn_id
&& payload.turn_id != target_turn_id
{
return Err(Box::new(DynamicToolCallDispatch::protocol_failure(
Some(payload.tool),
payload.namespace,
format!(
"Dynamic tool call targeted turn `{}`, but the active turn is `{target_turn_id}`.",
payload.turn_id
),
)));
tracing::warn!(
target_thread_id,
target_turn_id,
payload_thread_id = payload.thread_id.as_str(),
payload_turn_id = payload.turn_id.as_str(),
tool = payload.tool.as_str(),
namespace = payload.namespace.as_deref().unwrap_or(""),
"Dynamic tool call turn id differed from the active turn; accepting thread-bound request."
);
}

Ok(payload)
Expand Down
54 changes: 54 additions & 0 deletions apps/decodex/src/agent/app_server/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,60 @@ fn dynamic_tool_call_enforces_declared_namespace() {
}
}

#[test]
fn dynamic_tool_call_accepts_thread_bound_request_when_payload_turn_id_differs() {
let handler = NamespacedDynamicToolHandler { seen_namespace: RefCell::new(None) };
let request = JsonRpcRequest {
id: serde_json::json!(1),
method: String::from("item/tool/call"),
params: serde_json::json!({
"arguments": {},
"callId": "call-1",
"namespace": "tracker",
"threadId": "thread-1",
"tool": "tracker_tool",
"turnId": "tool-call-turn"
}),
};
let dispatch =
super::handle_dynamic_tool_call(Some(&handler), &request, "thread-1", Some("active-turn"));

assert!(dispatch.response.success);
assert!(dispatch.terminal_failure.is_none());
assert_eq!(*handler.seen_namespace.borrow(), Some(String::from("tracker")));
}

#[test]
fn dynamic_tool_call_rejects_wrong_thread_even_when_payload_turn_id_differs() {
let handler = NamespacedDynamicToolHandler { seen_namespace: RefCell::new(None) };
let request = JsonRpcRequest {
id: serde_json::json!(1),
method: String::from("item/tool/call"),
params: serde_json::json!({
"arguments": {},
"callId": "call-1",
"namespace": "tracker",
"threadId": "thread-2",
"tool": "tracker_tool",
"turnId": "tool-call-turn"
}),
};
let dispatch =
super::handle_dynamic_tool_call(Some(&handler), &request, "thread-1", Some("active-turn"));

assert!(!dispatch.response.success);
assert_eq!(*handler.seen_namespace.borrow(), None);
assert_eq!(
dispatch.terminal_failure.as_ref().map(super::AppServerDynamicToolFailure::error_class),
Some("app_server_dynamic_tool_protocol_failure")
);
assert!(matches!(
dispatch.response.content_items.as_slice(),
[DynamicToolContentItem::InputText { text }]
if text.contains("targeted thread `thread-2`")
));
}

#[test]
fn dynamic_tool_call_rejects_invalid_response_shape() {
let handler = EmptyToolResponseHandler;
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/app-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ When dynamic tools are enabled, `decodex` must also:
2. Answer `item/tool/call` requests with `DynamicToolCallResponse`.
3. Serialize dynamic tool output items with schema-approved `type` values such as `inputText`.
4. Keep every `dynamicTools[].name` and populated `dynamicTools[].namespace` within the app-server identifier pattern `^[a-zA-Z0-9_-]+$`.
5. Validate incoming `item/tool/call` thread, turn, tool-name, namespace, and response shape before treating the request as handled.
5. Validate incoming `item/tool/call` thread, tool-name, namespace, and response shape before treating the request as handled. Dynamic tool request `turnId` is diagnostic context only: the app-server may emit a request-scoped turn id that differs from the active `turn/start` id, so Decodex records mismatches but keeps the authorization boundary on the active thread and declared tool surface.

The client-side dynamic bridge may expose narrow Decodex-owned tools that are local to one run attempt, such as the deferred `decodex.decodex_run_context` tool. These tools must stay small and side-effect-bounded so they can move to a process-local MCP server if the surface expands. Broader stateful or cross-service tool families remain MCP candidates rather than reasons to grow the client bridge indefinitely.

Expand Down