From 29d0763b283a603f379da9264f1cf4602940595a Mon Sep 17 00:00:00 2001 From: Santanu Sinha Date: Mon, 24 Aug 2026 19:19:02 +0530 Subject: [PATCH 1/4] Agents can now alter tool call arguments --- .../phonepe/sentinelai/core/agent/Agent.java | 43 ++++- .../sentinelai/core/agent/AgentExtension.java | 19 ++ .../core/agent/AgentToolRunner.java | 38 ++-- .../sentinelai/core/agent/AgentTest.java | 173 +++++++++++++++++- .../core/agent/AgentToolRetryTest.java | 6 +- 5 files changed, 258 insertions(+), 21 deletions(-) diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java index 5accc9c0..852578bc 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java @@ -36,6 +36,7 @@ import com.phonepe.sentinelai.core.agentmessages.AgentMessageType; import com.phonepe.sentinelai.core.agentmessages.requests.GenericText; import com.phonepe.sentinelai.core.agentmessages.requests.UserPrompt; +import com.phonepe.sentinelai.core.agentmessages.responses.ToolCall; import com.phonepe.sentinelai.core.earlytermination.EarlyTerminationStrategy; import com.phonepe.sentinelai.core.earlytermination.NeverTerminateEarlyStrategy; import com.phonepe.sentinelai.core.errorhandling.DefaultErrorHandler; @@ -702,10 +703,10 @@ private void processExtensionData(AgentRunContext context, } private ArrayList populateOutputDefinitions(ProcessingMode processingMode) { - final var outputDefinitions = new ArrayList<>(List.of( - new ModelOutputDefinition(OUTPUT_VARIABLE_NAME, - "Output generated by the agent", - outputSchema()))); + final var modelOutputDefinition = new ModelOutputDefinition(OUTPUT_VARIABLE_NAME, + "Output generated by the agent", + outputSchema()); + final var outputDefinitions = new ArrayList<>(List.of(modelOutputDefinition)); outputDefinitions.addAll(extensions.stream() .map(extension -> extension.outputSchema(processingMode)) .filter(Optional::isPresent) @@ -714,6 +715,34 @@ private ArrayList populateOutputDefinitions(ProcessingMod return outputDefinitions; } + @SuppressWarnings("unchecked") + private ToolCall modifyToolCallArguments(AgentRunContext context, ToolCall toolCall) { + try { + + final var mapper = context.getAgentSetup() + .getMapper(); + var argumentNode = mapper + .readTree(toolCall.getArguments()); + for (final var extension : this.extensions) { + argumentNode = extension.modifyToolCallArguments(context, (A) this, toolCall, argumentNode); + } + return new ToolCall(toolCall.getSessionId(), + toolCall.getRunId(), + toolCall.getMessageId(), + toolCall.getTimestamp(), + toolCall.getToolCallId(), + toolCall.getToolName(), + mapper.writeValueAsString(argumentNode)); + } + catch (Exception e) { + log.error("Error transforming arguments for tool call %s: %s [Arguments: %s]" + .formatted(toolCall.getToolCallId(), + AgentUtils.rootCause(e).getMessage(), + toolCall.getArguments())); + return toolCall; + } + } + private ModelOutput makeModelCall(AgentSetup mergedAgentSetup, ModelRunContext modelRunContext, List outputDefinitions, @@ -725,7 +754,8 @@ private ModelOutput makeModelCall(AgentSetup mergedAgentSetup, final var toolRunner = new AgentToolRunner<>(self, mergedAgentSetup, toolRunApprovalSeeker, - context); + context, + toolCall -> modifyToolCallArguments(context, toolCall)); final var model = mergedAgentSetup.getModel(); final var safeRunner = new SafeToolRunner(toolRunner, mergedAgentSetup, @@ -779,7 +809,8 @@ private ModelOutput makeAsyncModelCall(AgentSetup mergedAgentSetup, final var toolRunner = new AgentToolRunner<>(self, mergedAgentSetup, toolRunApprovalSeeker, - context); + context, + toolCall -> modifyToolCallArguments(context, toolCall)); final var model = mergedAgentSetup.getModel(); final var safeRunner = new SafeToolRunner(toolRunner, mergedAgentSetup, diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java index 49a70166..26074faa 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; import com.phonepe.sentinelai.core.agentmessages.AgentMessage; +import com.phonepe.sentinelai.core.agentmessages.responses.ToolCall; import com.phonepe.sentinelai.core.tools.ToolBox; import lombok.Value; @@ -104,6 +105,24 @@ default List messages(AgentRunContext context, return List.of(); } + /** + * This method can be used to modify the input arguments for a tool call. This can be used to add additional + * parameters to the tool call or modify the existing parameters. + * + * @param context Context for the agent run + * @param agent Reference to the agent + * @param toolCall Tool call object + * @param inputArguments Input arguments for the tool call + * @return Modified input arguments for the tool call + */ + @SuppressWarnings("unused") + default JsonNode modifyToolCallArguments(AgentRunContext context, + A agent, + ToolCall toolCall, + JsonNode inputArguments) { + return inputArguments; + } + /** * This method can be used to set-up async tasks or perform any operations once the extension is registered with * the agent diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java index ba584bc6..0a23791e 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java @@ -55,6 +55,7 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.function.UnaryOperator; /** * @@ -76,6 +77,8 @@ public class AgentToolRunner> implements ToolRunn AgentRunContext context; + UnaryOperator toolCallPreProcessor; + /** * This returns a temporary failure for unhandled exceptions. Can be used to retry if needed. * @@ -155,28 +158,41 @@ private static ToolCallResponse successResponse(ExternalTool.ExternalToolRes @Override public ToolCallResponse runTool(Map tools, - ToolCall toolCall) { + ToolCall providedToolCall) { final var eventBus = context.getAgentSetup().getEventBus(); - if (!toolRunApprovalSeeker.seekApproval(agent, context, toolCall)) { + if (!toolRunApprovalSeeker.seekApproval(agent, context, providedToolCall)) { log.info("Tool call {} for tool {} was not approved by the user", - toolCall.getToolCallId(), - toolCall.getToolName()); + providedToolCall.getToolCallId(), + providedToolCall.getToolName()); eventBus.notify(new ToolCallApprovalDeniedAgentEvent(agent.name(), context.getRunId(), AgentUtils .sessionId(context), AgentUtils .userId(context), - toolCall.getToolCallId(), - toolCall.getToolName())); + providedToolCall.getToolCallId(), + providedToolCall.getToolName())); return new ToolCallResponse(AgentUtils.sessionId(context), context.getRunId(), - toolCall.getToolCallId(), - toolCall.getToolName(), + providedToolCall.getToolCallId(), + providedToolCall.getToolName(), ErrorType.TOOL_CALL_PERMANENT_FAILURE, "Tool call was not approved by the user", LocalDateTime.now()); } + final var toolCall = toolCallPreProcessor.apply(providedToolCall); + if (null == toolCall) { + log.info("Tool call {} for tool {} was pre-processed to null, skipping execution", + providedToolCall.getToolCallId(), + providedToolCall.getToolName()); + return new ToolCallResponse(AgentUtils.sessionId(context), + context.getRunId(), + providedToolCall.getToolCallId(), + providedToolCall.getToolName(), + ErrorType.TOOL_CALL_PERMANENT_FAILURE, + "Tool call was pre-processed to null, skipping execution", + LocalDateTime.now()); + } eventBus.notify(new ToolCalledAgentEvent(agent.name(), context.getRunId(), AgentUtils.sessionId(context), @@ -188,10 +204,8 @@ public ToolCallResponse runTool(Map tools, final var response = runTool(context, tools, toolCall); eventBus.notify(new ToolCallCompletedAgentEvent(agent.name(), context.getRunId(), - AgentUtils.sessionId( - context), - AgentUtils.userId( - context), + AgentUtils.sessionId(context), + AgentUtils.userId(context), toolCall.getToolCallId(), toolCall.getToolName(), response.getErrorType(), diff --git a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java index aad3c45f..e1c5f9be 100644 --- a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java +++ b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java @@ -16,6 +16,7 @@ package com.phonepe.sentinelai.core.agent; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -48,6 +49,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -63,6 +65,56 @@ class AgentTest { private static final ObjectMapper MAPPER = JsonUtils.createMapper(); + /** + * Extension that can modify tool call arguments or return null based on a flag. + */ + private static final class ModifyingExtension implements AgentExtension { + private final boolean returnNull; + + private ModifyingExtension(boolean returnNull) { + this.returnNull = returnNull; + } + + @Override + public ExtensionPromptSchema additionalSystemPrompts(String request, + AgentRunContext context, + TestAgent agent, + ProcessingMode processingMode) { + return new ExtensionPromptSchema(List.of()); + } + + @Override + public List facts(String request, + AgentRunContext context, + TestAgent agent) { + return List.of(); + } + + @Override + public JsonNode modifyToolCallArguments(AgentRunContext context, + TestAgent agent, + ToolCall toolCall, + JsonNode inputArguments) { + if (returnNull) { + return null; + } + final var node = (ObjectNode) inputArguments; + final var input = (ObjectNode) node.get("input"); + input.put("data", "Modified Data"); + return node; + } + + @Override + public String name() { + return "modifying-extension"; + } + + @Override + public Optional outputSchema(ProcessingMode processingMode) { + return Optional.empty(); + } + } + private static final class TestAgent extends Agent { public TestAgent(@NonNull AgentSetup setup, @@ -306,6 +358,126 @@ public CompletableFuture compute(ModelRunContext context, assertTrue(response.getData().contains("Santanu")); } + @Test + void testToolCallArgumentModification() { + final var textAgent = new TestAgent(AgentSetup.builder() + .model(new Model() { + @Override + public CompletableFuture compute(ModelRunContext context, + Collection outputDefinitions, + List oldMessages, + Map tools, + ToolRunner toolRunner, + EarlyTerminationStrategy earlyTerminationStrategy, + List preProcessors) { + return CompletableFuture.supplyAsync(() -> { + assertTrue(tools.containsKey( + "test_agent_structured_tool")); + final var response = toolRunner.runTool(tools, + new ToolCall("s1", + "r1", + "TC1", + "test_agent_structured_tool", + """ + { + "input": { + "data" : "Test Data" + } + } + """)); + assertTrue(response.isSuccess()); + assertEquals("TC1", response.getToolCallId()); + final var messages = new ArrayList<>(oldMessages); + final var message = new ToolCallResponse("s1", + "r1", + response.getToolCallId(), + response.getToolName(), + response.getErrorType(), + response.getResponse(), + LocalDateTime.now()); + messages.add(message); + return ModelOutput.success(createTextOutput( + "Hello " + response + .getResponse()), + List.of(message), + messages, + context.getModelUsageStats()); + }); + } + }) + .modelSettings(ModelSettings.builder().build()) + .mapper(MAPPER) + .build(), List.of(new ModifyingExtension(false)), Map.of()); + final var response = textAgent.execute(AgentInput.builder() + .request("Hi") + .requestMetadata(AgentRequestMetadata.builder() + .sessionId("s1") + .userId("ss") + .build()) + .build()); + assertTrue(response.getData().contains("Hello Modified Data")); + } + + @Test + void testToolCallModificationNull() { + final var textAgent = new TestAgent(AgentSetup.builder() + .model(new Model() { + @Override + public CompletableFuture compute(ModelRunContext context, + Collection outputDefinitions, + List oldMessages, + Map tools, + ToolRunner toolRunner, + EarlyTerminationStrategy earlyTerminationStrategy, + List preProcessors) { + return CompletableFuture.supplyAsync(() -> { + assertTrue(tools.containsKey( + "test_agent_structured_tool")); + final var response = toolRunner.runTool(tools, + new ToolCall("s1", + "r1", + "TC1", + "test_agent_structured_tool", + """ + { + "input": { + "data" : "Test Data" + } + } + """)); + assertFalse(response.isSuccess()); + assertEquals(ErrorType.TOOL_CALL_PERMANENT_FAILURE, + response.getErrorType()); + final var messages = new ArrayList<>(oldMessages); + final var message = new ToolCallResponse("s1", + "r1", + response.getToolCallId(), + response.getToolName(), + response.getErrorType(), + response.getResponse(), + LocalDateTime + .now()); + messages.add(message); + return ModelOutput.success(createTextOutput("Tool call skipped"), + List.of(message), + messages, + context.getModelUsageStats()); + }); + } + }) + .modelSettings(ModelSettings.builder().build()) + .mapper(MAPPER) + .build(), List.of(new ModifyingExtension(true)), Map.of()); + final var response = textAgent.execute(AgentInput.builder() + .request("Hi") + .requestMetadata(AgentRequestMetadata.builder() + .sessionId("s1") + .userId("ss") + .build()) + .build()); + assertTrue(response.getData().contains("Tool call skipped")); + } + @Test void testToolCallFailure() { @@ -549,5 +721,4 @@ private record Output( String output ) { } - } diff --git a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentToolRetryTest.java b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentToolRetryTest.java index 7721e563..25f5f8ce 100644 --- a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentToolRetryTest.java +++ b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentToolRetryTest.java @@ -96,7 +96,8 @@ void testRetryLogic() throws Exception { final var toolRunner = new AgentToolRunner(agent, setup, approvalSeeker, - context); + context, + t -> t); final var callCount = new AtomicInteger(0); final var toolDefinition = ToolDefinition.builder() @@ -150,7 +151,8 @@ void testTimeoutLogic() throws Exception { final var toolRunner = new AgentToolRunner(agent, setup, approvalSeeker, - context); + context, + t -> t); final var testTool = new TestTool(); final var method = TestTool.class.getMethod("slowMethod"); From 0ec0523e10acec0a7a9275e9df6b206168af627b Mon Sep 17 00:00:00 2001 From: Santanu Sinha Date: Mon, 24 Aug 2026 23:20:44 +0530 Subject: [PATCH 2/4] modifier can throw exception to break tool call --- .../phonepe/sentinelai/core/agent/Agent.java | 37 +++----- .../sentinelai/core/agent/AgentExtension.java | 1 + .../core/agent/AgentToolRunner.java | 18 ++-- .../sentinelai/core/agent/AgentTest.java | 85 +++++++++++++++++-- 4 files changed, 104 insertions(+), 37 deletions(-) diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java index 852578bc..5b9c6c7a 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/Agent.java @@ -716,31 +716,22 @@ private ArrayList populateOutputDefinitions(ProcessingMod } @SuppressWarnings("unchecked") + @SneakyThrows private ToolCall modifyToolCallArguments(AgentRunContext context, ToolCall toolCall) { - try { - - final var mapper = context.getAgentSetup() - .getMapper(); - var argumentNode = mapper - .readTree(toolCall.getArguments()); - for (final var extension : this.extensions) { - argumentNode = extension.modifyToolCallArguments(context, (A) this, toolCall, argumentNode); - } - return new ToolCall(toolCall.getSessionId(), - toolCall.getRunId(), - toolCall.getMessageId(), - toolCall.getTimestamp(), - toolCall.getToolCallId(), - toolCall.getToolName(), - mapper.writeValueAsString(argumentNode)); - } - catch (Exception e) { - log.error("Error transforming arguments for tool call %s: %s [Arguments: %s]" - .formatted(toolCall.getToolCallId(), - AgentUtils.rootCause(e).getMessage(), - toolCall.getArguments())); - return toolCall; + final var mapper = context.getAgentSetup() + .getMapper(); + var argumentNode = mapper + .readTree(toolCall.getArguments()); + for (final var extension : this.extensions) { + argumentNode = extension.modifyToolCallArguments(context, (A) this, toolCall, argumentNode); } + return new ToolCall(toolCall.getSessionId(), + toolCall.getRunId(), + toolCall.getMessageId(), + toolCall.getTimestamp(), + toolCall.getToolCallId(), + toolCall.getToolName(), + mapper.writeValueAsString(argumentNode)); } private ModelOutput makeModelCall(AgentSetup mergedAgentSetup, diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java index 26074faa..50ca5687 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentExtension.java @@ -108,6 +108,7 @@ default List messages(AgentRunContext context, /** * This method can be used to modify the input arguments for a tool call. This can be used to add additional * parameters to the tool call or modify the existing parameters. + * To fail the tool call in case of invalid input, throw an exception from this method. * * @param context Context for the agent run * @param agent Reference to the agent diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java index 0a23791e..32f1e62e 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java @@ -57,6 +57,8 @@ import java.util.concurrent.TimeUnit; import java.util.function.UnaryOperator; +import static com.phonepe.sentinelai.core.utils.AgentUtils.rootCause; + /** * */ @@ -180,17 +182,22 @@ public ToolCallResponse runTool(Map tools, "Tool call was not approved by the user", LocalDateTime.now()); } - final var toolCall = toolCallPreProcessor.apply(providedToolCall); - if (null == toolCall) { - log.info("Tool call {} for tool {} was pre-processed to null, skipping execution", + var toolCall = providedToolCall; + try { + toolCall = toolCallPreProcessor.apply(providedToolCall); + } + catch (Exception e) { + final var rootCause = AgentUtils.rootCause(e).getMessage(); + log.info("Tool call {} for tool {} failed with error: {}", providedToolCall.getToolCallId(), - providedToolCall.getToolName()); + providedToolCall.getToolName(), + rootCause); return new ToolCallResponse(AgentUtils.sessionId(context), context.getRunId(), providedToolCall.getToolCallId(), providedToolCall.getToolName(), ErrorType.TOOL_CALL_PERMANENT_FAILURE, - "Tool call was pre-processed to null, skipping execution", + "Tool call failed with error: " + rootCause, LocalDateTime.now()); } eventBus.notify(new ToolCalledAgentEvent(agent.name(), @@ -215,7 +222,6 @@ public ToolCallResponse runTool(Map tools, return response; } - /** * Convert parameters string received from LLM to actual parameters for tool call * diff --git a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java index e1c5f9be..f5883c3c 100644 --- a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java +++ b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java @@ -70,9 +70,15 @@ class AgentTest { */ private static final class ModifyingExtension implements AgentExtension { private final boolean returnNull; + private final boolean throwError; private ModifyingExtension(boolean returnNull) { + this(returnNull, false); + } + + private ModifyingExtension(boolean returnNull, boolean throwError) { this.returnNull = returnNull; + this.throwError = throwError; } @Override @@ -95,6 +101,9 @@ public JsonNode modifyToolCallArguments(AgentRunContext context, TestAgent agent, ToolCall toolCall, JsonNode inputArguments) { + if (throwError) { + throw new IllegalArgumentException("Invalid input argument"); + } if (returnNull) { return null; } @@ -419,7 +428,7 @@ public CompletableFuture compute(ModelRunContext context, } @Test - void testToolCallModificationNull() { + void testToolCallArgumentModificationThrowsError() { final var textAgent = new TestAgent(AgentSetup.builder() .model(new Model() { @Override @@ -448,6 +457,8 @@ public CompletableFuture compute(ModelRunContext context, assertFalse(response.isSuccess()); assertEquals(ErrorType.TOOL_CALL_PERMANENT_FAILURE, response.getErrorType()); + assertTrue(response.getResponse() + .contains("Invalid input argument")); final var messages = new ArrayList<>(oldMessages); final var message = new ToolCallResponse("s1", "r1", @@ -455,10 +466,9 @@ public CompletableFuture compute(ModelRunContext context, response.getToolName(), response.getErrorType(), response.getResponse(), - LocalDateTime - .now()); + LocalDateTime.now()); messages.add(message); - return ModelOutput.success(createTextOutput("Tool call skipped"), + return ModelOutput.success(createTextOutput("Tool call failed"), List.of(message), messages, context.getModelUsageStats()); @@ -467,7 +477,7 @@ public CompletableFuture compute(ModelRunContext context, }) .modelSettings(ModelSettings.builder().build()) .mapper(MAPPER) - .build(), List.of(new ModifyingExtension(true)), Map.of()); + .build(), List.of(new ModifyingExtension(false, true)), Map.of()); final var response = textAgent.execute(AgentInput.builder() .request("Hi") .requestMetadata(AgentRequestMetadata.builder() @@ -475,7 +485,7 @@ public CompletableFuture compute(ModelRunContext context, .userId("ss") .build()) .build()); - assertTrue(response.getData().contains("Tool call skipped")); + assertTrue(response.getData().contains("Tool call failed")); } @Test @@ -493,8 +503,7 @@ public CompletableFuture compute(ModelRunContext context, EarlyTerminationStrategy earlyTerminationStrategy, List preProcessors) { return CompletableFuture.supplyAsync(() -> { - assertTrue(tools.containsKey( - "test_agent_throw_tool")); + assertTrue(tools.containsKey("test_agent_throw_tool")); final var response = toolRunner.runTool(tools, new ToolCall("s1", "r1", @@ -536,6 +545,66 @@ public CompletableFuture compute(ModelRunContext context, assertTrue(data.getMessage().contains("Test exception")); } + @Test + void testToolCallModificationNull() { + final var textAgent = new TestAgent(AgentSetup.builder() + .model(new Model() { + @Override + public CompletableFuture compute(ModelRunContext context, + Collection outputDefinitions, + List oldMessages, + Map tools, + ToolRunner toolRunner, + EarlyTerminationStrategy earlyTerminationStrategy, + List preProcessors) { + return CompletableFuture.supplyAsync(() -> { + assertTrue(tools.containsKey( + "test_agent_structured_tool")); + final var response = toolRunner.runTool(tools, + new ToolCall("s1", + "r1", + "TC1", + "test_agent_structured_tool", + """ + { + "input": { + "data" : "Test Data" + } + } + """)); + assertFalse(response.isSuccess()); + assertEquals(ErrorType.TOOL_CALL_PERMANENT_FAILURE, + response.getErrorType()); + final var messages = new ArrayList<>(oldMessages); + final var message = new ToolCallResponse("s1", + "r1", + response.getToolCallId(), + response.getToolName(), + response.getErrorType(), + response.getResponse(), + LocalDateTime + .now()); + messages.add(message); + return ModelOutput.success(createTextOutput("Tool call skipped"), + List.of(message), + messages, + context.getModelUsageStats()); + }); + } + }) + .modelSettings(ModelSettings.builder().build()) + .mapper(MAPPER) + .build(), List.of(new ModifyingExtension(true)), Map.of()); + final var response = textAgent.execute(AgentInput.builder() + .request("Hi") + .requestMetadata(AgentRequestMetadata.builder() + .sessionId("s1") + .userId("ss") + .build()) + .build()); + assertTrue(response.getData().contains("Tool call skipped")); + } + @Test void testToolCallNoApproval() { From 31ac7de5f0d5865d7ea6b521dc657e4e8432e7f2 Mon Sep 17 00:00:00 2001 From: Santanu Sinha Date: Tue, 25 Aug 2026 11:26:53 +0530 Subject: [PATCH 3/4] Added special error code for preprocessor failure --- .../core/agent/AgentToolRunner.java | 33 ++++++++++++------- .../sentinelai/core/errors/ErrorType.java | 1 + .../sentinelai/core/agent/AgentTest.java | 3 +- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java index 32f1e62e..32ee3ee9 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/agent/AgentToolRunner.java @@ -57,8 +57,6 @@ import java.util.concurrent.TimeUnit; import java.util.function.UnaryOperator; -import static com.phonepe.sentinelai.core.utils.AgentUtils.rootCause; - /** * */ @@ -182,32 +180,43 @@ public ToolCallResponse runTool(Map tools, "Tool call was not approved by the user", LocalDateTime.now()); } + eventBus.notify(new ToolCalledAgentEvent(agent.name(), + context.getRunId(), + AgentUtils.sessionId(context), + AgentUtils.userId(context), + providedToolCall.getToolCallId(), + providedToolCall.getToolName(), + providedToolCall.getArguments())); + final var stopwatch = Stopwatch.createStarted(); var toolCall = providedToolCall; try { toolCall = toolCallPreProcessor.apply(providedToolCall); } catch (Exception e) { final var rootCause = AgentUtils.rootCause(e).getMessage(); + final var response = "Tool call failed with error: " + rootCause; log.info("Tool call {} for tool {} failed with error: {}", providedToolCall.getToolCallId(), providedToolCall.getToolName(), rootCause); + eventBus.notify(new ToolCallCompletedAgentEvent(agent.name(), + context.getRunId(), + AgentUtils.sessionId(context), + AgentUtils.userId(context), + toolCall.getToolCallId(), + toolCall.getToolName(), + ErrorType.TOOL_CALL_PREPROCESSING_FAILURE, + response, + Duration.ofMillis(stopwatch + .elapsed(TimeUnit.MILLISECONDS)))); return new ToolCallResponse(AgentUtils.sessionId(context), context.getRunId(), providedToolCall.getToolCallId(), providedToolCall.getToolName(), - ErrorType.TOOL_CALL_PERMANENT_FAILURE, - "Tool call failed with error: " + rootCause, + ErrorType.TOOL_CALL_PREPROCESSING_FAILURE, + response, LocalDateTime.now()); } - eventBus.notify(new ToolCalledAgentEvent(agent.name(), - context.getRunId(), - AgentUtils.sessionId(context), - AgentUtils.userId(context), - toolCall.getToolCallId(), - toolCall.getToolName(), - toolCall.getArguments())); - final var stopwatch = Stopwatch.createStarted(); final var response = runTool(context, tools, toolCall); eventBus.notify(new ToolCallCompletedAgentEvent(agent.name(), context.getRunId(), diff --git a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/errors/ErrorType.java b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/errors/ErrorType.java index ceba6afd..74737340 100644 --- a/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/errors/ErrorType.java +++ b/sentinel-ai-core/src/main/java/com/phonepe/sentinelai/core/errors/ErrorType.java @@ -31,6 +31,7 @@ public enum ErrorType { FILTERED("Content filtered", false), LENGTH_EXCEEDED("Content length exceeded", false), TOOL_CALL_PERMANENT_FAILURE("Tool call failed permanently for tool: %s", false), + TOOL_CALL_PREPROCESSING_FAILURE("Tool call failed in preprocessing: %s", false), TOOL_CALL_TEMPORARY_FAILURE("Tool call failed temporarily for tool: %s", true), TOOL_CALL_TIMEOUT("Tool call timed out for tool: %s", true), JSON_ERROR("Error parsing JSON. Error: %s", true), diff --git a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java index f5883c3c..3389aaa6 100644 --- a/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java +++ b/sentinel-ai-core/src/test/java/com/phonepe/sentinelai/core/agent/AgentTest.java @@ -455,7 +455,7 @@ public CompletableFuture compute(ModelRunContext context, } """)); assertFalse(response.isSuccess()); - assertEquals(ErrorType.TOOL_CALL_PERMANENT_FAILURE, + assertEquals(ErrorType.TOOL_CALL_PREPROCESSING_FAILURE, response.getErrorType()); assertTrue(response.getResponse() .contains("Invalid input argument")); @@ -485,6 +485,7 @@ public CompletableFuture compute(ModelRunContext context, .userId("ss") .build()) .build()); + System.out.println("Response: " + response); assertTrue(response.getData().contains("Tool call failed")); } From 9b675480d491134aca15d03d0cffabe622721c39 Mon Sep 17 00:00:00 2001 From: Santanu Sinha Date: Tue, 25 Aug 2026 11:39:20 +0530 Subject: [PATCH 4/4] Updated docs --- docs/docs/agents.md | 21 +++++++++++++++++++++ docs/docs/errors.md | 1 + 2 files changed, 22 insertions(+) diff --git a/docs/docs/agents.md b/docs/docs/agents.md index 8dee3494..ae21cf4d 100644 --- a/docs/docs/agents.md +++ b/docs/docs/agents.md @@ -704,3 +704,24 @@ Extensions can be used to: - Generate extra information from the agent To create an extension derive and implement the `AgentExtension` interface. + +### Tool Call Argument Preprocessing + +Extensions can preprocess tool call arguments before the tool runs. Override `modifyToolCallArguments` in your +extension to inspect or modify the arguments. All registered extensions are applied in order. To fail the tool call +(for example, on invalid input), throw an exception from this method. The tool call then fails with +`ErrorType.TOOL_CALL_PREPROCESSING_FAILURE` and the error message is returned to the model. + +```java +@Override +public JsonNode modifyToolCallArguments(AgentRunContext context, + MyAgent agent, + ToolCall toolCall, + JsonNode inputArguments) { + if ("my_tool".equals(toolCall.getToolName())) { + // Add or modify arguments + ((ObjectNode) inputArguments).put("region", "in"); + } + return inputArguments; +} +``` diff --git a/docs/docs/errors.md b/docs/docs/errors.md index 9b114c5f..7121a509 100644 --- a/docs/docs/errors.md +++ b/docs/docs/errors.md @@ -19,6 +19,7 @@ The `ErrorType` enum defines the categories of errors that can occur during agen | `FILTERED` | The generated content was filtered by the provider. | No | | `LENGTH_EXCEEDED` | The generated content exceeded the maximum allowed length/tokens. | No | | `TOOL_CALL_PERMANENT_FAILURE` | A tool call failed with a non-recoverable error. | No | +| `TOOL_CALL_PREPROCESSING_FAILURE` | A tool call failed during argument preprocessing by an extension. | No | | `TOOL_CALL_TEMPORARY_FAILURE` | A tool call failed with a transient error. | Yes | | `TOOL_CALL_TIMEOUT` | A tool call exceeded its configured timeout. | Yes | | `JSON_ERROR` | Error parsing JSON response from the model. | Yes |