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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -349,100 +345,32 @@ internal open class DefaultToolLoop(
toolCall: ToolCall,
): Pair<Tool.Result, String> {
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(
Comment thread
arnabnandy7 marked this conversation as resolved.
tool = tool,
toolCall = toolCall,
toolCallContext = toolCallContext,
toolCallInspectors = toolCallInspectors,
)

return result to content
return executed.result to executed.content
}

protected fun applyInjectionStrategy(
toolCall: ToolCall,
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<Tool>,
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<Tool>,
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,
)
}

Expand Down Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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. */
Comment thread
arnabnandy7 marked this conversation as resolved.
internal data class ExecutedToolCall(
Comment thread
arnabnandy7 marked this conversation as resolved.
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<ToolCallInspector>,
): 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<Message>,
availableTools: MutableList<Tool>,
iteration: Int,
injectionStrategy: ToolInjectionStrategy,
objectMapper: ObjectMapper,
toolDecorator: ((Tool) -> Tool)?,
injectedTools: MutableList<Tool>? = null,
removedTools: MutableList<Tool>? = 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
}
}
Loading