mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(runtime): harden long-task recovery and checkpoint writes
This commit is contained in:
parent
97125b991e
commit
dd4db18ed8
@ -769,6 +769,7 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
"read_file",
|
"read_file",
|
||||||
"send_file",
|
"send_file",
|
||||||
"write_file",
|
"write_file",
|
||||||
|
"append_file",
|
||||||
"edit_file",
|
"edit_file",
|
||||||
"execute_shell_command",
|
"execute_shell_command",
|
||||||
// Inline code execution — an agent-wide capability alongside shell.
|
// Inline code execution — an agent-wide capability alongside shell.
|
||||||
|
|||||||
@ -2509,11 +2509,41 @@ public class NodeStreamingChatHelper {
|
|||||||
acc.id,
|
acc.id,
|
||||||
acc.type != null ? acc.type : "function",
|
acc.type != null ? acc.type : "function",
|
||||||
acc.name,
|
acc.name,
|
||||||
sanitizeToolCallArguments(acc.name, acc.arguments.toString())));
|
toolCallArgumentsForExecution(acc.name, acc.arguments.toString())));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a streamed tool call for local execution.
|
||||||
|
*
|
||||||
|
* <p>Blank arguments are a common zero-argument representation and remain
|
||||||
|
* normalized to an empty object. Invalid non-blank JSON, however, must be
|
||||||
|
* preserved until {@code ToolExecutionExecutor} sees it; replacing it with
|
||||||
|
* {@code {}} loses the distinction between a truncated stream and a real
|
||||||
|
* empty call and can execute the wrong operation. The outgoing-history
|
||||||
|
* normalization path still calls {@link #sanitizeToolCallArguments} before
|
||||||
|
* a later provider request.</p>
|
||||||
|
*/
|
||||||
|
private static String toolCallArgumentsForExecution(String toolName, String arguments) {
|
||||||
|
if (arguments == null || arguments.isBlank()) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
TOOL_ARG_JSON_MAPPER.readTree(arguments);
|
||||||
|
return arguments;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
|
||||||
|
+ "(len={}, head={}); preserving the payload for safe executor rejection. "
|
||||||
|
+ "Parse error: {}",
|
||||||
|
toolName,
|
||||||
|
arguments.length(),
|
||||||
|
arguments.substring(0, Math.min(80, arguments.length())),
|
||||||
|
e.getMessage());
|
||||||
|
return arguments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure {@code function.arguments} is always a well-formed JSON string.
|
* Ensure {@code function.arguments} is always a well-formed JSON string.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
@ -85,7 +85,7 @@ public class ToolExecutionExecutor {
|
|||||||
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
|
static final int MAX_TOOL_CALLS_PER_RESPONSE = 16;
|
||||||
|
|
||||||
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
|
private static final Set<String> DEFAULT_UNSAFE_TOOLS = Set.of(
|
||||||
"browser_use", "BrowserUseTool", "write_file", "edit_file"
|
"browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file"
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -614,7 +614,7 @@ public class ToolExecutionExecutor {
|
|||||||
} catch (Exception jsonEx) {
|
} catch (Exception jsonEx) {
|
||||||
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
|
||||||
toolName, arguments.length(), jsonEx.getMessage());
|
toolName, arguments.length(), jsonEx.getMessage());
|
||||||
String truncationError = normalizeToolExecutionError(jsonEx);
|
String truncationError = incompleteToolArgumentsError(toolName);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), responseName, truncationError));
|
toolCall.id(), responseName, truncationError));
|
||||||
@ -730,6 +730,17 @@ public class ToolExecutionExecutor {
|
|||||||
rawEvidenceRef.get());
|
rawEvidenceRef.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String incompleteToolArgumentsError(String toolName) {
|
||||||
|
var error = OBJECT_MAPPER.createObjectNode();
|
||||||
|
error.put("error", true);
|
||||||
|
error.put("code", "TOOL_ARGUMENTS_INCOMPLETE");
|
||||||
|
error.put("recoverable", true);
|
||||||
|
error.put("toolName", toolName == null ? "" : toolName);
|
||||||
|
error.put("message", "Tool arguments were incomplete or invalid JSON; the tool was not executed.");
|
||||||
|
error.put("hint", "Retry with a smaller payload. For file updates, prefer edit_file or append_file instead of rewriting the whole file.");
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private static String requestedSkillName(String arguments) {
|
private static String requestedSkillName(String arguments) {
|
||||||
if (arguments == null || arguments.isBlank()) {
|
if (arguments == null || arguments.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -54,7 +54,7 @@ public class ObservationNode implements NodeAction {
|
|||||||
* determined statically, and a false reminder is worse than none.
|
* determined statically, and a false reminder is worse than none.
|
||||||
*/
|
*/
|
||||||
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
|
private static final java.util.Set<String> FILE_MUTATION_TOOLS =
|
||||||
java.util.Set.of("write_file", "edit_file");
|
java.util.Set.of("write_file", "append_file", "edit_file");
|
||||||
|
|
||||||
private static final String VERIFICATION_REMINDER =
|
private static final String VERIFICATION_REMINDER =
|
||||||
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
|
"\n\n[✅ 验证提醒] 本轮修改了文件。在给出最终回答前,请先验证改动是否生效" +
|
||||||
|
|||||||
@ -148,7 +148,7 @@ public class ReasoningNode implements NodeAction {
|
|||||||
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
|
"(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)");
|
||||||
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
|
private static final List<String> ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of(
|
||||||
"renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile",
|
"renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile",
|
||||||
"write_file", "local_write_file", "edit_file", "local_edit_file");
|
"write_file", "append_file", "local_write_file", "edit_file", "local_edit_file");
|
||||||
|
|
||||||
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
|
/** Continuation nudge appended to the prompt when the model returns an empty turn. */
|
||||||
private static final String EMPTY_COMPLETION_NUDGE =
|
private static final String EMPTY_COMPLETION_NUDGE =
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package vip.mate.common.result;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一响应结果封装
|
* 统一响应结果封装
|
||||||
@ -24,12 +25,18 @@ public class R<T> implements Serializable {
|
|||||||
private T data;
|
private T data;
|
||||||
|
|
||||||
/** i18n holder — set once at startup by I18nAutoConfig, used by ok()/fail() */
|
/** i18n holder — set once at startup by I18nAutoConfig, used by ok()/fail() */
|
||||||
private static volatile vip.mate.i18n.I18nService i18n;
|
private static final AtomicReference<vip.mate.i18n.I18nService> I18N = new AtomicReference<>();
|
||||||
|
|
||||||
public static void setI18n(vip.mate.i18n.I18nService service) { i18n = service; }
|
public static void setI18n(vip.mate.i18n.I18nService service) { I18N.set(service); }
|
||||||
|
|
||||||
|
/** Clear a closing context's service without clobbering a newer context. */
|
||||||
|
public static void clearI18n(vip.mate.i18n.I18nService service) {
|
||||||
|
I18N.compareAndSet(service, null);
|
||||||
|
}
|
||||||
|
|
||||||
private static String resolveMsg(ResultCode rc) {
|
private static String resolveMsg(ResultCode rc) {
|
||||||
return i18n != null ? rc.getMsg(i18n) : rc.getMsg();
|
vip.mate.i18n.I18nService service = I18N.get();
|
||||||
|
return service != null ? rc.getMsg(service) : rc.getMsg();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <T> R<T> ok() {
|
public static <T> R<T> ok() {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ public class ToolTimeoutProperties {
|
|||||||
"web_fetch", "web",
|
"web_fetch", "web",
|
||||||
"url_fetch", "web",
|
"url_fetch", "web",
|
||||||
"write_file", "file",
|
"write_file", "file",
|
||||||
|
"append_file", "file",
|
||||||
"edit_file", "file",
|
"edit_file", "file",
|
||||||
"read_file", "file"
|
"read_file", "file"
|
||||||
);
|
);
|
||||||
|
|||||||
@ -35,6 +35,9 @@ public interface GoalService {
|
|||||||
/** Active goal for the conversation, or null. Used by buildInitialState. */
|
/** Active goal for the conversation, or null. Used by buildInitialState. */
|
||||||
GoalEntity findActiveByConversation(String conversationId);
|
GoalEntity findActiveByConversation(String conversationId);
|
||||||
|
|
||||||
|
/** Most recently created goal for the conversation, regardless of status, or null. */
|
||||||
|
GoalEntity findLatestByConversation(String conversationId);
|
||||||
|
|
||||||
/** Paged list filtered by status / owner. */
|
/** Paged list filtered by status / owner. */
|
||||||
List<GoalEntity> list(String status, String username, int limit);
|
List<GoalEntity> list(String status, String username, int limit);
|
||||||
|
|
||||||
|
|||||||
@ -180,6 +180,18 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GoalEntity findLatestByConversation(String conversationId) {
|
||||||
|
if (conversationId == null || conversationId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return goalMapper.selectOne(new LambdaQueryWrapper<GoalEntity>()
|
||||||
|
.eq(GoalEntity::getConversationId, conversationId)
|
||||||
|
.orderByDesc(GoalEntity::getCreateTime)
|
||||||
|
.orderByDesc(GoalEntity::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<GoalEntity> list(String status, String username, int limit) {
|
public List<GoalEntity> list(String status, String username, int limit) {
|
||||||
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()
|
LambdaQueryWrapper<GoalEntity> w = new LambdaQueryWrapper<GoalEntity>()
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package vip.mate.i18n;
|
package vip.mate.i18n;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
@ -21,4 +22,9 @@ public class I18nAutoConfig {
|
|||||||
public void init() {
|
public void init() {
|
||||||
R.setI18n(i18nService);
|
R.setI18n(i18nService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void destroy() {
|
||||||
|
R.clearI18n(i18nService);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,7 +75,7 @@ public class ToolConcurrencyRegistry {
|
|||||||
// Keep the legacy hardcoded names so existing deployments without
|
// Keep the legacy hardcoded names so existing deployments without
|
||||||
// annotations still see the same behavior. New code should rely on
|
// annotations still see the same behavior. New code should rely on
|
||||||
// the @ConcurrencyUnsafe annotation rather than this list.
|
// the @ConcurrencyUnsafe annotation rather than this list.
|
||||||
discovered.addAll(Arrays.asList("browser_use", "BrowserUseTool", "write_file", "edit_file"));
|
discovered.addAll(Arrays.asList("browser_use", "BrowserUseTool", "write_file", "append_file", "edit_file"));
|
||||||
this.unsafeNames = Collections.unmodifiableSet(discovered);
|
this.unsafeNames = Collections.unmodifiableSet(discovered);
|
||||||
log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}",
|
log.info("[ToolConcurrencyRegistry] Concurrency-unsafe tools ({}): {}",
|
||||||
unsafeNames.size(), unsafeNames);
|
unsafeNames.size(), unsafeNames);
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.i18n.I18nService;
|
||||||
|
import vip.mate.tool.ConcurrencyUnsafe;
|
||||||
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
|
||||||
|
/** Append-only file mutation with retry idempotency and an optional tail precondition. */
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppendFileTool {
|
||||||
|
|
||||||
|
private final I18nService i18n;
|
||||||
|
|
||||||
|
@ConcurrencyUnsafe("append file — must serialize with reads/writes on overlapping paths")
|
||||||
|
@Tool(description = "Append a small content block to a file without rewriting existing content. "
|
||||||
|
+ "Creates the file and parent directories when absent. If the file already ends with the exact "
|
||||||
|
+ "content, the retry succeeds without writing it again. expectedTail can prevent appending to a "
|
||||||
|
+ "file that changed since it was read. Returns structured JSON.")
|
||||||
|
public String append_file(
|
||||||
|
@ToolParam(description = "Absolute or relative file path") String filePath,
|
||||||
|
@ToolParam(description = "Content block to append; send only the new content") String content,
|
||||||
|
@ToolParam(description = "Optional exact suffix that must currently end the file", required = false)
|
||||||
|
String expectedTail,
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
|
if (filePath == null || filePath.isBlank()) {
|
||||||
|
return error(filePath, "INVALID_ARGUMENT", i18n.msg("tool.write_file.error.path_empty"));
|
||||||
|
}
|
||||||
|
if (content == null || content.isEmpty()) {
|
||||||
|
return error(filePath, "INVALID_ARGUMENT", "content must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path path = WorkspacePathGuard.validatePath(filePath, ctx);
|
||||||
|
if (Files.isDirectory(path)) {
|
||||||
|
return error(filePath, "IS_DIRECTORY", i18n.msg("tool.write_file.error.is_directory", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
Path parent = path.getParent();
|
||||||
|
if (parent != null) Files.createDirectories(parent);
|
||||||
|
|
||||||
|
boolean existed = Files.exists(path);
|
||||||
|
String current = existed ? Files.readString(path, StandardCharsets.UTF_8) : "";
|
||||||
|
if (current.endsWith(content)) {
|
||||||
|
JSONObject result = baseResult(filePath);
|
||||||
|
result.set("bytesWritten", 0);
|
||||||
|
result.set("created", false);
|
||||||
|
result.set("alreadyApplied", true);
|
||||||
|
result.set("message", "Content already present at file tail; no write needed");
|
||||||
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
|
}
|
||||||
|
if (expectedTail != null && !current.endsWith(expectedTail)) {
|
||||||
|
return error(filePath, "PRECONDITION_FAILED",
|
||||||
|
"File tail changed; re-read the file before appending");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
||||||
|
Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
|
||||||
|
JSONObject result = baseResult(filePath);
|
||||||
|
result.set("bytesWritten", bytes.length);
|
||||||
|
result.set("created", !existed);
|
||||||
|
result.set("alreadyApplied", false);
|
||||||
|
result.set("message", "Appended: " + path + " (" + bytes.length + " bytes)");
|
||||||
|
log.info("[AppendFile] Appended {} bytes to {}", bytes.length, path);
|
||||||
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return error(filePath, "PATH_REJECTED", e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[AppendFile] Failed to append file: {}", e.getMessage(), e);
|
||||||
|
return error(filePath, "APPEND_FAILED",
|
||||||
|
i18n.msg("tool.write_file.error.write_exception", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject baseResult(String filePath) {
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.set("filePath", filePath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String error(String filePath, String code, String message) {
|
||||||
|
JSONObject result = baseResult(filePath);
|
||||||
|
result.set("error", true);
|
||||||
|
result.set("code", code);
|
||||||
|
result.set("message", message);
|
||||||
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -192,7 +192,24 @@ public class GoalManagementTool {
|
|||||||
if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled");
|
if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled");
|
||||||
GoalEntity goal = resolveActive(ctx);
|
GoalEntity goal = resolveActive(ctx);
|
||||||
if (goal == null) {
|
if (goal == null) {
|
||||||
return successJson(Map.of("active", false));
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
|
GoalEntity latest = origin != null && origin.conversationId() != null
|
||||||
|
? goalService.findLatestByConversation(origin.conversationId()) : null;
|
||||||
|
if (latest == null) {
|
||||||
|
return successJson(Map.of(
|
||||||
|
"active", false,
|
||||||
|
"recoverable", false,
|
||||||
|
"reason", "no_goal_on_conversation"));
|
||||||
|
}
|
||||||
|
boolean recoverable = latest.getStatus() == GoalStatus.PAUSED;
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("active", false);
|
||||||
|
out.put("goalId", String.valueOf(latest.getId()));
|
||||||
|
out.put("title", latest.getTitle());
|
||||||
|
out.put("status", latest.getStatus().getValue());
|
||||||
|
out.put("recoverable", recoverable);
|
||||||
|
out.put("reason", "latest_goal_" + latest.getStatus().getValue());
|
||||||
|
return successJson(out);
|
||||||
}
|
}
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
out.put("active", true);
|
out.put("active", true);
|
||||||
|
|||||||
@ -34,6 +34,7 @@ public class DefaultToolGuard implements ToolGuard {
|
|||||||
/** 文件写入类工具 —— 默认需要用户审批 */
|
/** 文件写入类工具 —— 默认需要用户审批 */
|
||||||
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
||||||
"write_file",
|
"write_file",
|
||||||
|
"append_file",
|
||||||
"edit_file"
|
"edit_file"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ public class FilePathGuardian implements ToolGuardGuardian {
|
|||||||
private static final Map<String, String> TOOL_FILE_PARAMS = Map.of(
|
private static final Map<String, String> TOOL_FILE_PARAMS = Map.of(
|
||||||
"read_file", "filePath",
|
"read_file", "filePath",
|
||||||
"write_file", "filePath",
|
"write_file", "filePath",
|
||||||
|
"append_file", "filePath",
|
||||||
"edit_file", "filePath",
|
"edit_file", "filePath",
|
||||||
"file_read", "file_path",
|
"file_read", "file_path",
|
||||||
"file_write", "file_path"
|
"file_write", "file_path"
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import java.util.Set;
|
|||||||
public class FileWriteGuardian implements ToolGuardGuardian {
|
public class FileWriteGuardian implements ToolGuardGuardian {
|
||||||
|
|
||||||
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
private static final Set<String> FILE_WRITE_TOOL_NAMES = Set.of(
|
||||||
"write_file", "edit_file"
|
"write_file", "append_file", "edit_file"
|
||||||
);
|
);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -65,6 +65,7 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
|
|||||||
private static final Map<String, String> FILE_PATH_PARAMS = Map.of(
|
private static final Map<String, String> FILE_PATH_PARAMS = Map.of(
|
||||||
"read_file", "filePath",
|
"read_file", "filePath",
|
||||||
"write_file", "filePath",
|
"write_file", "filePath",
|
||||||
|
"append_file", "filePath",
|
||||||
"edit_file", "filePath"
|
"edit_file", "filePath"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,7 @@ public class ToolGuardRuleSeedService implements ApplicationRunner {
|
|||||||
private static final Map<String, String> TOOL_NAME_RENAMES = Map.of(
|
private static final Map<String, String> TOOL_NAME_RENAMES = Map.of(
|
||||||
"ShellExecuteTool", "execute_shell_command",
|
"ShellExecuteTool", "execute_shell_command",
|
||||||
"WriteFileTool", "write_file",
|
"WriteFileTool", "write_file",
|
||||||
|
"AppendFileTool", "append_file",
|
||||||
"EditFileTool", "edit_file"
|
"EditFileTool", "edit_file"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -78,8 +78,8 @@ class NodeStreamingChatHelperToolCallArgsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Truncated/invalid JSON arguments normalized to '{}'")
|
@DisplayName("Truncated/invalid JSON arguments preserved for executor rejection")
|
||||||
void truncatedJsonArguments_replacedWithEmptyJsonObject() {
|
void truncatedJsonArguments_preservedForExecutor() {
|
||||||
// Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped.
|
// Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped.
|
||||||
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
||||||
"id-truncated", "function", "search", "{\"q\":\"hel");
|
"id-truncated", "function", "search", "{\"q\":\"hel");
|
||||||
@ -94,9 +94,9 @@ class NodeStreamingChatHelperToolCallArgsTest {
|
|||||||
|
|
||||||
assertTrue(result.hasToolCalls(), "tool call must survive");
|
assertTrue(result.hasToolCalls(), "tool call must survive");
|
||||||
assertEquals(1, result.toolCalls().size());
|
assertEquals(1, result.toolCalls().size());
|
||||||
assertEquals("{}", result.toolCalls().get(0).arguments(),
|
assertEquals("{\"q\":\"hel", result.toolCalls().get(0).arguments(),
|
||||||
"invalid JSON arguments must be replaced with '{}' so the follow-up "
|
"invalid streamed arguments must reach the executor so it can reject "
|
||||||
+ "request stays well-formed");
|
+ "the call without invoking the tool");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -147,6 +147,24 @@ class NodeStreamingChatHelperToolCallArgsTest {
|
|||||||
"tool call id must be preserved so the tool_call pairing holds");
|
"tool call id must be preserved so the tool_call pairing holds");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Prompt-history invalid arguments are normalized before provider replay")
|
||||||
|
void promptHistory_invalidArguments_normalizedBeforeSend() {
|
||||||
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
||||||
|
"id-hist-invalid", "function", "write_file", "{\"filePath\":\"x");
|
||||||
|
AssistantMessage historyMsg = AssistantMessage.builder()
|
||||||
|
.content("")
|
||||||
|
.toolCalls(List.of(tc))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(
|
||||||
|
new Prompt(List.of(new UserMessage("hi"), historyMsg)));
|
||||||
|
|
||||||
|
AssistantMessage out = (AssistantMessage) normalized.getInstructions().get(1);
|
||||||
|
assertEquals("{}", out.getToolCalls().get(0).arguments(),
|
||||||
|
"strict providers must never receive invalid JSON in replayed history");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Prompt with only valid tool-call arguments returned unchanged")
|
@DisplayName("Prompt with only valid tool-call arguments returned unchanged")
|
||||||
void promptHistory_validArguments_returnsSameInstance() {
|
void promptHistory_validArguments_returnsSameInstance() {
|
||||||
|
|||||||
@ -145,6 +145,24 @@ class ToolExecutionExecutorNameNormalizationTest {
|
|||||||
assertEquals("ok:read_file", result.responses().get(0).responseData());
|
assertEquals("ok:read_file", result.responses().get(0).responseData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("invalid streamed arguments return a stable rejection without executing the tool")
|
||||||
|
void invalidArgumentsRejectedWithoutExecution() {
|
||||||
|
ToolCallback callback = callbackNamed("write_file");
|
||||||
|
ToolExecutionExecutor executor = newExecutor(callback);
|
||||||
|
|
||||||
|
var result = executor.execute(
|
||||||
|
List.of(new AssistantMessage.ToolCall(
|
||||||
|
"call_invalid", "function", "write_file", "{\"filePath\":\"notes.md\"")),
|
||||||
|
"conv", "agent", false, "user", null);
|
||||||
|
|
||||||
|
assertEquals(1, result.responses().size());
|
||||||
|
assertTrue(result.responses().get(0).responseData().contains("TOOL_ARGUMENTS_INCOMPLETE"));
|
||||||
|
assertTrue(result.responses().get(0).responseData().contains("append_file"));
|
||||||
|
verify(callback, never()).call(anyString(), any());
|
||||||
|
verify(callback, never()).call(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("tool_call unwraps and executes the real tool in the same action round")
|
@DisplayName("tool_call unwraps and executes the real tool in the same action round")
|
||||||
void progressiveBridge_executesTargetSameRound() {
|
void progressiveBridge_executesTargetSameRound() {
|
||||||
|
|||||||
@ -661,6 +661,13 @@ class GoalServiceTest {
|
|||||||
verify(goalMapper, never()).selectOne(any());
|
verify(goalMapper, never()).selectOne(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findLatestByConversation_returnsNull_forBlankInput() {
|
||||||
|
assertNull(service.findLatestByConversation(""));
|
||||||
|
assertNull(service.findLatestByConversation(null));
|
||||||
|
verify(goalMapper, never()).selectOne(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getById_throws404_whenMissing() {
|
void getById_throws404_whenMissing() {
|
||||||
when(goalMapper.selectById(1L)).thenReturn(null);
|
when(goalMapper.selectById(1L)).thenReturn(null);
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
package vip.mate.i18n;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class I18nAutoConfigTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearHolder() {
|
||||||
|
R.setI18n(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closingContextClearsItsService() {
|
||||||
|
I18nService service = localized("localized-success");
|
||||||
|
I18nAutoConfig config = new I18nAutoConfig(service);
|
||||||
|
config.init();
|
||||||
|
|
||||||
|
config.destroy();
|
||||||
|
|
||||||
|
assertEquals("result.success", R.ok().getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closingOlderContextDoesNotClearNewerService() {
|
||||||
|
I18nAutoConfig older = new I18nAutoConfig(localized("older"));
|
||||||
|
I18nAutoConfig newer = new I18nAutoConfig(localized("newer"));
|
||||||
|
older.init();
|
||||||
|
newer.init();
|
||||||
|
|
||||||
|
older.destroy();
|
||||||
|
|
||||||
|
assertEquals("newer", R.ok().getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static I18nService localized(String message) {
|
||||||
|
I18nService service = mock(I18nService.class);
|
||||||
|
when(service.msg("result.success")).thenReturn(message);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -214,7 +214,7 @@ class TeamRunProjectorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void fallbackAndStopReasonProduceAttentionWithHumanActionFirst() {
|
void fallbackDoesNotInflateAttentionAndStopReasonKeepsHumanActionFirst() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
TeamRunEntity run = run(TeamRunStatus.CANCELLED, "{\"summaryQuality\":\"fallback\"}");
|
TeamRunEntity run = run(TeamRunStatus.CANCELLED, "{\"summaryQuality\":\"fallback\"}");
|
||||||
run.setFinalSummary("raw results");
|
run.setFinalSummary("raw results");
|
||||||
@ -233,7 +233,7 @@ class TeamRunProjectorTest {
|
|||||||
TeamRunView view = projector.project(RUN_ID);
|
TeamRunView view = projector.project(RUN_ID);
|
||||||
|
|
||||||
assertEquals("review", view.attentionItems().getFirst().type());
|
assertEquals("review", view.attentionItems().getFirst().type());
|
||||||
assertTrue(view.attentionItems().stream().anyMatch(item -> "synthesis".equals(item.type())));
|
assertTrue(view.attentionItems().stream().noneMatch(item -> "synthesis".equals(item.type())));
|
||||||
assertTrue(view.attentionItems().stream().anyMatch(item -> "stopped".equals(item.type())));
|
assertTrue(view.attentionItems().stream().anyMatch(item -> "stopped".equals(item.type())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,6 +68,41 @@ class FileMutationToolContextTest {
|
|||||||
assertThat(defaultRoot.resolve("deck.md")).doesNotExist();
|
assertThat(defaultRoot.resolve("deck.md")).doesNotExist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("append_file is workspace-scoped and duplicate retries are idempotent")
|
||||||
|
void appendFileUsesWorkspaceAndDeduplicatesRetry(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path defaultRoot = Files.createDirectory(tempDir.resolve("default-root"));
|
||||||
|
Path contextRoot = Files.createDirectory(tempDir.resolve("context-root"));
|
||||||
|
WorkspacePathGuard.setDefaultRoot(defaultRoot.toString());
|
||||||
|
Files.writeString(contextRoot.resolve("checkpoints.md"), "# Checkpoints\n", StandardCharsets.UTF_8);
|
||||||
|
AppendFileTool tool = new AppendFileTool(i18n());
|
||||||
|
var ctx = ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext();
|
||||||
|
|
||||||
|
String first = tool.append_file("checkpoints.md", "\n## CHK-020\nDone\n", "# Checkpoints\n", ctx);
|
||||||
|
String retry = tool.append_file("checkpoints.md", "\n## CHK-020\nDone\n", null, ctx);
|
||||||
|
|
||||||
|
assertThat(JSONUtil.parseObj(first).getBool("error", false)).isFalse();
|
||||||
|
assertThat(JSONUtil.parseObj(retry).getBool("alreadyApplied", false)).isTrue();
|
||||||
|
assertThat(contextRoot.resolve("checkpoints.md"))
|
||||||
|
.hasContent("# Checkpoints\n\n## CHK-020\nDone\n");
|
||||||
|
assertThat(defaultRoot.resolve("checkpoints.md")).doesNotExist();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("append_file rejects a stale expected tail without changing the file")
|
||||||
|
void appendFileRejectsExpectedTailMismatch(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path contextRoot = Files.createDirectory(tempDir.resolve("context-root"));
|
||||||
|
Path file = contextRoot.resolve("checkpoints.md");
|
||||||
|
Files.writeString(file, "current tail", StandardCharsets.UTF_8);
|
||||||
|
AppendFileTool tool = new AppendFileTool(i18n());
|
||||||
|
|
||||||
|
String result = tool.append_file("checkpoints.md", "new section", "different tail",
|
||||||
|
ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext());
|
||||||
|
|
||||||
|
assertThat(JSONUtil.parseObj(result).getStr("code")).isEqualTo("PRECONDITION_FAILED");
|
||||||
|
assertThat(file).hasContent("current tail");
|
||||||
|
}
|
||||||
|
|
||||||
private static I18nService i18n() {
|
private static I18nService i18n() {
|
||||||
return mock(I18nService.class, inv ->
|
return mock(I18nService.class, inv ->
|
||||||
"msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null);
|
"msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null);
|
||||||
|
|||||||
@ -165,6 +165,33 @@ class GoalManagementToolTest {
|
|||||||
assertTrue(result.contains("\"active\":false"));
|
assertTrue(result.contains("\"active\":false"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGoalStatus_completedLatestExplainsTerminalState() {
|
||||||
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(null);
|
||||||
|
GoalEntity completed = goal(GoalStatus.COMPLETED);
|
||||||
|
when(goalService.findLatestByConversation("conv-1")).thenReturn(completed);
|
||||||
|
|
||||||
|
String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice"));
|
||||||
|
|
||||||
|
assertTrue(result.contains("\"active\":false"));
|
||||||
|
assertTrue(result.contains("\"status\":\"completed\""));
|
||||||
|
assertTrue(result.contains("\"recoverable\":false"));
|
||||||
|
assertTrue(result.contains("latest_goal_completed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGoalStatus_pausedLatestIsResumable() {
|
||||||
|
when(goalService.findActiveByConversation("conv-1")).thenReturn(null);
|
||||||
|
GoalEntity paused = goal(GoalStatus.PAUSED);
|
||||||
|
when(goalService.findLatestByConversation("conv-1")).thenReturn(paused);
|
||||||
|
|
||||||
|
String result = tool.getGoalStatus(ctxWith("conv-1", 10L, "alice"));
|
||||||
|
|
||||||
|
assertTrue(result.contains("\"status\":\"paused\""));
|
||||||
|
assertTrue(result.contains("\"recoverable\":true"));
|
||||||
|
assertTrue(result.contains("latest_goal_paused"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getGoalStatus_active_carriesProgressSummary() {
|
void getGoalStatus_active_carriesProgressSummary() {
|
||||||
GoalEntity g = goal(GoalStatus.ACTIVE);
|
GoalEntity g = goal(GoalStatus.ACTIVE);
|
||||||
|
|||||||
@ -168,6 +168,15 @@ class DefaultToolGuardTest {
|
|||||||
assertFalse(result.isBlocked());
|
assertFalse(result.isBlocked());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("append_file follows the file-write approval policy")
|
||||||
|
void shouldRequireApprovalForAppendFile() {
|
||||||
|
ToolGuardResult result = toolGuard.check("append_file",
|
||||||
|
"{\"filePath\":\"notes.md\",\"content\":\"new\"}");
|
||||||
|
assertFalse(result.isBlocked());
|
||||||
|
assertTrue(result.needsApproval());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("允许带 WHERE 的 DELETE")
|
@DisplayName("允许带 WHERE 的 DELETE")
|
||||||
void shouldAllowFilteredDelete() {
|
void shouldAllowFilteredDelete() {
|
||||||
|
|||||||
@ -48,8 +48,12 @@ class WorkspaceBoundaryGuardianTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ToolInvocationContext write(String path, String basePath) {
|
private ToolInvocationContext write(String path, String basePath) {
|
||||||
|
return fileMutation("write_file", path, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolInvocationContext fileMutation(String toolName, String path, String basePath) {
|
||||||
String args = "{\"filePath\":\"" + path + "\",\"content\":\"x\"}";
|
String args = "{\"filePath\":\"" + path + "\",\"content\":\"x\"}";
|
||||||
return ToolInvocationContext.of("write_file", args, "conv", "agent")
|
return ToolInvocationContext.of(toolName, args, "conv", "agent")
|
||||||
.withWorkspaceBasePath(basePath);
|
.withWorkspaceBasePath(basePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,6 +160,14 @@ class WorkspaceBoundaryGuardianTest {
|
|||||||
assertTrue(guardian.evaluate(write(WORKSPACE + "/notes.txt", WORKSPACE)).isEmpty());
|
assertTrue(guardian.evaluate(write(WORKSPACE + "/notes.txt", WORKSPACE)).isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("append_file uses the same workspace boundary as write_file")
|
||||||
|
void appendFileBoundaryEnforced() {
|
||||||
|
assertBlocked(guardian.evaluate(fileMutation("append_file", "/etc/evil.conf", WORKSPACE)));
|
||||||
|
assertTrue(guardian.evaluate(fileMutation(
|
||||||
|
"append_file", WORKSPACE + "/checkpoints.md", WORKSPACE)).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("write_file with a relative path resolves against the workspace, not the process CWD (issue #494)")
|
@DisplayName("write_file with a relative path resolves against the workspace, not the process CWD (issue #494)")
|
||||||
void writeRelativePath_pass() {
|
void writeRelativePath_pass() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user