This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 5fbcf2365a8a CAMEL-23956: Handle malformed tool arguments in agentic
loop
5fbcf2365a8a is described below
commit 5fbcf2365a8a501e15541e8926729974573f3b6a
Author: Omar Atie <[email protected]>
AuthorDate: Sat Jul 18 00:14:43 2026 -0700
CAMEL-23956: Handle malformed tool arguments in agentic loop
Return invalid JSON tool arguments to the model as an error tool result
instead of failing the exchange. This matches the documented agentic
loop error-handling contract in both auto and manual tool execution
paths, allowing the model to self-correct malformed arguments.
Closes #24877
---
.../camel/component/openai/OpenAIProducer.java | 7 +-
.../openai/OpenAIToolExecutionProducer.java | 6 +-
...penAIAgenticLoopMalformedToolArgumentsTest.java | 163 +++++++++++++++++++++
3 files changed, 174 insertions(+), 2 deletions(-)
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
index 81995a7fae2b..aeb8292580eb 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
@@ -30,6 +30,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.openai.core.JsonField;
import com.openai.core.JsonValue;
@@ -524,10 +525,10 @@ public class OpenAIProducer extends DefaultAsyncProducer {
}
LOG.debug("Executing MCP tool '{}' with args: {}", toolName,
argsJson);
- Map<String, Object> argsMap =
OBJECT_MAPPER.readValue(argsJson, Map.class);
String resultContent;
try {
+ Map<String, Object> argsMap =
OBJECT_MAPPER.readValue(argsJson, Map.class);
McpSchema.CallToolResult toolResult
= getEndpoint().callTool(mcpClient, toolName,
argsMap);
@@ -540,6 +541,10 @@ public class OpenAIProducer extends DefaultAsyncProducer {
allReturnDirect = false;
}
}
+ } catch (JsonProcessingException e) {
+ LOG.warn("Invalid tool arguments for '{}': {}", toolName,
argsJson, e);
+ resultContent = "Error: invalid tool arguments: " +
e.getMessage();
+ allReturnDirect = false;
} catch (Exception e) {
LOG.warn("MCP tool '{}' execution failed: {}", toolName,
e.getMessage(), e);
resultContent = "Error: Tool execution failed: " +
e.getMessage();
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
index 616ab9733c29..857e0e04f1f1 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java
@@ -21,6 +21,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam;
@@ -142,10 +143,10 @@ public class OpenAIToolExecutionProducer extends
DefaultProducer {
"Tool '" + toolName + "' not found in any configured
MCP server");
}
- Map<String, Object> argsMap = OBJECT_MAPPER.readValue(argsJson,
Map.class);
String resultContent;
try {
+ Map<String, Object> argsMap =
OBJECT_MAPPER.readValue(argsJson, Map.class);
McpSchema.CallToolResult toolResult
= getEndpoint().callTool(mcpClient, toolName, argsMap);
@@ -155,6 +156,9 @@ public class OpenAIToolExecutionProducer extends
DefaultProducer {
} else {
resultContent = extractTextContent(toolResult.content());
}
+ } catch (JsonProcessingException e) {
+ LOG.warn("Invalid tool arguments for '{}': {}", toolName,
argsJson, e);
+ resultContent = "Error: invalid tool arguments: " +
e.getMessage();
} catch (Exception e) {
LOG.warn("MCP tool '{}' execution failed: {}", toolName,
e.getMessage(), e);
resultContent = "Error: Tool execution failed: " +
e.getMessage();
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticLoopMalformedToolArgumentsTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticLoopMalformedToolArgumentsTest.java
new file mode 100644
index 000000000000..e618b44731a9
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIAgenticLoopMalformedToolArgumentsTest.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Reproducer for malformed tool-call arguments in the agentic loop.
+ *
+ * <p>
+ * Models occasionally emit syntactically invalid JSON in {@code
tool_calls[].function.arguments} (a hallucination class
+ * every agentic runtime has to deal with). The documented error-handling
contract of the agentic loop (openai-mcp.adoc,
+ * "Error Handling in the Agentic Loop") is that tool execution problems are
caught and sent back to the model as tool
+ * result text so the model can recover gracefully.
+ *
+ * <p>
+ * However, {@code OpenAIProducer.processNonStreamingAgentic} parses the
arguments with
+ * {@code OBJECT_MAPPER.readValue(argsJson, Map.class)} <b>outside</b> the
try/catch that implements that contract, so a
+ * malformed arguments string crashes the whole exchange with a raw Jackson
{@code JsonParseException} instead of giving
+ * the model a chance to correct itself.
+ */
+public class OpenAIAgenticLoopMalformedToolArgumentsTest extends
CamelTestSupport {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final AtomicInteger callCounter = new AtomicInteger();
+
+ @RegisterExtension
+ public OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("call bad tool")
+ .thenRespondWith((exchange, input) -> {
+ try {
+ if (callCounter.getAndIncrement() == 0) {
+ // first round: the model requests a tool call with
malformed JSON arguments
+ return toolCallResponseWithMalformedArguments();
+ }
+ // subsequent round: the model recovers and produces the
final answer
+ return simpleTextResponse("Recovered from bad arguments");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ })
+ .end()
+ .build();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:mcp-chat")
+
.to("openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true&baseUrl="
+ + openAIMock.getBaseUrl() + "/v1");
+ }
+ };
+ }
+
+ @Test
+ void malformedToolArgumentsMustBeReportedToTheModelNotCrashTheExchange() {
+ McpSyncClient client = mock(McpSyncClient.class);
+ McpSchema.CallToolResult toolResult =
McpSchema.CallToolResult.builder()
+ .content(List.of(new McpSchema.TextContent(null, "Sunny",
null)))
+ .isError(false)
+ .build();
+
when(client.callTool(any(McpSchema.CallToolRequest.class))).thenReturn(toolResult);
+
+ String endpointUri =
"openai:chat-completion?model=gpt-5&apiKey=dummy&autoToolExecution=true&baseUrl="
+ + openAIMock.getBaseUrl() + "/v1";
+ OpenAIEndpoint endpoint = context.getEndpoint(endpointUri,
OpenAIEndpoint.class);
+ List<McpSchema.Tool> mcpTools = List.of(
+ McpSchema.Tool.builder("get_weather", Map.of("type", "object"))
+ .description("Mock tool: get_weather")
+ .build());
+ endpoint.setMcpToolState(new McpToolState(
+ McpToolConverter.convert(mcpTools),
+ Map.of("get_weather", client),
+ Map.of(),
+ Set.of()));
+
+ Exchange result = template.request("direct:mcp-chat", e ->
e.getIn().setBody("call bad tool"));
+
+ assertNull(result.getException(),
+ "Malformed tool arguments must be handled like any other tool
execution error "
+ + "(fed back to the model as an
error tool result), not crash the exchange. Got: "
+ + result.getException());
+ assertEquals("Recovered from bad arguments",
result.getMessage().getBody(String.class));
+ }
+
+ private static String toolCallResponseWithMalformedArguments() throws
Exception {
+ Map<String, Object> function = new HashMap<>();
+ function.put("name", "get_weather");
+ function.put("arguments", "{\"city\": \"London\""); // truncated JSON
- not parseable
+
+ Map<String, Object> toolCall = new HashMap<>();
+ toolCall.put("id", UUID.randomUUID().toString());
+ toolCall.put("type", "function");
+ toolCall.put("function", function);
+
+ Map<String, Object> message = new HashMap<>();
+ message.put("role", "assistant");
+ message.put("content", null);
+ message.put("tool_calls", List.of(toolCall));
+
+ return chatCompletion(message, "tool_calls");
+ }
+
+ private static String simpleTextResponse(String content) throws Exception {
+ Map<String, Object> message = new HashMap<>();
+ message.put("role", "assistant");
+ message.put("content", content);
+ return chatCompletion(message, "stop");
+ }
+
+ private static String chatCompletion(Map<String, Object> message, String
finishReason) throws Exception {
+ Map<String, Object> choice = new HashMap<>();
+ choice.put("finish_reason", finishReason);
+ choice.put("index", 0);
+ choice.put("message", message);
+
+ Map<String, Object> completion = new HashMap<>();
+ completion.put("id", UUID.randomUUID().toString());
+ completion.put("choices", List.of(choice));
+ completion.put("created", System.currentTimeMillis() / 1000L);
+ completion.put("model", "openai-mock");
+ completion.put("object", "chat.completion");
+ return MAPPER.writeValueAsString(completion);
+ }
+}