fix: pass workspace context to tool guard (#617)

This commit is contained in:
matevip 2026-08-24 02:43:57 -04:00
parent dd7e561e48
commit e88be95cd2
2 changed files with 82 additions and 0 deletions

View File

@ -483,6 +483,12 @@ public class ToolExecutionExecutor {
ChatOrigin origin,
Set<String> loadedSkills) {
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) {
safeOrigin = safeOrigin.withConversationId(conversationId);
}
if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) {
safeOrigin = safeOrigin.withWorkspace(safeOrigin.workspaceId(), workspaceBasePath);
}
// Reset per-turn audit dedupe state. A retried denied tool inside the
// same turn writes a single audit row; the set is repopulated by the
// denial branch below.
@ -1297,6 +1303,10 @@ public class ToolExecutionExecutor {
return GuardDecision.allowed();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
/**
* Deny an approval-required tool when the run is non-interactive (no human can
* approve), returning an actionable message so the agent falls back to a

View File

@ -0,0 +1,72 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.tool.guard.model.GuardEvaluation;
import vip.mate.tool.guard.model.ToolInvocationContext;
import vip.mate.tool.guard.service.ToolGuardService;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ToolExecutionExecutorWorkspaceGuardContextTest {
@Test
@DisplayName("guard evaluation receives state workspaceBasePath when origin has no base path (#617)")
void guardUsesWorkspaceBasePathArgumentWhenOriginIsBlank(@TempDir Path workspaceRoot) {
ToolGuardService guardService = mock(ToolGuardService.class);
when(guardService.evaluate(any(ToolInvocationContext.class), eq(true)))
.thenReturn(GuardEvaluation.allow("write_file"));
ToolExecutionExecutor executor = new ToolExecutionExecutor(
AgentToolSet.fromCallbacks(List.of(), List.of(stub("write_file"))),
guardService,
null,
null);
executor.execute(
List.of(new AssistantMessage.ToolCall(
"call_1", "function", "write_file",
"{\"filePath\":\"deck.md\",\"content\":\"# Deck\"}")),
"conv-617",
"agent-617",
false,
"alice",
workspaceRoot.toString(),
ChatOrigin.web("conv-617", "alice", 1L, null));
ArgumentCaptor<ToolInvocationContext> captor = ArgumentCaptor.forClass(ToolInvocationContext.class);
verify(guardService).evaluate(captor.capture(), eq(true));
assertThat(captor.getValue().workspaceBasePath()).isEqualTo(workspaceRoot.toString());
}
private static ToolCallback stub(String name) {
ToolDefinition def = ToolDefinition.builder()
.name(name)
.description("test tool " + name)
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
.build();
ToolMetadata md = ToolMetadata.builder().returnDirect(false).build();
return new ToolCallback() {
@Override public ToolDefinition getToolDefinition() { return def; }
@Override public ToolMetadata getToolMetadata() { return md; }
@Override public String call(String arguments) { return "ok"; }
@Override public String call(String arguments, ToolContext toolContext) { return "ok"; }
};
}
}