mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
fix(agent): harden tool completion and team retries (#606)
This commit is contained in:
parent
2372827762
commit
dd7e561e48
@ -23,6 +23,7 @@ import vip.mate.approval.grant.AutoApproveResult;
|
|||||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||||
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
import vip.mate.tool.ToolInputValidationException;
|
||||||
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
||||||
import vip.mate.tool.guard.ToolGuard;
|
import vip.mate.tool.guard.ToolGuard;
|
||||||
import vip.mate.tool.guard.ToolGuardResult;
|
import vip.mate.tool.guard.ToolGuardResult;
|
||||||
@ -465,6 +466,22 @@ public class ToolExecutionExecutor {
|
|||||||
boolean isReplay, String requesterId,
|
boolean isReplay, String requesterId,
|
||||||
String workspaceBasePath,
|
String workspaceBasePath,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
|
return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
|
||||||
|
workspaceBasePath, origin, Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferred graph overload. {@code loadedSkills} is the conversation/run
|
||||||
|
* state captured before this batch, allowing the shared executor to reject
|
||||||
|
* both cross-iteration and same-batch duplicate {@code load_skill} calls
|
||||||
|
* before parallel execution starts.
|
||||||
|
*/
|
||||||
|
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
|
String conversationId, String agentId,
|
||||||
|
boolean isReplay, String requesterId,
|
||||||
|
String workspaceBasePath,
|
||||||
|
ChatOrigin origin,
|
||||||
|
Set<String> loadedSkills) {
|
||||||
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
// Reset per-turn audit dedupe state. A retried denied tool inside the
|
// 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
|
// same turn writes a single audit row; the set is repopulated by the
|
||||||
@ -511,6 +528,15 @@ public class ToolExecutionExecutor {
|
|||||||
// ═══ Phase 1: 顺序 Guard + 分段 ═══
|
// ═══ Phase 1: 顺序 Guard + 分段 ═══
|
||||||
List<PreparedToolCall> preparedCalls = new ArrayList<>();
|
List<PreparedToolCall> preparedCalls = new ArrayList<>();
|
||||||
ApprovalBarrier barrier = null;
|
ApprovalBarrier barrier = null;
|
||||||
|
Set<String> seenSkillLoads = new LinkedHashSet<>();
|
||||||
|
if (loadedSkills != null) {
|
||||||
|
loadedSkills.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(name -> !name.isEmpty())
|
||||||
|
.map(name -> name.toLowerCase(Locale.ROOT))
|
||||||
|
.forEach(seenSkillLoads::add);
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = 0; i < effectiveCalls.size(); i++) {
|
for (int i = 0; i < effectiveCalls.size(); i++) {
|
||||||
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
|
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
|
||||||
@ -590,6 +616,26 @@ public class ToolExecutionExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load_skill is retrieval-only and concurrency-safe, so identical
|
||||||
|
// calls in one model response would otherwise race through the
|
||||||
|
// parallel phase and read/record the same skill twice. Keep this in
|
||||||
|
// the shared executor so both ActionNode and plan execution receive
|
||||||
|
// identical protection while preserving one response per call id.
|
||||||
|
if ("load_skill".equals(toolName)) {
|
||||||
|
String requestedSkill = requestedSkillName(arguments);
|
||||||
|
if (requestedSkill != null
|
||||||
|
&& !seenSkillLoads.add(requestedSkill.toLowerCase(Locale.ROOT))) {
|
||||||
|
String message = "Skill '" + requestedSkill + "' was already loaded earlier in this run. "
|
||||||
|
+ "Reuse the SKILL.md content already present in the conversation; "
|
||||||
|
+ "do not call load_skill for this skill again.";
|
||||||
|
log.debug("[ToolExecutor] Skipping duplicate load_skill({})", requestedSkill);
|
||||||
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, message, true));
|
||||||
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
|
toolCall.id(), responseName, message));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. ToolGuard 安全检查(replay 模式跳过)
|
// 2. ToolGuard 安全检查(replay 模式跳过)
|
||||||
if (!isReplay) {
|
if (!isReplay) {
|
||||||
GuardDecision decision = evaluateGuard(toolCall, toolName, arguments,
|
GuardDecision decision = evaluateGuard(toolCall, toolName, arguments,
|
||||||
@ -678,6 +724,23 @@ public class ToolExecutionExecutor {
|
|||||||
rawEvidenceRef.get());
|
rawEvidenceRef.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String requestedSkillName(String arguments) {
|
||||||
|
if (arguments == null || arguments.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var node = OBJECT_MAPPER.readTree(arguments);
|
||||||
|
var value = node.get("skillName");
|
||||||
|
if (value == null || value.isNull() || value.asText().isBlank()) {
|
||||||
|
value = node.get("name");
|
||||||
|
}
|
||||||
|
return value == null || value.isNull() || value.asText().isBlank()
|
||||||
|
? null : value.asText().trim();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a pre-approved tool call (used by StepExecutionNode's replay path
|
* Execute a pre-approved tool call (used by StepExecutionNode's replay path
|
||||||
* after a user approves a previously-blocked invocation).
|
* after a user approves a previously-blocked invocation).
|
||||||
@ -767,6 +830,13 @@ public class ToolExecutionExecutor {
|
|||||||
}
|
}
|
||||||
events.add(GraphEventPublisher.toolDirectResult(
|
events.add(GraphEventPublisher.toolDirectResult(
|
||||||
toolCall.id(), toolName, fullResult));
|
toolCall.id(), toolName, fullResult));
|
||||||
|
// A direct result replaces the tool card's body, but the
|
||||||
|
// started event still needs a terminal pair so live clients
|
||||||
|
// do not leave the card spinning forever. The placeholder is
|
||||||
|
// deliberately used here: the full result remains confined to
|
||||||
|
// tool_direct_result / DIRECT_TOOL_OUTPUTS.
|
||||||
|
events.add(GraphEventPublisher.toolComplete(
|
||||||
|
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||||
}
|
}
|
||||||
@ -789,9 +859,12 @@ public class ToolExecutionExecutor {
|
|||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
|
||||||
String safeError = isReturnDirect(callback)
|
String validationError = safeInputValidationMessage(e);
|
||||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
String safeError = validationError != null
|
||||||
: "Tool execution failed: " + e.getMessage();
|
? validationError
|
||||||
|
: isReturnDirect(callback)
|
||||||
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
|
: "Tool execution failed: " + e.getMessage();
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false));
|
||||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError);
|
||||||
} finally {
|
} finally {
|
||||||
@ -1019,8 +1092,14 @@ public class ToolExecutionExecutor {
|
|||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId,
|
streamTracker.broadcastObject(pc.conversationId,
|
||||||
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data());
|
GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data());
|
||||||
|
streamTracker.broadcastObject(pc.conversationId,
|
||||||
|
GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||||
|
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName,
|
||||||
|
DIRECT_TOOL_PLACEHOLDER, true).data());
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
|
events.add(GraphEventPublisher.toolComplete(
|
||||||
|
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
||||||
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
||||||
// for OpenAI-compatible providers, while withholding the data from
|
// for OpenAI-compatible providers, while withholding the data from
|
||||||
// any subsequent LLM round (the graph won't take a next round —
|
// any subsequent LLM round (the graph won't take a next round —
|
||||||
@ -1080,9 +1159,12 @@ public class ToolExecutionExecutor {
|
|||||||
// or other sensitive substrings that should not enter LLM context.
|
// or other sensitive substrings that should not enter LLM context.
|
||||||
// Emit a generic placeholder instead. Full error still goes to logs
|
// Emit a generic placeholder instead. Full error still goes to logs
|
||||||
// for operator diagnosis.
|
// for operator diagnosis.
|
||||||
String reportedError = isReturnDirect(pc.callback)
|
String validationError = safeInputValidationMessage(e);
|
||||||
? "Tool execution failed (details withheld per returnDirect policy)"
|
String reportedError = validationError != null
|
||||||
: normalizeToolExecutionError(e);
|
? validationError
|
||||||
|
: isReturnDirect(pc.callback)
|
||||||
|
? "Tool execution failed (details withheld per returnDirect policy)"
|
||||||
|
: normalizeToolExecutionError(e);
|
||||||
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
|
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false));
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||||
@ -1304,6 +1386,17 @@ public class ToolExecutionExecutor {
|
|||||||
return "Tool execution failed: " + message;
|
return "Tool execution failed: " + message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String safeInputValidationMessage(Throwable error) {
|
||||||
|
Throwable current = error;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof ToolInputValidationException validation) {
|
||||||
|
return "Tool input validation failed: " + validation.getMessage();
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #46 — when a tool callback miss happens, check whether the
|
* Issue #46 — when a tool callback miss happens, check whether the
|
||||||
* unrecognized name actually matches an active skill. If it does, return
|
* unrecognized name actually matches an active skill. If it does, return
|
||||||
|
|||||||
@ -144,7 +144,8 @@ public class ActionNode implements NodeAction {
|
|||||||
|
|
||||||
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
||||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
|
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin,
|
||||||
|
accessor.loadedSkills());
|
||||||
|
|
||||||
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
||||||
.responses(result.responses())
|
.responses(result.responses())
|
||||||
|
|||||||
@ -623,7 +623,9 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
|
||||||
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
|
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
|
||||||
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
|
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
|
||||||
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。"));
|
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。\n"
|
||||||
|
+ "5. 不要创建专门的‘最终汇总/总结/验收’成员步骤;系统会在所有任务结束后自动汇总。"
|
||||||
|
+ "把必要的自检和验收标准写进实际产出步骤,避免为了复述结果增加串行任务。"));
|
||||||
} else {
|
} else {
|
||||||
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
|
||||||
if (!delegatable.isEmpty()) {
|
if (!delegatable.isEmpty()) {
|
||||||
|
|||||||
@ -368,7 +368,8 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
} else {
|
} else {
|
||||||
// 非预批准工具走正常执行器
|
// 非预批准工具走正常执行器
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath,
|
||||||
|
chatOrigin, loadedSkills);
|
||||||
toolResponses.addAll(execResult.responses());
|
toolResponses.addAll(execResult.responses());
|
||||||
events.addAll(execResult.events());
|
events.addAll(execResult.events());
|
||||||
if (execResult.hasDirectOutputs()) {
|
if (execResult.hasDirectOutputs()) {
|
||||||
@ -383,18 +384,10 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
||||||
List<AssistantMessage.ToolCall> executableToolCalls = new ArrayList<>();
|
if (!allToolCalls.isEmpty()) {
|
||||||
for (AssistantMessage.ToolCall toolCall : allToolCalls) {
|
|
||||||
String alreadyLoadedSkill = alreadyLoadedSkillName(toolCall, loadedSkills);
|
|
||||||
if (alreadyLoadedSkill != null) {
|
|
||||||
toolResponses.add(alreadyLoadedSkillResponse(toolCall, alreadyLoadedSkill));
|
|
||||||
} else {
|
|
||||||
executableToolCalls.add(toolCall);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!executableToolCalls.isEmpty()) {
|
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
executableToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
allToolCalls, conversationId, agentId, false, "", workspaceBasePath,
|
||||||
|
chatOrigin, loadedSkills);
|
||||||
toolResponses.addAll(execResult.responses());
|
toolResponses.addAll(execResult.responses());
|
||||||
events.addAll(execResult.events());
|
events.addAll(execResult.events());
|
||||||
if (execResult.hasDirectOutputs()) {
|
if (execResult.hasDirectOutputs()) {
|
||||||
@ -891,26 +884,6 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
return String.format("步骤%d结果:%s", stepIndex + 1, result);
|
return String.format("步骤%d结果:%s", stepIndex + 1, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String alreadyLoadedSkillName(AssistantMessage.ToolCall toolCall, Set<String> loadedSkills) {
|
|
||||||
if (toolCall == null || loadedSkills == null || loadedSkills.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Set<String> requested = ActionNode.extractLoadedSkillNames(List.of(toolCall));
|
|
||||||
if (requested.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String skillName = requested.iterator().next();
|
|
||||||
return loadedSkills.contains(skillName) ? skillName : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ToolResponseMessage.ToolResponse alreadyLoadedSkillResponse(
|
|
||||||
AssistantMessage.ToolCall toolCall, String skillName) {
|
|
||||||
String message = "Skill '" + skillName + "' was already loaded earlier in this run. "
|
|
||||||
+ "Reuse the SKILL.md content already present in the conversation; "
|
|
||||||
+ "do not call load_skill for this skill again.";
|
|
||||||
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolCall.name(), message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断当前工具调用是否与预批准 payload 中的工具名匹配。
|
* 判断当前工具调用是否与预批准 payload 中的工具名匹配。
|
||||||
* payload 格式: {"name":"toolName","arguments":"...","status":"running"}
|
* payload 格式: {"name":"toolName","arguments":"...","status":"running"}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package vip.mate.skill.usage;
|
package vip.mate.skill.usage;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||||
import vip.mate.skill.repository.SkillUsageStatMapper;
|
import vip.mate.skill.repository.SkillUsageStatMapper;
|
||||||
@ -28,14 +30,11 @@ public class SkillUsageService {
|
|||||||
try {
|
try {
|
||||||
Long scopedAgentId = agentId != null ? agentId : 0L;
|
Long scopedAgentId = agentId != null ? agentId : 0L;
|
||||||
String scopedConversationId = blankToEmpty(conversationId);
|
String scopedConversationId = blankToEmpty(conversationId);
|
||||||
SkillUsageStatEntity row = mapper.selectOne(new LambdaQueryWrapper<SkillUsageStatEntity>()
|
|
||||||
.eq(SkillUsageStatEntity::getSkillName, skill.getName())
|
|
||||||
.eq(SkillUsageStatEntity::getAgentId, scopedAgentId)
|
|
||||||
.eq(SkillUsageStatEntity::getConversationId, scopedConversationId)
|
|
||||||
.last("LIMIT 1"));
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (row == null) {
|
int updated = incrementExisting(skill, scopedAgentId, scopedConversationId,
|
||||||
row = new SkillUsageStatEntity();
|
filePath, tokenEstimate, now);
|
||||||
|
if (updated == 0) {
|
||||||
|
SkillUsageStatEntity row = new SkillUsageStatEntity();
|
||||||
row.setSkillName(skill.getName());
|
row.setSkillName(skill.getName());
|
||||||
row.setSkillId(skill.getId());
|
row.setSkillId(skill.getId());
|
||||||
row.setAgentId(scopedAgentId);
|
row.setAgentId(scopedAgentId);
|
||||||
@ -45,14 +44,14 @@ public class SkillUsageService {
|
|||||||
row.setLastFilePath(filePath);
|
row.setLastFilePath(filePath);
|
||||||
row.setLastTokenEstimate(tokenEstimate);
|
row.setLastTokenEstimate(tokenEstimate);
|
||||||
row.setDeleted(0);
|
row.setDeleted(0);
|
||||||
mapper.insert(row);
|
try {
|
||||||
} else {
|
mapper.insert(row);
|
||||||
row.setSkillId(skill.getId());
|
} catch (DuplicateKeyException race) {
|
||||||
row.setLoadCount((row.getLoadCount() == null ? 0L : row.getLoadCount()) + 1);
|
// Another parallel invocation inserted the same scoped row
|
||||||
row.setLastLoadedAt(now);
|
// after our update missed it. Retry as one atomic update.
|
||||||
row.setLastFilePath(filePath);
|
incrementExisting(skill, scopedAgentId, scopedConversationId,
|
||||||
row.setLastTokenEstimate(tokenEstimate);
|
filePath, tokenEstimate, now);
|
||||||
mapper.updateById(row);
|
}
|
||||||
}
|
}
|
||||||
// Mirror the activity anchor onto mate_skill so the lifecycle
|
// Mirror the activity anchor onto mate_skill so the lifecycle
|
||||||
// curator's daily scan stays a single indexed select.
|
// curator's daily scan stays a single indexed select.
|
||||||
@ -62,6 +61,19 @@ public class SkillUsageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int incrementExisting(ResolvedSkill skill, Long agentId, String conversationId,
|
||||||
|
String filePath, int tokenEstimate, LocalDateTime now) {
|
||||||
|
return mapper.update(null, new LambdaUpdateWrapper<SkillUsageStatEntity>()
|
||||||
|
.set(SkillUsageStatEntity::getSkillId, skill.getId())
|
||||||
|
.set(SkillUsageStatEntity::getLastLoadedAt, now)
|
||||||
|
.set(SkillUsageStatEntity::getLastFilePath, filePath)
|
||||||
|
.set(SkillUsageStatEntity::getLastTokenEstimate, tokenEstimate)
|
||||||
|
.setSql("load_count = COALESCE(load_count, 0) + 1")
|
||||||
|
.eq(SkillUsageStatEntity::getSkillName, skill.getName())
|
||||||
|
.eq(SkillUsageStatEntity::getAgentId, agentId)
|
||||||
|
.eq(SkillUsageStatEntity::getConversationId, conversationId));
|
||||||
|
}
|
||||||
|
|
||||||
public Set<String> recentLoadedSkillNames(Long agentId, int limit) {
|
public Set<String> recentLoadedSkillNames(Long agentId, int limit) {
|
||||||
if (agentId == null || limit <= 0) return Set.of();
|
if (agentId == null || limit <= 0) return Set.of();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -55,6 +55,9 @@ public class TeamDispatchService {
|
|||||||
/** Result summaries are capped before persisting to keep the board readable. */
|
/** Result summaries are capped before persisting to keep the board readable. */
|
||||||
static final int MAX_RESULT_CHARS = 8000;
|
static final int MAX_RESULT_CHARS = 8000;
|
||||||
|
|
||||||
|
/** Empty/fallback member runs get one recovery attempt, not three long identical runs. */
|
||||||
|
static final int MAX_RESPONSE_FAILURE_DISPATCHES = 2;
|
||||||
|
|
||||||
private static final Pattern GENERATED_FILE_MARKDOWN_LINK = Pattern.compile(
|
private static final Pattern GENERATED_FILE_MARKDOWN_LINK = Pattern.compile(
|
||||||
"\\[([^\\]\\r\\n]{1,200})]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
|
"\\[([^\\]\\r\\n]{1,200})]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
|
||||||
|
|
||||||
@ -178,6 +181,13 @@ public class TeamDispatchService {
|
|||||||
}
|
}
|
||||||
dispatchedThisRound.add(assignee);
|
dispatchedThisRound.add(assignee);
|
||||||
TeamTaskEntity assigned = taskService.getTask(task.getId());
|
TeamTaskEntity assigned = taskService.getTask(task.getId());
|
||||||
|
// assignTask clears the persisted reason for a clean running state,
|
||||||
|
// but the worker needs the previous failure feedback or an
|
||||||
|
// automatic retry is just the same prompt sent to the same agent.
|
||||||
|
if (assigned != null && (assigned.getReason() == null || assigned.getReason().isBlank())
|
||||||
|
&& task.getReason() != null && !task.getReason().isBlank()) {
|
||||||
|
assigned.setReason(task.getReason());
|
||||||
|
}
|
||||||
DISPATCH_EXECUTOR.submit(() -> runTask(teamId, assigned));
|
DISPATCH_EXECUTOR.submit(() -> runTask(teamId, assigned));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -279,10 +289,13 @@ public class TeamDispatchService {
|
|||||||
String invalidReason = invalidResultReason(current, reply, attachedGeneratedFile);
|
String invalidReason = invalidResultReason(current, reply, attachedGeneratedFile);
|
||||||
if (invalidReason != null) {
|
if (invalidReason != null) {
|
||||||
int attempts = current.getDispatchCount() == null ? 0 : current.getDispatchCount();
|
int attempts = current.getDispatchCount() == null ? 0 : current.getDispatchCount();
|
||||||
if (attempts < TeamTaskService.MAX_DISPATCHES
|
int maxAttempts = isResponseGenerationFailure(invalidReason)
|
||||||
|
? MAX_RESPONSE_FAILURE_DISPATCHES
|
||||||
|
: TeamTaskService.MAX_DISPATCHES;
|
||||||
|
if (attempts < maxAttempts
|
||||||
&& taskService.requeueUnusableResult(task.getId(), invalidReason)) {
|
&& taskService.requeueUnusableResult(task.getId(), invalidReason)) {
|
||||||
log.warn("Team task #{} produced an unusable result on attempt {}/{}; requeued: {}",
|
log.warn("Team task #{} produced an unusable result on attempt {}/{}; requeued: {}",
|
||||||
task.getTaskNumber(), attempts, TeamTaskService.MAX_DISPATCHES,
|
task.getTaskNumber(), attempts, maxAttempts,
|
||||||
invalidReason);
|
invalidReason);
|
||||||
broadcast(task, "team_task_retrying", Map.of("reason", invalidReason));
|
broadcast(task, "team_task_retrying", Map.of("reason", invalidReason));
|
||||||
return;
|
return;
|
||||||
@ -364,6 +377,11 @@ public class TeamDispatchService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isResponseGenerationFailure(String reason) {
|
||||||
|
return "member produced no result".equals(reason)
|
||||||
|
|| "member response generation failed".equals(reason);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean looksLikeClarificationQuestion(String reply) {
|
private boolean looksLikeClarificationQuestion(String reply) {
|
||||||
if (reply == null) {
|
if (reply == null) {
|
||||||
return false;
|
return false;
|
||||||
@ -390,7 +408,10 @@ public class TeamDispatchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean attachGeneratedFileDeliverable(TeamTaskEntity task, String reply) {
|
private boolean attachGeneratedFileDeliverable(TeamTaskEntity task, String reply) {
|
||||||
if (!requiresDeliverable(task) || reply == null || reply.isBlank()) {
|
// A render link is a useful task artifact regardless of how the task
|
||||||
|
// was created. The metadata flag controls validation/retry semantics,
|
||||||
|
// not whether an otherwise valid generated file is discoverable in UI.
|
||||||
|
if (reply == null || reply.isBlank()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
Matcher link = GENERATED_FILE_MARKDOWN_LINK.matcher(reply);
|
Matcher link = GENERATED_FILE_MARKDOWN_LINK.matcher(reply);
|
||||||
@ -430,7 +451,7 @@ public class TeamDispatchService {
|
|||||||
static final int MAX_LEAD_ATTACHMENT_SECTION_CHARS = 6000;
|
static final int MAX_LEAD_ATTACHMENT_SECTION_CHARS = 6000;
|
||||||
|
|
||||||
/** The full instruction envelope the member receives. */
|
/** The full instruction envelope the member receives. */
|
||||||
private String buildDispatchContent(TeamTaskEntity task) {
|
String buildDispatchContent(TeamTaskEntity task) {
|
||||||
StringBuilder sb = new StringBuilder(1024);
|
StringBuilder sb = new StringBuilder(1024);
|
||||||
sb.append("[Assigned team task #").append(task.getTaskNumber())
|
sb.append("[Assigned team task #").append(task.getTaskNumber())
|
||||||
.append(" (taskId: ").append(task.getId()).append(")]\n")
|
.append(" (taskId: ").append(task.getId()).append(")]\n")
|
||||||
@ -438,6 +459,13 @@ public class TeamDispatchService {
|
|||||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||||
sb.append("\n").append(task.getDescription()).append('\n');
|
sb.append("\n").append(task.getDescription()).append('\n');
|
||||||
}
|
}
|
||||||
|
if (task.getDispatchCount() != null && task.getDispatchCount() > 1
|
||||||
|
&& task.getReason() != null && !task.getReason().isBlank()) {
|
||||||
|
sb.append("\n[Retry feedback]\n")
|
||||||
|
.append("The previous attempt was rejected: ")
|
||||||
|
.append(truncate(task.getReason().strip(), 500))
|
||||||
|
.append(". Correct that failure in this attempt; do not repeat the same empty or fallback response.\n");
|
||||||
|
}
|
||||||
appendLeadAttachmentContext(sb, task);
|
appendLeadAttachmentContext(sb, task);
|
||||||
appendPrerequisiteResults(sb, task);
|
appendPrerequisiteResults(sb, task);
|
||||||
sb.append("""
|
sb.append("""
|
||||||
|
|||||||
@ -191,11 +191,9 @@ final class TeamRunViewFactory {
|
|||||||
task.getId(), message == null ? task.getSubject() : message, task.getUpdateTime()));
|
task.getId(), message == null ? task.getSubject() : message, task.getUpdateTime()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String quality = outcomeQuality(run, tasks);
|
// Synthesis quality is already exposed as outcomeQuality. A fallback
|
||||||
if ("fallback".equals(quality) || "partial".equals(quality)) {
|
// summary is informative but has no user action, so it must not inflate
|
||||||
items.add(new TeamRunView.AttentionItem("run:" + run.getId() + ":synthesis", "synthesis",
|
// the board's "needs attention" count.
|
||||||
"warning", 10, null, "Final synthesis used a degraded outcome", run.getUpdateTime()));
|
|
||||||
}
|
|
||||||
if (text(run.getStopReason()) != null) {
|
if (text(run.getStopReason()) != null) {
|
||||||
items.add(new TeamRunView.AttentionItem("run:" + run.getId() + ":stopped", "stopped",
|
items.add(new TeamRunView.AttentionItem("run:" + run.getId() + ":stopped", "stopped",
|
||||||
"warning", 10, null, run.getStopReason(), run.getUpdateTime()));
|
"warning", 10, null, run.getStopReason(), run.getUpdateTime()));
|
||||||
|
|||||||
@ -559,6 +559,13 @@ public class TeamTaskService {
|
|||||||
if (deliverables == null) {
|
if (deliverables == null) {
|
||||||
deliverables = new JSONArray();
|
deliverables = new JSONArray();
|
||||||
}
|
}
|
||||||
|
for (Object item : deliverables) {
|
||||||
|
if (item instanceof JSONObject existing
|
||||||
|
&& trimmedUrl.equals(existing.getStr("url"))) {
|
||||||
|
log.debug("Team task {} deliverable already attached: {}", taskId, trimmedUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (deliverables.size() >= MAX_DELIVERABLES) {
|
if (deliverables.size() >= MAX_DELIVERABLES) {
|
||||||
throw new IllegalStateException("task #" + task.getTaskNumber() + " already has "
|
throw new IllegalStateException("task #" + task.getTaskNumber() + " already has "
|
||||||
+ MAX_DELIVERABLES + " deliverables; consolidate outputs instead of adding more");
|
+ MAX_DELIVERABLES + " deliverables; consolidate outputs instead of adding more");
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared team task board exposed to the LLM. One multi-action tool (rather
|
* Shared team task board exposed to the LLM. One multi-action tool (rather
|
||||||
@ -48,6 +49,10 @@ import java.util.Optional;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class TeamTasksTool {
|
public class TeamTasksTool {
|
||||||
|
|
||||||
|
private static final Pattern DELIVERABLE_REQUEST = Pattern.compile(
|
||||||
|
"(?i)(交付物|生成.{0,8}(文件|文档)|文档成稿|报告成稿|"
|
||||||
|
+ "docx|xlsx|pptx|pdf|deliverable|document|spreadsheet|presentation)");
|
||||||
|
|
||||||
private final TeamService teamService;
|
private final TeamService teamService;
|
||||||
private final TeamTaskService taskService;
|
private final TeamTaskService taskService;
|
||||||
private final TeamRunService runService;
|
private final TeamRunService runService;
|
||||||
@ -201,6 +206,7 @@ public class TeamTasksTool {
|
|||||||
.blockedBy(parseIdList(blockedBy))
|
.blockedBy(parseIdList(blockedBy))
|
||||||
.requireApproval(Boolean.TRUE.equals(requireApproval))
|
.requireApproval(Boolean.TRUE.equals(requireApproval))
|
||||||
.leadConversationId(conversationId)
|
.leadConversationId(conversationId)
|
||||||
|
.metadata(deliverableMetadata(subject, description))
|
||||||
.build());
|
.build());
|
||||||
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
|
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
|
||||||
return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId()
|
return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId()
|
||||||
@ -211,6 +217,17 @@ public class TeamTasksTool {
|
|||||||
+ " Seal the run after all tasks are staged.";
|
+ " Seal the run after all tasks are staged.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String deliverableMetadata(String subject, String description) {
|
||||||
|
String taskText = (subject == null ? "" : subject) + "\n"
|
||||||
|
+ (description == null ? "" : description);
|
||||||
|
if (!DELIVERABLE_REQUEST.matcher(taskText).find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new cn.hutool.json.JSONObject()
|
||||||
|
.set("deliverableRequired", true)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private String sealRun(AgentTeamEntity team, boolean isLead, Long workspaceId,
|
private String sealRun(AgentTeamEntity team, boolean isLead, Long workspaceId,
|
||||||
String conversationId, String runId) {
|
String conversationId, String runId) {
|
||||||
if (!isLead) {
|
if (!isLead) {
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
package vip.mate.tool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals an LLM-correctable tool argument error whose message is safe to
|
||||||
|
* return to the model, including for {@code returnDirect} tools.
|
||||||
|
*
|
||||||
|
* <p>Ordinary exceptions from direct tools remain redacted because they can
|
||||||
|
* contain credentials, connection strings, or other sensitive internals.
|
||||||
|
*/
|
||||||
|
public class ToolInputValidationException extends RuntimeException {
|
||||||
|
|
||||||
|
public ToolInputValidationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ToolInputValidationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import org.springframework.ai.tool.annotation.ToolParam;
|
|||||||
import org.springframework.ai.chat.model.ToolContext;
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.tool.ToolInputValidationException;
|
||||||
import vip.mate.tool.document.FilenameSanitizer;
|
import vip.mate.tool.document.FilenameSanitizer;
|
||||||
import vip.mate.tool.document.GeneratedFileCache;
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
import vip.mate.tool.document.GeneratedFileLink;
|
import vip.mate.tool.document.GeneratedFileLink;
|
||||||
@ -76,7 +77,9 @@ public class DocxRenderTool {
|
|||||||
@Nullable ToolContext ctx) {
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
if (markdown == null || markdown.isBlank()) {
|
if (markdown == null || markdown.isBlank()) {
|
||||||
return "错误:markdown 参数为空,无法生成文档。";
|
throw new ToolInputValidationException(
|
||||||
|
"markdown must not be blank; provide the document content, "
|
||||||
|
+ "or write it to a .md file and call renderDocxFromFile");
|
||||||
}
|
}
|
||||||
|
|
||||||
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
||||||
@ -90,7 +93,7 @@ public class DocxRenderTool {
|
|||||||
return GeneratedFileLink.resultZh(bytes, displayName, DOCX_MIME, cache, "文档", ctx);
|
return GeneratedFileLink.resultZh(bytes, displayName, DOCX_MIME, cache, "文档", ctx);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e);
|
log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e);
|
||||||
return "渲染失败:" + e.getMessage();
|
throw new IllegalStateException("DOCX rendering failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +145,7 @@ public class DocxRenderTool {
|
|||||||
try {
|
try {
|
||||||
input = MarkdownInputResolver.readSingle(filePath);
|
input = MarkdownInputResolver.readSingle(filePath);
|
||||||
} catch (ResolveException e) {
|
} catch (ResolveException e) {
|
||||||
return "Error: " + e.getMessage();
|
throw new ToolInputValidationException(e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
||||||
@ -157,7 +160,7 @@ public class DocxRenderTool {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[DocxRender] render failed for {} (source: {}): {}",
|
log.error("[DocxRender] render failed for {} (source: {}): {}",
|
||||||
displayName, input.sources().get(0), e.getMessage(), e);
|
displayName, input.sources().get(0), e.getMessage(), e);
|
||||||
return "Render failed: " + e.getMessage();
|
throw new IllegalStateException("DOCX rendering failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,7 +205,7 @@ public class DocxRenderTool {
|
|||||||
try {
|
try {
|
||||||
input = MarkdownInputResolver.readManyJoined(filePaths);
|
input = MarkdownInputResolver.readManyJoined(filePaths);
|
||||||
} catch (ResolveException e) {
|
} catch (ResolveException e) {
|
||||||
return "Error: " + e.getMessage();
|
throw new ToolInputValidationException(e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx";
|
||||||
@ -219,7 +222,7 @@ public class DocxRenderTool {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[DocxRender] render failed for {} (sources: {}): {}",
|
log.error("[DocxRender] render failed for {} (sources: {}): {}",
|
||||||
displayName, input.sources(), e.getMessage(), e);
|
displayName, input.sources(), e.getMessage(), e);
|
||||||
return "Render failed: " + e.getMessage();
|
throw new IllegalStateException("DOCX rendering failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,11 +10,15 @@ import org.springframework.ai.tool.definition.ToolDefinition;
|
|||||||
import org.springframework.ai.tool.metadata.ToolMetadata;
|
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||||
|
import vip.mate.tool.ToolInputValidationException;
|
||||||
import vip.mate.tool.guard.ToolGuard;
|
import vip.mate.tool.guard.ToolGuard;
|
||||||
import vip.mate.tool.guard.ToolGuardResult;
|
import vip.mate.tool.guard.ToolGuardResult;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@ -29,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
* fixed placeholder, not the sensitive content.</li>
|
* fixed placeholder, not the sensitive content.</li>
|
||||||
* <li>An {@code EVENT_TOOL_DIRECT_RESULT} event is emitted with the full text
|
* <li>An {@code EVENT_TOOL_DIRECT_RESULT} event is emitted with the full text
|
||||||
* and {@code renderAs=assistant_message}.</li>
|
* and {@code renderAs=assistant_message}.</li>
|
||||||
|
* <li>A payload-free {@code EVENT_TOOL_COMPLETE} closes the live tool card.</li>
|
||||||
* <li>Non-direct tools in the same batch keep their existing behavior.</li>
|
* <li>Non-direct tools in the same batch keep their existing behavior.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*/
|
*/
|
||||||
@ -81,11 +86,17 @@ class ToolExecutionExecutorReturnDirectTest {
|
|||||||
assertEquals(SECRET, data.get("result"));
|
assertEquals(SECRET, data.get("result"));
|
||||||
assertEquals("assistant_message", data.get("renderAs"));
|
assertEquals("assistant_message", data.get("renderAs"));
|
||||||
|
|
||||||
// (4) no tool_call_completed event for the direct tool — direct path replaces it
|
// (4) the started tool card receives a terminal pair, without leaking
|
||||||
boolean hasCompleted = result.events().stream()
|
// the direct payload into its ordinary result field.
|
||||||
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type()));
|
var completed = result.events().stream()
|
||||||
assertFalse(hasCompleted, "direct path replaces tool_call_completed; double-emit would " +
|
.filter(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type()))
|
||||||
"leak the placeholder into UI as a tool result card");
|
.toList();
|
||||||
|
assertEquals(1, completed.size());
|
||||||
|
assertEquals(Boolean.TRUE, completed.get(0).data().get("success"));
|
||||||
|
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER,
|
||||||
|
completed.get(0).data().get("result"));
|
||||||
|
assertFalse(String.valueOf(completed.get(0).data().get("result"))
|
||||||
|
.contains("EMPLOYEE-SALARY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -110,6 +121,39 @@ class ToolExecutionExecutorReturnDirectTest {
|
|||||||
assertFalse(hasDirect);
|
assertFalse(hasDirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("duplicate load_skill calls execute once across batch and loaded state")
|
||||||
|
void duplicateSkillLoadsAreShortCircuitedBeforeParallelExecution() {
|
||||||
|
AtomicInteger executions = new AtomicInteger();
|
||||||
|
ToolCallback loadSkill = stubCallback("load_skill", false, args -> {
|
||||||
|
executions.incrementAndGet();
|
||||||
|
return "skill instructions";
|
||||||
|
});
|
||||||
|
ToolExecutionExecutor executor = newExecutor(loadSkill);
|
||||||
|
AssistantMessage.ToolCall first = new AssistantMessage.ToolCall(
|
||||||
|
"skill_1", "function", "load_skill", "{\"skillName\":\"docx\"}");
|
||||||
|
AssistantMessage.ToolCall duplicate = new AssistantMessage.ToolCall(
|
||||||
|
"skill_2", "function", "load_skill", "{\"skillName\":\"DOCX\"}");
|
||||||
|
|
||||||
|
ToolExecutionExecutor.ToolExecutionResult batch = executor.execute(
|
||||||
|
List.of(first, duplicate), "conv", "agent", false, "", null,
|
||||||
|
ChatOrigin.EMPTY, Set.of());
|
||||||
|
|
||||||
|
assertEquals(1, executions.get());
|
||||||
|
assertEquals(2, batch.responses().size());
|
||||||
|
assertTrue(batch.responses().get(1).responseData().contains("already loaded"));
|
||||||
|
assertTrue(batch.events().stream().anyMatch(event ->
|
||||||
|
GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type())
|
||||||
|
&& "skill_2".equals(event.data().get("toolCallId"))
|
||||||
|
&& Boolean.TRUE.equals(event.data().get("success"))));
|
||||||
|
|
||||||
|
ToolExecutionExecutor.ToolExecutionResult laterTurn = executor.execute(
|
||||||
|
List.of(first), "conv", "agent", false, "", null,
|
||||||
|
ChatOrigin.EMPTY, Set.of("docx"));
|
||||||
|
assertEquals(1, executions.get());
|
||||||
|
assertTrue(laterTurn.responses().getFirst().responseData().contains("already loaded"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("RFC-052: returnDirect tool throwing yields generic message (no exception details leak)")
|
@DisplayName("RFC-052: returnDirect tool throwing yields generic message (no exception details leak)")
|
||||||
void directTool_throwing_genericErrorMessage() {
|
void directTool_throwing_genericErrorMessage() {
|
||||||
@ -133,6 +177,27 @@ class ToolExecutionExecutorReturnDirectTest {
|
|||||||
assertFalse(content.contains("OracleDriver"), "Stack/connection details must not leak");
|
assertFalse(content.contains("OracleDriver"), "Stack/connection details must not leak");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("RFC-052: safe input validation errors return to the model for correction")
|
||||||
|
void directTool_validationErrorIsActionableAndDoesNotShortCircuit() {
|
||||||
|
ToolCallback invalidDirect = stubCallback("renderDocx", true, args -> {
|
||||||
|
throw new ToolInputValidationException("markdown must not be blank");
|
||||||
|
});
|
||||||
|
ToolExecutionExecutor executor = newExecutor(invalidDirect);
|
||||||
|
|
||||||
|
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
|
||||||
|
"call_v", "function", "renderDocx", "{\"markdown\":\"\"}");
|
||||||
|
ToolExecutionExecutor.ToolExecutionResult result =
|
||||||
|
executor.execute(List.of(call), "conv_v", "agent_v", false, "user_v", null);
|
||||||
|
|
||||||
|
assertFalse(result.hasDirectOutputs(), "invalid input must not trigger returnDirect");
|
||||||
|
assertEquals("Tool input validation failed: markdown must not be blank",
|
||||||
|
result.responses().get(0).responseData());
|
||||||
|
assertTrue(result.events().stream()
|
||||||
|
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type())
|
||||||
|
&& Boolean.FALSE.equals(e.data().get("success"))));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("RFC-052: pre-approved direct tool replays through direct path")
|
@DisplayName("RFC-052: pre-approved direct tool replays through direct path")
|
||||||
void executePreApproved_directTool_takesDirectPath() {
|
void executePreApproved_directTool_takesDirectPath() {
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
package vip.mate.channel.web;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.agent.AgentService.StreamDelta;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
class AgentStreamAccumulatorReturnDirectTest {
|
||||||
|
|
||||||
|
private static final String DIRECT_PLACEHOLDER =
|
||||||
|
"[Tool result returned directly to user. "
|
||||||
|
+ "Content withheld from model context per tool policy.]";
|
||||||
|
|
||||||
|
private static final AgentStreamAccumulator.Sink NOOP_SINK =
|
||||||
|
new AgentStreamAccumulator.Sink() {
|
||||||
|
@Override
|
||||||
|
public void broadcast(String conversationId, String eventName, Object payload) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updatePhase(String conversationId, String phase) { }
|
||||||
|
};
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returnDirect result terminates its tool card without persisting the direct payload there")
|
||||||
|
void directResultHasCompletedToolPair() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
AgentStreamAccumulator accumulator = new AgentStreamAccumulator(mapper, NOOP_SINK);
|
||||||
|
String conversationId = "conv-direct";
|
||||||
|
|
||||||
|
accumulator.accept(StreamDelta.event("tool_call_started", Map.of(
|
||||||
|
"toolCallId", "call-1", "toolName", "renderDocx", "arguments", "{}")),
|
||||||
|
conversationId);
|
||||||
|
accumulator.accept(StreamDelta.event("tool_direct_result", Map.of(
|
||||||
|
"toolCallId", "call-1", "toolName", "renderDocx",
|
||||||
|
"result", "SECRET-DIRECT-PAYLOAD")), conversationId);
|
||||||
|
accumulator.accept(StreamDelta.event("tool_call_completed", Map.of(
|
||||||
|
"toolCallId", "call-1", "toolName", "renderDocx",
|
||||||
|
"result", DIRECT_PLACEHOLDER, "success", true)),
|
||||||
|
conversationId);
|
||||||
|
|
||||||
|
JsonNode metadata = mapper.readTree(accumulator.toMetadataJson());
|
||||||
|
JsonNode call = metadata.path("toolCalls").get(0);
|
||||||
|
JsonNode segment = metadata.path("segments").get(0);
|
||||||
|
|
||||||
|
assertEquals("completed", call.path("status").asText());
|
||||||
|
assertEquals("completed", segment.path("status").asText());
|
||||||
|
assertEquals(DIRECT_PLACEHOLDER, segment.path("toolResult").asText());
|
||||||
|
assertFalse(metadata.toString().contains("SECRET-DIRECT-PAYLOAD"));
|
||||||
|
assertEquals("renderDocx", metadata.path("directToolNames").get(0).asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,12 +9,15 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||||
import vip.mate.skill.repository.SkillUsageStatMapper;
|
import vip.mate.skill.repository.SkillUsageStatMapper;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ -48,7 +51,6 @@ class SkillUsageServiceActivityBubbleTest {
|
|||||||
@Test
|
@Test
|
||||||
void recordLoadedBubblesActivityToLifecycle() {
|
void recordLoadedBubblesActivityToLifecycle() {
|
||||||
ResolvedSkill skill = ResolvedSkill.builder().id(7L).name("demo").build();
|
ResolvedSkill skill = ResolvedSkill.builder().id(7L).name("demo").build();
|
||||||
when(mapper.selectOne(any())).thenReturn(null);
|
|
||||||
|
|
||||||
service.recordLoaded(skill, 1L, "conv-1", "SKILL.md", 100);
|
service.recordLoaded(skill, 1L, "conv-1", "SKILL.md", 100);
|
||||||
|
|
||||||
@ -60,4 +62,17 @@ class SkillUsageServiceActivityBubbleTest {
|
|||||||
service.recordLoaded(null, 1L, "conv-1", "SKILL.md", 100);
|
service.recordLoaded(null, 1L, "conv-1", "SKILL.md", 100);
|
||||||
verify(lifecycleService, never()).bumpActivity(any());
|
verify(lifecycleService, never()).bumpActivity(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordLoadedRetriesAtomicUpdateWhenParallelInsertWins() {
|
||||||
|
ResolvedSkill skill = ResolvedSkill.builder().id(7L).name("demo").build();
|
||||||
|
when(mapper.update(any(), any())).thenReturn(0, 1);
|
||||||
|
doThrow(new DuplicateKeyException("parallel insert"))
|
||||||
|
.when(mapper).insert(any(SkillUsageStatEntity.class));
|
||||||
|
|
||||||
|
service.recordLoaded(skill, 1L, "conv-1", "SKILL.md", 100);
|
||||||
|
|
||||||
|
verify(mapper, times(2)).update(any(), any());
|
||||||
|
verify(lifecycleService).bumpActivity(7L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -215,6 +215,40 @@ class TeamDispatchServiceTest {
|
|||||||
verify(announceService).announceTaskSettled(failed);
|
verify(announceService).announceTaskSettled(failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a second empty member result fails without starting a third long run")
|
||||||
|
void settleEmptyResultFailsAfterSingleRecoveryAttempt() {
|
||||||
|
TeamTaskEntity running = task(1L, MEMBER_A);
|
||||||
|
running.setStatus(TeamTaskStatus.IN_PROGRESS);
|
||||||
|
running.setDispatchCount(TeamDispatchService.MAX_RESPONSE_FAILURE_DISPATCHES);
|
||||||
|
TeamTaskEntity failed = task(1L, MEMBER_A);
|
||||||
|
failed.setStatus(TeamTaskStatus.FAILED);
|
||||||
|
failed.setReason("member produced no result");
|
||||||
|
when(taskService.getTask(1L)).thenReturn(running, failed);
|
||||||
|
when(taskService.failTask(1L, "member produced no result")).thenReturn(true);
|
||||||
|
|
||||||
|
service.settleOutcome(running, "");
|
||||||
|
|
||||||
|
verify(taskService, never()).requeueUnusableResult(any(), anyString());
|
||||||
|
verify(taskService).failTask(1L, "member produced no result");
|
||||||
|
verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any());
|
||||||
|
verify(announceService).announceTaskSettled(failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("automatic retry tells the worker why its previous attempt was rejected")
|
||||||
|
void retryDispatchContentIncludesPreviousFailure() {
|
||||||
|
TeamTaskEntity retry = task(1L, MEMBER_A);
|
||||||
|
retry.setDispatchCount(2);
|
||||||
|
retry.setReason("member produced no result");
|
||||||
|
|
||||||
|
String content = service.buildDispatchContent(retry);
|
||||||
|
|
||||||
|
assertTrue(content.contains("[Retry feedback]"));
|
||||||
|
assertTrue(content.contains("member produced no result"));
|
||||||
|
assertTrue(content.contains("do not repeat the same empty or fallback response"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("a declared deliverable task without an attachment is requeued")
|
@DisplayName("a declared deliverable task without an attachment is requeued")
|
||||||
void settleMissingDeliverableRequeues() {
|
void settleMissingDeliverableRequeues() {
|
||||||
|
|||||||
@ -69,6 +69,18 @@ class TeamRunViewFactoryTest {
|
|||||||
assertEquals(full.outcomeQuality(), summary.outcomeQuality());
|
assertEquals(full.outcomeQuality(), summary.outcomeQuality());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallbackSummaryDoesNotCreateUnactionableAttentionItem() {
|
||||||
|
TeamRunEntity fallbackRun = run("{\"summaryQuality\":\"fallback\"}");
|
||||||
|
TeamTaskEntity completed = task(101L, 201L, null);
|
||||||
|
|
||||||
|
TeamRunView view = TeamRunViewFactory.create(fallbackRun, TeamRunStatus.COMPLETED,
|
||||||
|
new TeamRunView.Progress(1, 1, 0, 0, 100), List.of(completed), true);
|
||||||
|
|
||||||
|
assertEquals("fallback", view.outcomeQuality());
|
||||||
|
assertEquals(List.of(), view.attentionItems());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aggregatesRunOnlyDeliverables() {
|
void aggregatesRunOnlyDeliverables() {
|
||||||
TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\","
|
TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\","
|
||||||
|
|||||||
@ -542,6 +542,22 @@ class TeamTaskServiceTest {
|
|||||||
assertEquals("/api/v1/files/generated/abc", files.get(0).url());
|
assertEquals("/api/v1/files/generated/abc", files.get(0).url());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("addDeliverable is idempotent for the same generated URL")
|
||||||
|
void addDeliverableIgnoresDuplicateUrl() {
|
||||||
|
TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||||||
|
running.setOwnerAgentId(MEMBER_ID);
|
||||||
|
running.setMetadata("{\"deliverables\":[{\"name\":\"report.docx\","
|
||||||
|
+ "\"url\":\"/api/v1/files/generated/abc\"}]}");
|
||||||
|
when(taskMapper.selectById(5L)).thenReturn(running);
|
||||||
|
|
||||||
|
service.addDeliverable(5L, MEMBER_ID, "report-again.docx",
|
||||||
|
"/api/v1/files/generated/abc");
|
||||||
|
|
||||||
|
verify(taskMapper, never()).update(isNull(), any());
|
||||||
|
verify(eventMapper, never()).insert(any(TeamTaskEventEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("deliverable guards: external URL, non-owner, terminal task and overflow are rejected")
|
@DisplayName("deliverable guards: external URL, non-owner, terminal task and overflow are rejected")
|
||||||
void addDeliverableGuards() {
|
void addDeliverableGuards() {
|
||||||
|
|||||||
@ -295,6 +295,24 @@ class TeamTasksToolTest {
|
|||||||
assertTrue(captor.getValue().isRequireApproval());
|
assertTrue(captor.getValue().isRequireApproval());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("file-producing create task records a required deliverable contract")
|
||||||
|
void createInfersRequiredDeliverableFromDescription() {
|
||||||
|
callerIs(LEAD_ID);
|
||||||
|
when(runService.requireRun(RUN_ID, WORKSPACE_ID))
|
||||||
|
.thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING));
|
||||||
|
when(taskService.createTask(any())).thenReturn(task(53L, TeamTaskStatus.PENDING));
|
||||||
|
|
||||||
|
tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null,
|
||||||
|
"生成报告", "请生成最终 DOCX 文件", String.valueOf(MEMBER_ID), null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
|
ArgumentCaptor<TeamTaskCreateCommand> captor =
|
||||||
|
ArgumentCaptor.forClass(TeamTaskCreateCommand.class);
|
||||||
|
verify(taskService).createTask(captor.capture());
|
||||||
|
assertTrue(captor.getValue().getMetadata().contains("\"deliverableRequired\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("create requires an explicit run id")
|
@DisplayName("create requires an explicit run id")
|
||||||
void createRequiresRunId() {
|
void createRequiresRunId() {
|
||||||
|
|||||||
@ -3,7 +3,9 @@ package vip.mate.tool.builtin;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.chat.model.ToolContext;
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import vip.mate.tool.ToolInputValidationException;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class DocxRenderToolReturnDirectTest {
|
class DocxRenderToolReturnDirectTest {
|
||||||
@ -15,6 +17,16 @@ class DocxRenderToolReturnDirectTest {
|
|||||||
assertReturnDirect("renderDocxFromFiles", java.util.List.class, String.class, String.class, ToolContext.class);
|
assertReturnDirect("renderDocxFromFiles", java.util.List.class, String.class, String.class, ToolContext.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankMarkdownIsARecoverableInputFailureNotADirectSuccess() {
|
||||||
|
DocxRenderTool tool = new DocxRenderTool(null, null);
|
||||||
|
|
||||||
|
ToolInputValidationException error = assertThrows(ToolInputValidationException.class,
|
||||||
|
() -> tool.renderDocx(" ", "report", "A4", null));
|
||||||
|
|
||||||
|
assertTrue(error.getMessage().contains("markdown must not be blank"));
|
||||||
|
}
|
||||||
|
|
||||||
private static void assertReturnDirect(String methodName, Class<?>... parameterTypes) throws Exception {
|
private static void assertReturnDirect(String methodName, Class<?>... parameterTypes) throws Exception {
|
||||||
Tool tool = DocxRenderTool.class
|
Tool tool = DocxRenderTool.class
|
||||||
.getMethod(methodName, parameterTypes)
|
.getMethod(methodName, parameterTypes)
|
||||||
|
|||||||
@ -184,6 +184,36 @@ describe('useTeamRuns', () => {
|
|||||||
scope.stop()
|
scope.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('performs a trailing detail refresh when a terminal event arrives in flight', async () => {
|
||||||
|
let onEvent: ((event: TeamBoardEvent) => void) | undefined
|
||||||
|
let resolveStale!: (value: { data: TeamRun }) => void
|
||||||
|
let resolveTerminal!: (value: { data: TeamRun }) => void
|
||||||
|
const staleDetail = new Promise<{ data: TeamRun }>(resolve => { resolveStale = resolve })
|
||||||
|
const terminalDetail = new Promise<{ data: TeamRun }>(resolve => { resolveTerminal = resolve })
|
||||||
|
const dependencies: TeamRunsDependencies = {
|
||||||
|
listByConversation: vi.fn().mockResolvedValue({ data: [run('10')] }),
|
||||||
|
getRun: vi.fn()
|
||||||
|
.mockReturnValueOnce(staleDetail)
|
||||||
|
.mockReturnValueOnce(terminalDetail),
|
||||||
|
subscribe: vi.fn((_teamId, callback) => { onEvent = callback; return vi.fn() }),
|
||||||
|
}
|
||||||
|
const scope = effectScope()
|
||||||
|
const state = scope.run(() => useTeamRuns(ref('lead'), { dependencies }))!
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
onEvent!({ event: 'team_run_progress', data: { runId: '10' } })
|
||||||
|
onEvent!({ event: 'team_run_completed', data: { runId: '10', status: 'completed' } })
|
||||||
|
expect(dependencies.getRun).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
resolveStale({ data: run('10') })
|
||||||
|
await flush()
|
||||||
|
expect(dependencies.getRun).toHaveBeenCalledTimes(2)
|
||||||
|
resolveTerminal({ data: run('10', 'team-1', 'completed') })
|
||||||
|
await flush()
|
||||||
|
expect(state.runs.value[0].status).toBe('completed')
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores team events belonging to another lead conversation', async () => {
|
it('ignores team events belonging to another lead conversation', async () => {
|
||||||
let onEvent: ((event: TeamBoardEvent) => void) | undefined
|
let onEvent: ((event: TeamBoardEvent) => void) | undefined
|
||||||
const dependencies: TeamRunsDependencies = {
|
const dependencies: TeamRunsDependencies = {
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export function useTeamRuns(
|
|||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
const subscriptions = new Map<string, () => void>()
|
const subscriptions = new Map<string, () => void>()
|
||||||
const inFlight = new Map<string, Promise<void>>()
|
const inFlight = new Map<string, Promise<void>>()
|
||||||
|
const trailingRefresh = new Set<string>()
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let stopped = false
|
let stopped = false
|
||||||
|
|
||||||
@ -91,11 +92,14 @@ export function useTeamRuns(
|
|||||||
ensureSubscriptions()
|
ensureSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshRun = (runId: string): Promise<void> => {
|
const refreshRun = (runId: string, requireTrailing = false): Promise<void> => {
|
||||||
const activeGeneration = generation
|
const activeGeneration = generation
|
||||||
const requestKey = `${activeGeneration}:${runId}`
|
const requestKey = `${activeGeneration}:${runId}`
|
||||||
const existing = inFlight.get(requestKey)
|
const existing = inFlight.get(requestKey)
|
||||||
if (existing) return existing
|
if (existing) {
|
||||||
|
if (requireTrailing) trailingRefresh.add(requestKey)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
const request = dependencies.getRun(runId)
|
const request = dependencies.getRun(runId)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!stopped && activeGeneration === generation) replaceRun(dataOf(result))
|
if (!stopped && activeGeneration === generation) replaceRun(dataOf(result))
|
||||||
@ -103,7 +107,12 @@ export function useTeamRuns(
|
|||||||
.catch((cause) => {
|
.catch((cause) => {
|
||||||
if (!stopped && activeGeneration === generation) error.value = cause
|
if (!stopped && activeGeneration === generation) error.value = cause
|
||||||
})
|
})
|
||||||
.finally(() => { inFlight.delete(requestKey) })
|
.finally(() => {
|
||||||
|
inFlight.delete(requestKey)
|
||||||
|
if (!stopped && activeGeneration === generation && trailingRefresh.delete(requestKey)) {
|
||||||
|
void refreshRun(runId)
|
||||||
|
}
|
||||||
|
})
|
||||||
inFlight.set(requestKey, request)
|
inFlight.set(requestKey, request)
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
@ -127,7 +136,10 @@ export function useTeamRuns(
|
|||||||
? { ...current, status, progress }
|
? { ...current, status, progress }
|
||||||
: run)
|
: run)
|
||||||
}
|
}
|
||||||
void refreshRun(runId)
|
const terminalEvent = ['completed', 'failed', 'cancelled', 'stopped'].includes(
|
||||||
|
typeof event.data.status === 'string' ? event.data.status : '',
|
||||||
|
) || ['team_run_completed', 'team_run_failed', 'team_run_cancelled', 'team_run_stopped'].includes(event.event)
|
||||||
|
void refreshRun(runId, terminalEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
@ -136,6 +148,7 @@ export function useTeamRuns(
|
|||||||
loadingMore.value = false
|
loadingMore.value = false
|
||||||
cleanupSubscriptions()
|
cleanupSubscriptions()
|
||||||
inFlight.clear()
|
inFlight.clear()
|
||||||
|
trailingRefresh.clear()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
@ -194,6 +207,7 @@ export function useTeamRuns(
|
|||||||
generation += 1
|
generation += 1
|
||||||
stopWatch()
|
stopWatch()
|
||||||
cleanupSubscriptions()
|
cleanupSubscriptions()
|
||||||
|
trailingRefresh.clear()
|
||||||
}
|
}
|
||||||
if (getCurrentScope()) onScopeDispose(stop)
|
if (getCurrentScope()) onScopeDispose(stop)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user