mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 14:11:49 +08:00
fix(team): ground worker progress and preserve runtime failure causes
This commit is contained in:
parent
a9b4dfd124
commit
f449fb229e
@ -45,6 +45,15 @@ public final class TaskFinalizationCoordinator {
|
|||||||
describe finishing the task after failure; it does not establish original-operation recovery.
|
describe finishing the task after failure; it does not establish original-operation recovery.
|
||||||
Endorse recovery only for canonical supportedRecoveryCallIds; an empty list means none is established.
|
Endorse recovery only for canonical supportedRecoveryCallIds; an empty list means none is established.
|
||||||
Do not invent numeric results or reconstruct omitted results.
|
Do not invent numeric results or reconstruct omitted results.
|
||||||
|
For literal existence checks, a substring anywhere in the complete observed output satisfies existence.
|
||||||
|
Do not invent a standalone line, whole-line equality, token-boundary or occurrence-count requirement
|
||||||
|
unless the CURRENT objective explicitly requires it. A quoted numeric marker such as
|
||||||
|
"130 checks passed and evidence retained" is not a claim that those tests were executed.
|
||||||
|
Distinguish reporting that a marker exists from endorsing the marker's embedded claim.
|
||||||
|
successfulProgressUpdates records successful team progress writes separately from substantive outputs:
|
||||||
|
check its taskId, percent and completedAt for requested milestone and read-before-progress constraints.
|
||||||
|
Their absence from successfulSubstantiveCalls does not mean progress was not reported.
|
||||||
|
Free-form progress prose alone cannot establish a successful progress write.
|
||||||
Unsupported candidate prose requires REVISE with empty recoveryClaims and a specific correction.
|
Unsupported candidate prose requires REVISE with empty recoveryClaims and a specific correction.
|
||||||
Unfinished executable objective work or unsatisfied requested state constraints require CONTINUE, not prose-only REVISE.
|
Unfinished executable objective work or unsatisfied requested state constraints require CONTINUE, not prose-only REVISE.
|
||||||
Explicitly requested pending records are not unfinished executable work; compare them to the original constraints.
|
Explicitly requested pending records are not unfinished executable work; compare them to the original constraints.
|
||||||
|
|||||||
@ -29,14 +29,24 @@ public final class ActionExecutionLedger {
|
|||||||
|
|
||||||
public enum Status { SUCCEEDED, FAILED }
|
public enum Status { SUCCEEDED, FAILED }
|
||||||
|
|
||||||
|
public record ProgressUpdate(String taskId, int percent) {}
|
||||||
|
|
||||||
public record Receipt(String toolCallId, String toolName, Status status,
|
public record Receipt(String toolCallId, String toolName, Status status,
|
||||||
String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly) {
|
String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly, ProgressUpdate progressUpdate) {
|
||||||
public Receipt {
|
public Receipt {
|
||||||
|
if (!progressOnly || progressUpdate != null && (!validTaskId(progressUpdate.taskId())
|
||||||
|
|| progressUpdate.percent() < 0 || progressUpdate.percent() > 100)) progressUpdate = null;
|
||||||
// Older serialized receipts have no original output length (primitive defaults to zero).
|
// Older serialized receipts have no original output length (primitive defaults to zero).
|
||||||
if (resultSummary != null && !resultSummary.isEmpty() && resultChars == 0) resultChars = -1;
|
if (resultSummary != null && !resultSummary.isEmpty() && resultChars == 0) resultChars = -1;
|
||||||
if (operationIdentity == null || !operationIdentity.matches("sha256:[a-f0-9]{64}")) {
|
if (operationIdentity == null || !operationIdentity.matches("sha256:[a-f0-9]{64}")) {
|
||||||
operationIdentity = UNKNOWN_OPERATION;
|
operationIdentity = UNKNOWN_OPERATION;
|
||||||
}
|
}
|
||||||
|
if (!"team_tasks".equals(toolName) || UNKNOWN_OPERATION.equals(operationIdentity)) progressUpdate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Receipt(String toolCallId, String toolName, Status status,
|
||||||
|
String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly) {
|
||||||
|
this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity, resultChars, progressOnly, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Receipt(String toolCallId, String toolName, Status status,
|
public Receipt(String toolCallId, String toolName, Status status,
|
||||||
@ -98,6 +108,7 @@ public final class ActionExecutionLedger {
|
|||||||
Map<String, String> operations = new LinkedHashMap<>();
|
Map<String, String> operations = new LinkedHashMap<>();
|
||||||
Map<String, String> operationTools = new LinkedHashMap<>();
|
Map<String, String> operationTools = new LinkedHashMap<>();
|
||||||
Map<String, Boolean> progressCalls = new LinkedHashMap<>();
|
Map<String, Boolean> progressCalls = new LinkedHashMap<>();
|
||||||
|
Map<String, ProgressUpdate> progressUpdates = new LinkedHashMap<>();
|
||||||
for (GraphEventPublisher.GraphEvent event : events) {
|
for (GraphEventPublisher.GraphEvent event : events) {
|
||||||
if (event == null || !GraphEventPublisher.EVENT_TOOL_START.equals(event.type())) continue;
|
if (event == null || !GraphEventPublisher.EVENT_TOOL_START.equals(event.type())) continue;
|
||||||
String id = String.valueOf(event.data().getOrDefault("toolCallId", ""));
|
String id = String.valueOf(event.data().getOrDefault("toolCallId", ""));
|
||||||
@ -107,6 +118,8 @@ public final class ActionExecutionLedger {
|
|||||||
String.valueOf(event.data().getOrDefault("arguments", "")));
|
String.valueOf(event.data().getOrDefault("arguments", "")));
|
||||||
progressCalls.put(id, isTeamProgress(name,
|
progressCalls.put(id, isTeamProgress(name,
|
||||||
String.valueOf(event.data().getOrDefault("arguments", ""))));
|
String.valueOf(event.data().getOrDefault("arguments", ""))));
|
||||||
|
progressUpdates.put(id, teamProgressUpdate(name,
|
||||||
|
String.valueOf(event.data().getOrDefault("arguments", ""))));
|
||||||
operationTools.put(id, name);
|
operationTools.put(id, name);
|
||||||
operations.merge(id, identity, (first, next) -> first.equals(next) ? first : UNKNOWN_OPERATION);
|
operations.merge(id, identity, (first, next) -> first.equals(next) ? first : UNKNOWN_OPERATION);
|
||||||
}
|
}
|
||||||
@ -130,7 +143,11 @@ public final class ActionExecutionLedger {
|
|||||||
? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars,
|
? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars,
|
||||||
name.equals(operationTools.get(id))
|
name.equals(operationTools.get(id))
|
||||||
&& !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION))
|
&& !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION))
|
||||||
&& Boolean.TRUE.equals(progressCalls.get(id))));
|
&& Boolean.TRUE.equals(progressCalls.get(id)),
|
||||||
|
name.equals(operationTools.get(id))
|
||||||
|
&& !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION))
|
||||||
|
&& success && acknowledgedProgress(String.valueOf(data.getOrDefault("result", "")))
|
||||||
|
? progressUpdates.get(id) : null));
|
||||||
}
|
}
|
||||||
return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults);
|
return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults);
|
||||||
}
|
}
|
||||||
@ -147,6 +164,34 @@ public final class ActionExecutionLedger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Native callbacks may return the plain acknowledgement or its MethodToolCallback JSON string. */
|
||||||
|
private static boolean acknowledgedProgress(String result) {
|
||||||
|
if ("✓ Progress recorded.".equals(result.trim())) return true;
|
||||||
|
try {
|
||||||
|
var value = OPERATION_MAPPER.readTree(result);
|
||||||
|
return value != null && value.isTextual() && "✓ Progress recorded.".equals(value.textValue());
|
||||||
|
} catch (java.io.IOException invalid) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validTaskId(String value) {
|
||||||
|
if (value == null || !value.matches("[1-9][0-9]{0,18}")) return false;
|
||||||
|
try { return Long.parseLong(value) > 0; } catch (NumberFormatException invalid) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProgressUpdate teamProgressUpdate(String toolName, String arguments) {
|
||||||
|
if (!"team_tasks".equals(toolName)) return null;
|
||||||
|
try {
|
||||||
|
var request = OPERATION_MAPPER.readTree(arguments);
|
||||||
|
if (request == null || !request.isObject() || !"progress".equals(request.path("action").asText())) return null;
|
||||||
|
var id = request.path("taskId");
|
||||||
|
var percent = request.path("percent");
|
||||||
|
if (!(id.isTextual() || id.isIntegralNumber()) || !validTaskId(id.asText())
|
||||||
|
|| !percent.isIntegralNumber() || !percent.canConvertToInt()
|
||||||
|
|| percent.intValue() < 0 || percent.intValue() > 100) return null;
|
||||||
|
return new ProgressUpdate(id.asText(), percent.intValue());
|
||||||
|
} catch (java.io.IOException invalid) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-run receipts survive model-history compaction. Inject these before generation
|
* Per-run receipts survive model-history compaction. Inject these before generation
|
||||||
* so the streamed answer and its persisted copy share the same factual context.
|
* so the streamed answer and its persisted copy share the same factual context.
|
||||||
@ -301,6 +346,20 @@ public final class ActionExecutionLedger {
|
|||||||
}
|
}
|
||||||
successful.add(output);
|
successful.add(output);
|
||||||
}
|
}
|
||||||
|
// Progress is execution evidence for milestone constraints, never the latest substantive output.
|
||||||
|
var progress = facts.putArray("successfulProgressUpdates");
|
||||||
|
Map<String, Long> latestProgressAt = new LinkedHashMap<>();
|
||||||
|
receipts.values().stream().filter(r -> r.status() == Status.SUCCEEDED && r.progressUpdate() != null)
|
||||||
|
.forEach(r -> latestProgressAt.merge(r.progressUpdate().taskId(), r.completedAt(), Math::max));
|
||||||
|
receipts.values().stream().filter(r -> r.status() == Status.SUCCEEDED && r.progressUpdate() != null
|
||||||
|
&& r.completedAt() == latestProgressAt.get(r.progressUpdate().taskId()))
|
||||||
|
.sorted(Comparator.comparingLong(Receipt::completedAt).reversed().thenComparing(Receipt::toolCallId))
|
||||||
|
.forEach(r -> {
|
||||||
|
var node = progress.addObject();
|
||||||
|
node.put("toolCallId", identifierPreview(r.toolCallId()));
|
||||||
|
node.put("taskId", r.progressUpdate().taskId()); node.put("percent", r.progressUpdate().percent());
|
||||||
|
node.put("completedAt", r.completedAt());
|
||||||
|
});
|
||||||
facts.put("omittedFailedCallCount", failures.size() - failed.size());
|
facts.put("omittedFailedCallCount", failures.size() - failed.size());
|
||||||
facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size());
|
facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size());
|
||||||
if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) {
|
if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) {
|
||||||
@ -332,6 +391,7 @@ public final class ActionExecutionLedger {
|
|||||||
var node = OPERATION_MAPPER.createObjectNode();
|
var node = OPERATION_MAPPER.createObjectNode();
|
||||||
node.put("toolCallId", identifierPreview(receipt.toolCallId()));
|
node.put("toolCallId", identifierPreview(receipt.toolCallId()));
|
||||||
node.put("toolName", identifierPreview(receipt.toolName()));
|
node.put("toolName", identifierPreview(receipt.toolName()));
|
||||||
|
node.put("completedAt", receipt.completedAt());
|
||||||
node.put("resultSummary", receipt.resultSummary());
|
node.put("resultSummary", receipt.resultSummary());
|
||||||
int omitted = receipt.resultChars() < 0 ? -1 : Math.max(0, receipt.resultChars() - MAX_RESULT_SUMMARY_CHARS);
|
int omitted = receipt.resultChars() < 0 ? -1 : Math.max(0, receipt.resultChars() - MAX_RESULT_SUMMARY_CHARS);
|
||||||
node.put("resultComplete", omitted == 0);
|
node.put("resultComplete", omitted == 0);
|
||||||
|
|||||||
@ -215,6 +215,7 @@ public class TeamDispatchService {
|
|||||||
WorkerDecisionAdapter.Snapshot snapshot = WorkerDecisionAdapter.Snapshot.capture(task).withConversation(childConvId);
|
WorkerDecisionAdapter.Snapshot snapshot = WorkerDecisionAdapter.Snapshot.capture(task).withConversation(childConvId);
|
||||||
ScheduledFuture<?> heartbeat = null;
|
ScheduledFuture<?> heartbeat = null;
|
||||||
String reply = null;
|
String reply = null;
|
||||||
|
String finishReason = null;
|
||||||
Exception runFailure = null;
|
Exception runFailure = null;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
@ -244,6 +245,7 @@ public class TeamDispatchService {
|
|||||||
AgentService.ChatResult result = agentService.chatWithUsage(
|
AgentService.ChatResult result = agentService.chatWithUsage(
|
||||||
memberId, dispatchContent, childConvId);
|
memberId, dispatchContent, childConvId);
|
||||||
reply = result == null ? null : result.content();
|
reply = result == null ? null : result.content();
|
||||||
|
finishReason = result == null ? null : result.finishReason();
|
||||||
if (reply != null && !reply.isBlank()) {
|
if (reply != null && !reply.isBlank()) {
|
||||||
conversationService.saveMessage(childConvId, "assistant", reply);
|
conversationService.saveMessage(childConvId, "assistant", reply);
|
||||||
}
|
}
|
||||||
@ -278,7 +280,7 @@ public class TeamDispatchService {
|
|||||||
if (runFailure != null) {
|
if (runFailure != null) {
|
||||||
throw runFailure;
|
throw runFailure;
|
||||||
}
|
}
|
||||||
settleOutcome(task, reply, snapshot);
|
settleOutcome(task, reply, snapshot, finishReason);
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
settleRunFailure(teamId, task, error);
|
settleRunFailure(teamId, task, error);
|
||||||
}
|
}
|
||||||
@ -335,6 +337,10 @@ public class TeamDispatchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot) {
|
void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot) {
|
||||||
|
settleOutcome(task, reply, snapshot, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot, String finishReason) {
|
||||||
TeamTaskEntity current = taskService.getTask(task.getId());
|
TeamTaskEntity current = taskService.getTask(task.getId());
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
return;
|
return;
|
||||||
@ -346,6 +352,26 @@ public class TeamDispatchService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) {
|
if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) {
|
||||||
|
// Runtime diagnostic prose is not a malformed business envelope. Explicit non-completion wins
|
||||||
|
// even if the retained candidate happens to contain a valid COMPLETED object.
|
||||||
|
if (finishReason != null && !finishReason.isBlank()
|
||||||
|
&& !Set.of("normal", "summarized", "return_direct").contains(finishReason)) {
|
||||||
|
String reason = "WORKER_RUNTIME_NOT_COMPLETED: finishReason="
|
||||||
|
+ TeamResultExcerpt.label(finishReason, 80);
|
||||||
|
boolean failed;
|
||||||
|
if (decisionAdapter != null && decisionAdapter.enabled()) {
|
||||||
|
var judgment = decisionAdapter.judge(current, "WORKER_RUNTIME_NOT_COMPLETED", reply);
|
||||||
|
failed = taskService.failTask(task.getId(), reason, judgment.ticket(), snapshot);
|
||||||
|
} else {
|
||||||
|
failed = taskService.failTask(task.getId(), reason);
|
||||||
|
}
|
||||||
|
if (failed) {
|
||||||
|
TeamTaskEntity settled = taskService.getTask(task.getId());
|
||||||
|
broadcast(settled != null ? settled : task, "team_task_failed", Map.of("reason", reason));
|
||||||
|
announceService.announceTaskSettled(settled);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Domain guard: a successful model invocation is not a completed business task.
|
// Domain guard: a successful model invocation is not a completed business task.
|
||||||
// OFF retains the legacy text contract; SHADOW records the corrected baseline.
|
// OFF retains the legacy text contract; SHADOW records the corrected baseline.
|
||||||
if (decisionAdapter != null && decisionAdapter.enabled()) {
|
if (decisionAdapter != null && decisionAdapter.enabled()) {
|
||||||
|
|||||||
@ -123,20 +123,20 @@ public class TeamTasksTool {
|
|||||||
|
|
||||||
String conversationId = ToolExecutionContext.conversationId(ctx);
|
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||||
if (conversationId == null || conversationId.isBlank()) {
|
if (conversationId == null || conversationId.isBlank()) {
|
||||||
return "Error: no conversation context bound to this call.";
|
return failure(ctx, "Error: no conversation context bound to this call.");
|
||||||
}
|
}
|
||||||
ConversationEntity conversation = conversationService.findByConversationId(conversationId);
|
ConversationEntity conversation = conversationService.findByConversationId(conversationId);
|
||||||
if (conversation == null || conversation.getAgentId() == null) {
|
if (conversation == null || conversation.getAgentId() == null) {
|
||||||
return "Error: cannot resolve the calling agent for this conversation.";
|
return failure(ctx, "Error: cannot resolve the calling agent for this conversation.");
|
||||||
}
|
}
|
||||||
if (conversation.getWorkspaceId() == null) {
|
if (conversation.getWorkspaceId() == null) {
|
||||||
return "Error: workspaceId is missing from conversation context.";
|
return failure(ctx, "Error: workspaceId is missing from conversation context.");
|
||||||
}
|
}
|
||||||
Long agentId = conversation.getAgentId();
|
Long agentId = conversation.getAgentId();
|
||||||
Long workspaceId = conversation.getWorkspaceId();
|
Long workspaceId = conversation.getWorkspaceId();
|
||||||
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
|
Optional<AgentTeamEntity> teamOpt = teamService.getTeamForAgent(agentId);
|
||||||
if (teamOpt.isEmpty()) {
|
if (teamOpt.isEmpty()) {
|
||||||
return "Error: you are not part of any agent team; team_tasks is unavailable.";
|
return failure(ctx, "Error: you are not part of any agent team; team_tasks is unavailable.");
|
||||||
}
|
}
|
||||||
AgentTeamEntity team = teamOpt.get();
|
AgentTeamEntity team = teamOpt.get();
|
||||||
boolean isLead = teamService.isLead(team, agentId);
|
boolean isLead = teamService.isLead(team, agentId);
|
||||||
@ -153,7 +153,7 @@ public class TeamTasksTool {
|
|||||||
requireApproval, conversationId);
|
requireApproval, conversationId);
|
||||||
case "seal_run" -> sealRun(team, isLead, workspaceId, conversationId, runId);
|
case "seal_run" -> sealRun(team, isLead, workspaceId, conversationId, runId);
|
||||||
case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result);
|
case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result);
|
||||||
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
|
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step, ctx);
|
||||||
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
|
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
|
||||||
case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url);
|
case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url);
|
||||||
case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text);
|
case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text);
|
||||||
@ -165,13 +165,13 @@ public class TeamTasksTool {
|
|||||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
DecisionRecordingException recording = DecisionRecordingException.find(e);
|
DecisionRecordingException recording = DecisionRecordingException.find(e);
|
||||||
if (recording != null) throw recording;
|
if (recording != null) throw recording;
|
||||||
return "Error: " + e.getMessage();
|
return failure(ctx, "Error: " + e.getMessage());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
DecisionRecordingException recording = DecisionRecordingException.find(e);
|
DecisionRecordingException recording = DecisionRecordingException.find(e);
|
||||||
if (recording != null) throw recording;
|
if (recording != null) throw recording;
|
||||||
log.warn("team_tasks {} failed for team={} agent={}: {}",
|
log.warn("team_tasks {} failed for team={} agent={}: {}",
|
||||||
action, team.getId(), agentId, e.getMessage());
|
action, team.getId(), agentId, e.getMessage());
|
||||||
return "Error: team_tasks failed — " + e.getMessage();
|
return failure(ctx, "Error: team_tasks failed — " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,11 +275,17 @@ public class TeamTasksTool {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String failure(ToolContext ctx, String message) {
|
||||||
|
var capture = vip.mate.tool.mcp.runtime.McpToolResultCapture.from(ctx);
|
||||||
|
if (capture != null) capture.recordFailure(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
private String progress(AgentTeamEntity team, Long agentId, Long taskId,
|
private String progress(AgentTeamEntity team, Long agentId, Long taskId,
|
||||||
Integer percent, String step) {
|
Integer percent, String step, ToolContext ctx) {
|
||||||
requireTaskInTeam(team, taskId);
|
requireTaskInTeam(team, taskId);
|
||||||
if (percent != null && (percent < 0 || percent > 100)) {
|
if (percent != null && (percent < 0 || percent > 100)) {
|
||||||
return "Error: percent must be between 0 and 100.";
|
return failure(ctx, "Error: percent must be between 0 and 100.");
|
||||||
}
|
}
|
||||||
boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
|
boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@ -293,7 +299,7 @@ public class TeamTasksTool {
|
|||||||
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra);
|
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra);
|
||||||
}
|
}
|
||||||
return ok ? "✓ Progress recorded."
|
return ok ? "✓ Progress recorded."
|
||||||
: "Error: task is not in progress under your ownership; progress not recorded.";
|
: failure(ctx, "Error: task is not in progress under your ownership; progress not recorded.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String comment(AgentTeamEntity team, Long agentId, Long taskId,
|
private String comment(AgentTeamEntity team, Long agentId, Long taskId,
|
||||||
|
|||||||
@ -53,6 +53,18 @@ class TaskFinalizationCoordinatorTest {
|
|||||||
assertEquals(TaskFinalizationGate.Outcome.ACCEPT,review.result().outcome());
|
assertEquals(TaskFinalizationGate.Outcome.ACCEPT,review.result().outcome());
|
||||||
assertEquals(1,review.calls());
|
assertEquals(1,review.calls());
|
||||||
}
|
}
|
||||||
|
@Test void existenceReviewDoesNotInventWholeLineOrExecutionCountRequirements() {
|
||||||
|
when(helper.streamCallSilent(any(), any(), anyString(), anyString())).thenAnswer(call -> {
|
||||||
|
String prompt = ((Prompt)call.getArgument(1)).getContents();
|
||||||
|
assertTrue(prompt.contains("substring anywhere"));
|
||||||
|
assertTrue(prompt.contains("standalone line"));
|
||||||
|
assertTrue(prompt.contains("not a claim that those tests were executed"));
|
||||||
|
assertTrue(prompt.contains("successfulProgressUpdates"));
|
||||||
|
return result(json("ACCEPT", "[]"));
|
||||||
|
});
|
||||||
|
assertEquals(TaskFinalizationGate.Outcome.ACCEPT, review(16000, 0, 0).result().outcome());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void revisionFeedbackRequestsSpecificOffendingClauseAndGroundedReplacement() {
|
@Test void revisionFeedbackRequestsSpecificOffendingClauseAndGroundedReplacement() {
|
||||||
when(helper.streamCallSilent(any(),any(),anyString(),anyString())).thenAnswer(call->{
|
when(helper.streamCallSilent(any(),any(),anyString(),anyString())).thenAnswer(call->{
|
||||||
String prompt=((Prompt)call.getArgument(1)).getContents();
|
String prompt=((Prompt)call.getArgument(1)).getContents();
|
||||||
|
|||||||
@ -32,7 +32,7 @@ class ActionExecutionLedgerTest {
|
|||||||
GraphEventPublisher.toolComplete("read", "read_file", body, true)));
|
GraphEventPublisher.toolComplete("read", "read_file", body, true)));
|
||||||
var progress = ActionExecutionLedger.fromEvents(List.of(
|
var progress = ActionExecutionLedger.fromEvents(List.of(
|
||||||
GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
||||||
GraphEventPublisher.toolComplete("progress", "team_tasks", "Progress recorded", true)));
|
GraphEventPublisher.toolComplete("progress", "team_tasks", "✓ Progress recorded.", true)));
|
||||||
var merged = read.merge(progress);
|
var merged = read.merge(progress);
|
||||||
assertEquals(java.util.Map.of("read", body), merged.latestCompleteResult());
|
assertEquals(java.util.Map.of("read", body), merged.latestCompleteResult());
|
||||||
assertFalse(merged.receipts().get("progress").substantive());
|
assertFalse(merged.receipts().get("progress").substantive());
|
||||||
@ -46,6 +46,82 @@ class ActionExecutionLedgerTest {
|
|||||||
assertEquals(1, merged.merge(failed).failedCallCount());
|
assertEquals(1, merged.merge(failed).failedCallCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void canonicalTeamProgressFactsPreserveLatestPercentWithoutReplacingReadEvidence() throws Exception {
|
||||||
|
String body = "HEAD " + "evidence ".repeat(1000) + " 130 checks passed and evidence retained TAIL";
|
||||||
|
var ledger = ActionExecutionLedger.fromEvents(List.of(
|
||||||
|
GraphEventPublisher.toolComplete("read", "read_file", body, true),
|
||||||
|
GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"step\":\"secret/freeform step\"}"),
|
||||||
|
GraphEventPublisher.toolComplete("progress", "team_tasks", "✓ Progress recorded.", true)));
|
||||||
|
var projection = ledger.projectAnswerFacts(10000, ledger.latestCompleteResult());
|
||||||
|
assertTrue(projection.coreFactsFit());
|
||||||
|
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(projection.text());
|
||||||
|
var progress = facts.path("successfulProgressUpdates").get(0);
|
||||||
|
org.junit.jupiter.api.Assertions.assertNotNull(progress);
|
||||||
|
assertEquals("5", progress.path("taskId").asText());
|
||||||
|
assertEquals(100, progress.path("percent").asInt());
|
||||||
|
assertFalse(projection.text().contains("secret/freeform step"));
|
||||||
|
assertEquals(java.util.Map.of("read", body), ledger.latestCompleteResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void onlyJoinedSuccessfulStrictProgressRequestsEstablishPercent() throws Exception {
|
||||||
|
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
for (String args : List.of("{\"action\":\"progress\",\"percent\":100}",
|
||||||
|
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":\"100\"}",
|
||||||
|
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100.0}",
|
||||||
|
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":101}",
|
||||||
|
"{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"percent\":0}",
|
||||||
|
"{\"action\":\"progress\",\"taskId\":\"not-an-id\",\"percent\":100}")) {
|
||||||
|
var ledger = ActionExecutionLedger.fromEvents(List.of(
|
||||||
|
GraphEventPublisher.toolStart("p", "team_tasks", args),
|
||||||
|
GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)));
|
||||||
|
assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty());
|
||||||
|
}
|
||||||
|
for (var events : List.of(
|
||||||
|
List.of(GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)),
|
||||||
|
List.of(GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
||||||
|
GraphEventPublisher.toolComplete("p", "team_tasks", "Error", false)),
|
||||||
|
List.of(GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
||||||
|
GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"complete\",\"taskId\":\"5\"}"),
|
||||||
|
GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)))) {
|
||||||
|
var ledger = ActionExecutionLedger.fromEvents(events);
|
||||||
|
assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void historicalSuccessFlagWithRejectedNativeTextCannotEstablishMilestone() throws Exception {
|
||||||
|
var ledger = ActionExecutionLedger.fromEvents(List.of(
|
||||||
|
GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
||||||
|
GraphEventPublisher.toolComplete("p", "team_tasks", "Error: progress not recorded", true)));
|
||||||
|
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
|
||||||
|
assertTrue(facts.path("successfulProgressUpdates").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void malformedOrErrorEncodedAcknowledgementsRemainUnproven() throws Exception {
|
||||||
|
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
for (String text : List.of("\"Error: progress not recorded\"", "\"✓ Progress recorded.\" {}",
|
||||||
|
"{\"result\":\"✓ Progress recorded.\"}", "\"\\\"✓ Progress recorded.\\\"\"")) {
|
||||||
|
var ledger = ActionExecutionLedger.fromEvents(List.of(
|
||||||
|
GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
||||||
|
GraphEventPublisher.toolComplete("p", "team_tasks", text, true)));
|
||||||
|
assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void latestProgressPerTaskSurvivesSerializationWithoutReintroducingOlderPercent() throws Exception {
|
||||||
|
var events = List.of(
|
||||||
|
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_START, java.util.Map.of("toolCallId","p1", "toolName","team_tasks", "arguments","{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), 1),
|
||||||
|
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE, java.util.Map.of("toolCallId","p1", "toolName","team_tasks", "result","✓ Progress recorded.", "success",true), 2),
|
||||||
|
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_START, java.util.Map.of("toolCallId","p2", "toolName","team_tasks", "arguments","{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":25}"), 3),
|
||||||
|
new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE, java.util.Map.of("toolCallId","p2", "toolName","team_tasks", "result","✓ Progress recorded.", "success",true), 4));
|
||||||
|
var ledger = ActionExecutionLedger.fromEvents(events);
|
||||||
|
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
var restored = mapper.readValue(mapper.writeValueAsString(ledger.receipts().get("p2")), ActionExecutionLedger.Receipt.class);
|
||||||
|
assertEquals(25, restored.progressUpdate().percent());
|
||||||
|
var progress = mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates");
|
||||||
|
assertEquals(1, progress.size()); assertEquals(25, progress.get(0).path("percent").asInt());
|
||||||
|
assertEquals(4, progress.get(0).path("completedAt").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void ambiguousOrMutatingTeamCallsRemainSubstantive() {
|
@Test void ambiguousOrMutatingTeamCallsRemainSubstantive() {
|
||||||
for (String arguments : List.of("{\"action\":\"complete\",\"taskId\":\"5\"}",
|
for (String arguments : List.of("{\"action\":\"complete\",\"taskId\":\"5\"}",
|
||||||
"{\"action\":\"output\",\"taskId\":\"5\"}",
|
"{\"action\":\"output\",\"taskId\":\"5\"}",
|
||||||
@ -56,7 +132,7 @@ class ActionExecutionLedgerTest {
|
|||||||
assertTrue(ledger.receipts().get("call").substantive());
|
assertTrue(ledger.receipts().get("call").substantive());
|
||||||
}
|
}
|
||||||
var missingStart = ActionExecutionLedger.fromEvents(List.of(
|
var missingStart = ActionExecutionLedger.fromEvents(List.of(
|
||||||
GraphEventPublisher.toolComplete("legacy", "team_tasks", "Progress recorded", true)));
|
GraphEventPublisher.toolComplete("legacy", "team_tasks", "✓ Progress recorded.", true)));
|
||||||
assertTrue(missingStart.receipts().get("legacy").substantive());
|
assertTrue(missingStart.receipts().get("legacy").substantive());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -222,6 +222,37 @@ class TeamDispatchServiceTest {
|
|||||||
verify(taskService, never()).completeTask(any(), any(), anyString());
|
verify(taskService, never()).completeTask(any(), any(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"incomplete", "error_fallback", "max_iterations_reached", "stopped", "evidence_insufficient", "action_unverified", "action_failed"})
|
||||||
|
void incompleteRuntimeIsNotMisreportedAsInvalidWorkerJson(String finishReason) {
|
||||||
|
var adapter = mock(WorkerDecisionAdapter.class);
|
||||||
|
when(adapter.enabled()).thenReturn(true);
|
||||||
|
var ticket = new DecisionTicket(null, DecisionMode.SHADOW, new DecisionValue.BooleanValue(false));
|
||||||
|
when(adapter.judge(any(), anyString(), anyString())).thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false));
|
||||||
|
service.setDecisionAdapter(adapter);
|
||||||
|
TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS);
|
||||||
|
when(taskService.getTask(1L)).thenReturn(assigned);
|
||||||
|
when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())).thenReturn(
|
||||||
|
new AgentService.ChatResult("任务尚未完成:文字修正已达上限", 10, 5, "model", "provider", finishReason));
|
||||||
|
service.runTask(TEAM_ID, assigned);
|
||||||
|
verify(adapter).judge(eq(assigned), eq("WORKER_RUNTIME_NOT_COMPLETED"), anyString());
|
||||||
|
verify(taskService).failTask(eq(1L), startsWith("WORKER_RUNTIME_NOT_COMPLETED:"), eq(ticket), any());
|
||||||
|
verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any());
|
||||||
|
verify(adapter, never()).judge(any(), eq("INVALID_RESULT_CONTRACT"), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validJsonCannotOverrideAnIncompleteRuntimeEvenWhenDecisionEnvelopeIsDisabled() {
|
||||||
|
TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS);
|
||||||
|
when(taskService.getTask(1L)).thenReturn(assigned);
|
||||||
|
when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())).thenReturn(
|
||||||
|
new AgentService.ChatResult("{\"status\":\"COMPLETED\",\"result\":\"done\",\"evidence\":\"checked\"}",
|
||||||
|
10, 5, "model", "provider", "incomplete"));
|
||||||
|
service.runTask(TEAM_ID, assigned);
|
||||||
|
verify(taskService).failTask(eq(1L), startsWith("WORKER_RUNTIME_NOT_COMPLETED:"));
|
||||||
|
verify(taskService, never()).completeTask(any(), any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void childStreamClosesBeforeLeadReceivesSuccessfulResult() {
|
void childStreamClosesBeforeLeadReceivesSuccessfulResult() {
|
||||||
TeamTaskEntity assigned = task(1L, MEMBER_A);
|
TeamTaskEntity assigned = task(1L, MEMBER_A);
|
||||||
|
|||||||
@ -113,6 +113,43 @@ class TeamTasksToolTest {
|
|||||||
null, null, null, null, null, null, null, null, null, null, null);
|
null, null, null, null, null, null, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void methodCallbackEncodedProgressIsGroundedInActualSuccessfulWrite() throws Exception {
|
||||||
|
callerIs(MEMBER_ID);
|
||||||
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||||||
|
when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(true);
|
||||||
|
var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture();
|
||||||
|
var ctx = capture.attach(new ToolContext(java.util.Map.of()));
|
||||||
|
String args = "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"step\":\"done\"}";
|
||||||
|
var callback = org.springframework.ai.support.ToolCallbacks.from(tool)[0];
|
||||||
|
String result = callback.call(args, ctx);
|
||||||
|
assertFalse(capture.isError());
|
||||||
|
var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of(
|
||||||
|
vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", args),
|
||||||
|
vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError())));
|
||||||
|
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
|
||||||
|
assertEquals(1, facts.path("successfulProgressUpdates").size());
|
||||||
|
assertEquals(100, facts.path("successfulProgressUpdates").get(0).path("percent").asInt());
|
||||||
|
verify(taskService).updateProgress(5L, MEMBER_ID, 100, "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void rejectedNativeProgressCannotEstablishSuccessfulMilestone() throws Exception {
|
||||||
|
callerIs(MEMBER_ID);
|
||||||
|
when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||||||
|
when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(false);
|
||||||
|
var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture();
|
||||||
|
var ctx = capture.attach(new ToolContext(java.util.Map.of()));
|
||||||
|
String result = tool.team_tasks("progress", "5", null, null, null, null, null, null,
|
||||||
|
null, null, null, null, 100, "done", null, null, null, null, ctx);
|
||||||
|
assertTrue(capture.isError());
|
||||||
|
var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of(
|
||||||
|
vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"),
|
||||||
|
vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError())));
|
||||||
|
assertEquals(1, ledger.failedCallCount());
|
||||||
|
var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text());
|
||||||
|
assertTrue(facts.path("successfulProgressUpdates").isEmpty());
|
||||||
|
verify(eventChannel, never()).publishTaskEvent(any(), eq("team_task_progress"), any());
|
||||||
|
}
|
||||||
|
|
||||||
private TeamRunEntity run(Long teamId, String conversationId, String status) {
|
private TeamRunEntity run(Long teamId, String conversationId, String status) {
|
||||||
TeamRunEntity run = new TeamRunEntity();
|
TeamRunEntity run = new TeamRunEntity();
|
||||||
run.setId(RUN_ID);
|
run.setId(RUN_ID);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user