diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/loop/support/DefaultToolLoop.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/loop/support/DefaultToolLoop.kt index 2083dcbf4..f57464d66 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/loop/support/DefaultToolLoop.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/loop/support/DefaultToolLoop.kt @@ -21,8 +21,6 @@ import com.embabel.agent.core.support.AbstractAgentProcess import com.embabel.agent.api.tool.Tool import com.embabel.agent.api.tool.ToolCallContext import com.embabel.agent.api.tool.ToolControlFlowSignal -import com.embabel.agent.api.tool.callback.AfterToolCallContext -import com.embabel.agent.api.tool.callback.BeforeToolCallContext import com.embabel.agent.api.tool.callback.ToolCallInspector import com.embabel.agent.api.tool.callback.ToolLoopInspector import com.embabel.agent.api.tool.callback.ToolLoopTransformer @@ -36,8 +34,6 @@ import com.embabel.agent.spi.loop.EmptyResponsePolicy import com.embabel.agent.spi.loop.ExitOnEmptyPolicy import com.embabel.agent.spi.loop.LlmMessageSender import com.embabel.agent.spi.loop.MaxIterationsExceededException -import com.embabel.agent.spi.loop.ToolCallResult -import com.embabel.agent.spi.loop.ToolInjectionContext import com.embabel.agent.spi.loop.ToolInjectionStrategy import com.embabel.agent.spi.loop.ToolLoop import com.embabel.agent.spi.loop.ToolLoopResult @@ -349,32 +345,13 @@ internal open class DefaultToolLoop( toolCall: ToolCall, ): Pair { logger.debug("Executing tool: {} with input: {}", toolCall.name, toolCall.arguments) - - // Notify tool call inspectors BEFORE execution - toolCallInspectors.notifyBeforeToolCall(BeforeToolCallContext(toolCall)) - - // Measure execution time - val startTime = System.currentTimeMillis() - val result = tool.call(toolCall.arguments, toolCallContext) - val durationMs = System.currentTimeMillis() - startTime - - val content = when (result) { - is Tool.Result.Text -> result.content - is Tool.Result.WithArtifact -> result.content - is Tool.Result.Error -> "Error: ${result.message}" - } - - // Notify tool call inspectors AFTER execution - toolCallInspectors.notifyAfterToolCall( - AfterToolCallContext( - toolCall = toolCall, - result = result, - resultAsString = content, - durationMs = durationMs, - ) + val executed = executeTool( + tool = tool, + toolCall = toolCall, + toolCallContext = toolCallContext, + toolCallInspectors = toolCallInspectors, ) - - return result to content + return executed.result to executed.content } protected fun applyInjectionStrategy( @@ -382,67 +359,18 @@ internal open class DefaultToolLoop( resultContent: String, state: LoopState, ) { - val context = ToolInjectionContext( + applyToolInjection( + toolCall = toolCall, + resultContent = resultContent, conversationHistory = state.conversationHistory, - currentTools = state.availableTools, - lastToolCall = ToolCallResult( - toolName = toolCall.name, - toolInput = toolCall.arguments, - result = resultContent, - resultObject = tryDeserialize(resultContent), - ), - iterationCount = state.iterations, - ) - - val injectionResult = injectionStrategy.evaluate(context) - if (!injectionResult.hasChanges()) return - - removeTools(injectionResult.toolsToRemove, toolCall.name, state) - addTools(injectionResult.toolsToAdd, toolCall.name, state) - } - - private fun removeTools( - toolsToRemove: List, - afterToolName: String, - state: LoopState, - ) { - if (toolsToRemove.isEmpty()) return - - val namesToRemove = toolsToRemove.map { it.definition.name }.toSet() - state.availableTools.removeIf { it.definition.name in namesToRemove } - state.removedTools.addAll(toolsToRemove) - logger.info("Strategy removed {} tools after {}: {}", toolsToRemove.size, afterToolName, namesToRemove) - } - - private fun addTools( - toolsToAdd: List, - afterToolName: String, - state: LoopState, - ) { - if (toolsToAdd.isEmpty()) return - - val decoratedTools = if (toolDecorator != null) { - toolsToAdd.map { toolDecorator.invoke(it) } - } else { - toolsToAdd - } - - // Deduplicate: skip tools whose name already exists in the available set - val existingNames = state.availableTools.map { it.definition.name }.toSet() - val newTools = decoratedTools.filter { it.definition.name !in existingNames } - - if (newTools.isEmpty()) { - logger.debug("All {} tools already present after {}, skipping", decoratedTools.size, afterToolName) - return - } - - state.availableTools.addAll(newTools) - state.injectedTools.addAll(newTools) - logger.info( - "Strategy injected {} tools after {}: {}", - newTools.size, - afterToolName, - newTools.map { it.definition.name } + availableTools = state.availableTools, + iteration = state.iterations, + injectionStrategy = injectionStrategy, + objectMapper = objectMapper, + toolDecorator = toolDecorator, + injectedTools = state.injectedTools, + removedTools = state.removedTools, + logger = logger, ) } @@ -514,21 +442,4 @@ internal open class DefaultToolLoop( } } } - - /** - * Try to deserialize a JSON result string. - * Only attempts parsing if the result looks like JSON (starts with `{` or `[`). - */ - private fun tryDeserialize(jsonResult: String): Any? { - val trimmed = jsonResult.trimStart() - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - return null - } - return try { - objectMapper.readValue(jsonResult, Any::class.java) - } catch (e: Exception) { - logger.debug("Could not deserialize tool result as JSON: {}", e.message) - null - } - } } diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/loop/support/ToolExecutionSupport.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/loop/support/ToolExecutionSupport.kt new file mode 100644 index 000000000..b53973e37 --- /dev/null +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/loop/support/ToolExecutionSupport.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed 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 com.embabel.agent.spi.loop.support + +import com.embabel.agent.api.tool.Tool +import com.embabel.agent.api.tool.ToolCallContext +import com.embabel.agent.api.tool.callback.AfterToolCallContext +import com.embabel.agent.api.tool.callback.BeforeToolCallContext +import com.embabel.agent.api.tool.callback.ToolCallInspector +import com.embabel.agent.spi.loop.ToolCallResult +import com.embabel.agent.spi.loop.ToolInjectionContext +import com.embabel.agent.spi.loop.ToolInjectionStrategy +import com.embabel.chat.Message +import com.embabel.chat.ToolCall +import org.slf4j.Logger +import tools.jackson.databind.ObjectMapper + +/** Result of provider-independent tool execution. */ +internal data class ExecutedToolCall( + val result: Tool.Result, + val content: String, +) + +/** Execute a tool and publish its before and after inspection callbacks. */ +internal fun executeTool( + tool: Tool, + toolCall: ToolCall, + toolCallContext: ToolCallContext, + toolCallInspectors: List, +): ExecutedToolCall { + toolCallInspectors.notifyBeforeToolCall(BeforeToolCallContext(toolCall)) + val started = System.currentTimeMillis() + val result = tool.call(toolCall.arguments, toolCallContext) + val content = when (result) { + is Tool.Result.Text -> result.content + is Tool.Result.WithArtifact -> result.content + is Tool.Result.Error -> "Error: ${result.message}" + } + toolCallInspectors.notifyAfterToolCall( + AfterToolCallContext( + toolCall = toolCall, + result = result, + resultAsString = content, + durationMs = System.currentTimeMillis() - started, + ) + ) + return ExecutedToolCall(result, content) +} + +/** + * Evaluate and apply dynamic tool changes after a tool call. + * + * Centralizing decoration, deduplication, and tracking keeps injection semantics + * consistent for every tool-loop implementation. + */ +internal fun applyToolInjection( + toolCall: ToolCall, + resultContent: String, + conversationHistory: List, + availableTools: MutableList, + iteration: Int, + injectionStrategy: ToolInjectionStrategy, + objectMapper: ObjectMapper, + toolDecorator: ((Tool) -> Tool)?, + injectedTools: MutableList? = null, + removedTools: MutableList? = null, + logger: Logger? = null, +) { + val injection = injectionStrategy.evaluate( + ToolInjectionContext( + conversationHistory = conversationHistory, + currentTools = availableTools, + lastToolCall = ToolCallResult( + toolName = toolCall.name, + toolInput = toolCall.arguments, + result = resultContent, + resultObject = deserializeToolResult(resultContent, objectMapper, logger), + ), + iterationCount = iteration, + ) + ) + if (!injection.hasChanges()) return + + val removedNames = injection.toolsToRemove.map { it.definition.name }.toSet() + availableTools.removeIf { it.definition.name in removedNames } + removedTools?.addAll(injection.toolsToRemove) + if (injection.toolsToRemove.isNotEmpty()) { + logger?.info( + "Strategy removed {} tools after {}: {}", + injection.toolsToRemove.size, + toolCall.name, + removedNames, + ) + } + + val existingNames = availableTools.map { it.definition.name }.toSet() + val additions = injection.toolsToAdd + .map { toolDecorator?.invoke(it) ?: it } + .filter { it.definition.name !in existingNames } + if (injection.toolsToAdd.isNotEmpty() && additions.isEmpty()) { + logger?.debug( + "All {} tools already present after {}, skipping", + injection.toolsToAdd.size, + toolCall.name, + ) + } + availableTools.addAll(additions) + injectedTools?.addAll(additions) + if (additions.isNotEmpty()) { + logger?.info( + "Strategy injected {} tools after {}: {}", + additions.size, + toolCall.name, + additions.map { it.definition.name }, + ) + } +} + +private fun deserializeToolResult( + content: String, + objectMapper: ObjectMapper, + logger: Logger?, +): Any? { + val trimmed = content.trimStart() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null + return try { + objectMapper.readValue(content, Any::class.java) + } catch (e: Exception) { + logger?.debug("Could not deserialize tool result as JSON: {}", e.message) + null + } +} diff --git a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/loop/support/ToolExecutionSupportTest.kt b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/loop/support/ToolExecutionSupportTest.kt new file mode 100644 index 000000000..a726f35a1 --- /dev/null +++ b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/loop/support/ToolExecutionSupportTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed 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 com.embabel.agent.spi.loop.support + +import com.embabel.agent.api.tool.Tool +import com.embabel.agent.api.tool.ToolCallContext +import com.embabel.agent.api.tool.callback.AfterToolCallContext +import com.embabel.agent.api.tool.callback.BeforeToolCallContext +import com.embabel.agent.api.tool.callback.ToolCallInspector +import com.embabel.agent.spi.loop.MockTool +import com.embabel.agent.spi.loop.ToolInjectionContext +import com.embabel.agent.spi.loop.ToolInjectionResult +import com.embabel.agent.spi.loop.ToolInjectionStrategy +import com.embabel.chat.ToolCall +import com.embabel.chat.UserMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tools.jackson.module.kotlin.jacksonObjectMapper + +class ToolExecutionSupportTest { + + @Test + fun `executeTool returns content and publishes callbacks`() { + val callbacks = mutableListOf() + val inspector = object : ToolCallInspector { + override fun beforeToolCall(context: BeforeToolCallContext) { + callbacks += "before:${context.toolCall.name}" + } + + override fun afterToolCall(context: AfterToolCallContext) { + callbacks += "after:${context.resultAsString}" + } + } + val tool = MockTool("lookup", "Lookup") { Tool.Result.text("found") } + + val executed = executeTool( + tool = tool, + toolCall = ToolCall("call-1", "lookup", "{}"), + toolCallContext = ToolCallContext.EMPTY, + toolCallInspectors = listOf(inspector), + ) + + assertTrue(executed.result is Tool.Result.Text) + assertEquals("found", executed.content) + assertEquals(listOf("before:lookup", "after:found"), callbacks) + } + + @Test + fun `applyToolInjection shares decoration deduplication removal and JSON context semantics`() { + val existing = MockTool("existing", "Existing") { Tool.Result.text("existing") } + val removed = MockTool("removed", "Removed") { Tool.Result.text("removed") } + val added = MockTool("added", "Added") { Tool.Result.text("added") } + val decorated = MockTool("added", "Decorated") { Tool.Result.text("decorated") } + val available = mutableListOf(existing, removed) + val injectedTools = mutableListOf() + val removedTools = mutableListOf() + var evaluatedContext: ToolInjectionContext? = null + val strategy = object : ToolInjectionStrategy { + override fun evaluate(context: ToolInjectionContext): ToolInjectionResult { + evaluatedContext = context + return ToolInjectionResult( + toolsToAdd = listOf(existing, added), + toolsToRemove = listOf(removed), + ) + } + } + + applyToolInjection( + toolCall = ToolCall("call-1", "source", "{\"input\":true}"), + resultContent = "{\"answer\":42}", + conversationHistory = listOf(UserMessage("question")), + availableTools = available, + iteration = 3, + injectionStrategy = strategy, + objectMapper = jacksonObjectMapper(), + toolDecorator = { if (it.definition.name == "added") decorated else it }, + injectedTools = injectedTools, + removedTools = removedTools, + ) + + assertEquals(listOf("existing", "added"), available.map { it.definition.name }) + assertTrue(decorated === available.last()) + assertEquals(listOf(decorated), injectedTools) + assertEquals(listOf(removed), removedTools) + assertEquals(3, evaluatedContext?.iterationCount) + assertEquals("source", evaluatedContext?.lastToolCall?.toolName) + assertTrue(evaluatedContext?.lastToolCall?.resultObject is Map<*, *>) + } +}