diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 2e39e19d..226a294a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -139,6 +139,18 @@ public class ReasoningNode implements NodeAction { "(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)"); private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile( "(一万|1万|十千)\\s*(字|字符|中文字|汉字)"); + private static final Pattern OUTPUT_LENGTH_INTENT_PATTERN = Pattern.compile( + "(?i)(?:写|撰写|创作|扩写|续写)[^\\n]{0,48}$|" + + "(?:^|[::\\s])(?:请帮我|请为我|请|帮我|为我|给我|麻烦|需要|务必|必须|希望)?" + + "(?:写|撰写|创作|生成|输出|扩写|续写|回答|回复|write\\b|compose\\b|produce\\b|generate\\b|respond\\b|answer\\b)" + + "[^\\n]{0,48}$"); + private static final Pattern DIRECT_LENGTH_TARGET_PATTERN = Pattern.compile( + "(?i)^(?:约|大约|一篇|一部|一份|请用|用|以|about|around|至少|不少于|不低于|at least|no less than)?\\s*$"); + private static final Pattern CLAUSE_LENGTH_TARGET_PATTERN = Pattern.compile( + "(?i)^(?:要求|全文(?:字数|长度)?|字数|长度|篇幅|length|word count)\\s*[::]?\\s*" + + "(?:约|大约|为|是|至少|不少于|不低于|about|around|at least)?\\s*$"); + private static final Pattern LENGTH_CEILING_PATTERN = Pattern.compile( + "(?i)(?:不超过|不能超过|不要超过|最多|至多|少于|小于|\\bno more than|\\bat most|\\bless than|\\bunder|\\bup to|\\bmaximum)\\s*$"); private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile( "(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)"); private static final List ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of( @@ -247,13 +259,21 @@ public class ReasoningNode implements NodeAction { if (userMessage == null || userMessage.isBlank()) { return OptionalInt.empty(); } + // Structured workers deliver a bounded JSON summary, even when their + // task describes a much longer source. Let the worker result contract + // validate completion instead of forcing prose continuation by source size. + if (userMessage.contains("[Final result contract]\n" + + vip.mate.agent.runtime.WorkerResultContract.INSTRUCTIONS)) { + return OptionalInt.empty(); + } Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage); - if (tenThousand.find()) { - return OptionalInt.of(10_000); + while (tenThousand.find()) { + if (isOutputLengthTarget(userMessage, tenThousand.start())) return OptionalInt.of(10_000); } Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage); int best = 0; while (matcher.find()) { + if (!isOutputLengthTarget(userMessage, matcher.start())) continue; String rawNumber = matcher.group(1).replace(",", "").replace(",", ""); double value; try { @@ -272,6 +292,18 @@ public class ReasoningNode implements NodeAction { return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty(); } + /** Counts describing source material and output ceilings are not minimum writing targets. */ + private static boolean isOutputLengthTarget(String request, int numberStart) { + int clauseStart = numberStart; + while (clauseStart > 0 && "\n。!?!?;;,,".indexOf(request.charAt(clauseStart - 1)) < 0) clauseStart--; + String prefix = request.substring(clauseStart, numberStart).strip(); + return !LENGTH_CEILING_PATTERN.matcher(prefix).find() + && (DIRECT_LENGTH_TARGET_PATTERN.matcher(prefix).matches() + || OUTPUT_LENGTH_INTENT_PATTERN.matcher(prefix).find() + || (CLAUSE_LENGTH_TARGET_PATTERN.matcher(prefix).matches() + && OUTPUT_LENGTH_INTENT_PATTERN.matcher(request.substring(0, clauseStart)).find())); + } + static List filterLongFormArtifactTools(String userMessage, List callbacks) { String currentRequest = currentUserRequest(userMessage); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java index 72e7b0a3..117ebaf6 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java @@ -30,7 +30,7 @@ public final class ActionExecutionLedger { public enum Status { SUCCEEDED, FAILED } public record Receipt(String toolCallId, String toolName, Status status, - String resultSummary, long completedAt, String operationIdentity, int resultChars) { + String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly) { public Receipt { // Older serialized receipts have no original output length (primitive defaults to zero). if (resultSummary != null && !resultSummary.isEmpty() && resultChars == 0) resultChars = -1; @@ -39,6 +39,11 @@ public final class ActionExecutionLedger { } } + public Receipt(String toolCallId, String toolName, Status status, + String resultSummary, long completedAt, String operationIdentity, int resultChars) { + this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity, resultChars, false); + } + public Receipt(String toolCallId, String toolName, Status status, String resultSummary, long completedAt, String operationIdentity) { this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity, @@ -51,7 +56,7 @@ public final class ActionExecutionLedger { this(toolCallId, toolName, status, resultSummary, completedAt, UNKNOWN_OPERATION); } public boolean substantive() { - return toolName != null && !NON_SUBSTANTIVE_TOOLS.contains(toolName); + return !progressOnly && toolName != null && !NON_SUBSTANTIVE_TOOLS.contains(toolName); } } @@ -92,6 +97,7 @@ public final class ActionExecutionLedger { // which request completed. Conflicting duplicate starts stay unknown. Map operations = new LinkedHashMap<>(); Map operationTools = new LinkedHashMap<>(); + Map progressCalls = new LinkedHashMap<>(); for (GraphEventPublisher.GraphEvent event : events) { if (event == null || !GraphEventPublisher.EVENT_TOOL_START.equals(event.type())) continue; String id = String.valueOf(event.data().getOrDefault("toolCallId", "")); @@ -99,6 +105,8 @@ public final class ActionExecutionLedger { String name = String.valueOf(event.data().getOrDefault("toolName", "")); String identity = operationIdentity(name, String.valueOf(event.data().getOrDefault("arguments", ""))); + progressCalls.put(id, isTeamProgress(name, + String.valueOf(event.data().getOrDefault("arguments", "")))); operationTools.put(id, name); operations.merge(id, identity, (first, next) -> first.equals(next) ? first : UNKNOWN_OPERATION); } @@ -119,11 +127,26 @@ public final class ActionExecutionLedger { receipts.put(id, new Receipt(id, name, success ? Status.SUCCEEDED : Status.FAILED, result, event.timestamp(), name.equals(operationTools.get(id)) - ? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars)); + ? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars, + name.equals(operationTools.get(id)) + && !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION)) + && Boolean.TRUE.equals(progressCalls.get(id)))); } return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults); } + /** Progress changes metadata, not the substantive result used to verify the answer. */ + private static boolean isTeamProgress(String toolName, String arguments) { + if (!"team_tasks".equals(toolName)) return false; + try { + var request = OPERATION_MAPPER.readTree(arguments); + return request != null && request.isObject() + && "progress".equals(request.path("action").asText()); + } catch (java.io.IOException invalid) { + return false; + } + } + /** * Per-run receipts survive model-history compaction. Inject these before generation * so the streamed answer and its persisted copy share the same factual context. diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java index 9ecba9fc..dd019915 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java @@ -52,6 +52,7 @@ public class TeamAnnounceService { /** A batch drains immediately once it reaches this size. */ static final int MAX_BATCH = 20; + static final int MAX_ANNOUNCEMENT_CHARS = 16000; /** Re-check interval while waiting for a busy lead to go idle. */ static final long BUSY_RETRY_MILLIS = 2000; @@ -304,14 +305,22 @@ public class TeamAnnounceService { } sb.append(".\n"); } - for (AnnounceItem item : items) { - sb.append("\n--- Task #").append(item.taskNumber()) - .append(" \"").append(item.subject()).append("\" — ") - .append(item.status()) - .append(" (member: ").append(item.memberName()).append(") ---\n"); - if (!item.detail().isBlank()) { - sb.append(item.detail()).append('\n'); - } + int visibleCount = Math.min(items.size(), MAX_BATCH); + int itemBudget = visibleCount == 0 ? 0 : (MAX_ANNOUNCEMENT_CHARS - 1000) / visibleCount; + for (AnnounceItem item : items.subList(0, visibleCount)) { + String heading = "\n--- Task #" + item.taskNumber() + " \"" + + TeamResultExcerpt.label(item.subject(), 160) + "\" — " + item.status() + + " (member: " + TeamResultExcerpt.label(item.memberName(), 80) + ") ---\n"; + String reference = "Full worker output: " + TeamResultExcerpt.outputReference(item.taskId()) + "\n"; + sb.append(heading); + sb.append(TeamResultExcerpt.render(item.detail(), + itemBudget - heading.length() - reference.length() - 1, item.taskId())).append('\n'); + sb.append(reference); + } + if (items.size() > visibleCount) { + sb.append("\n").append(items.size() - visibleCount) + .append(" additional task details omitted to protect context. ") + .append("Use team_tasks(action=\"list\") and retrieve their task outputs.\n"); } sb.append(""" diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index 0b3738b6..e60219dc 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -63,7 +63,7 @@ public class TeamDispatchService { public void setDecisionAdapter(WorkerDecisionAdapter adapter) { this.decisionAdapter = adapter; } /** Result summaries are capped before persisting to keep the board readable. */ - static final int MAX_RESULT_CHARS = 8000; + static final int MAX_RESULT_CHARS = TeamTaskService.MAX_RESULT_CHARS; /** Empty/fallback member runs get one recovery attempt, not three long identical runs. */ static final int MAX_RESPONSE_FAILURE_DISPATCHES = 2; @@ -214,79 +214,95 @@ public class TeamDispatchService { String childConvId = "team-task-" + IdUtil.fastSimpleUUID(); WorkerDecisionAdapter.Snapshot snapshot = WorkerDecisionAdapter.Snapshot.capture(task).withConversation(childConvId); ScheduledFuture heartbeat = null; + String reply = null; + Exception runFailure = null; try { - AgentTeamEntity team = teamService.getTeam(teamId); - conversationService.createChildConversation(childConvId, memberId, "system", - team == null ? null : team.getWorkspaceId(), task.getLeadConversationId(), - "team_worker"); - taskService.attachConversation(task.getId(), childConvId); - // Track the child run so graph nodes honor requestStop() — without a - // registered RunState, cancelling the task could never interrupt the - // member mid-run. - streamTracker.register(childConvId); - streamTracker.incrementFlux(childConvId); - // Renew the execution lease while the member works; the conditional - // UPDATE inside renewLock makes this a no-op once the task settles. - heartbeat = startLeaseHeartbeat(task.getId()); - broadcast(task, "team_task_dispatched", Map.of()); - log.info("Team {} task #{} dispatched to agent {} (conv {})", - teamId, task.getTaskNumber(), memberId, childConvId); + try { + AgentTeamEntity team = teamService.getTeam(teamId); + conversationService.createChildConversation(childConvId, memberId, "system", + team == null ? null : team.getWorkspaceId(), task.getLeadConversationId(), + "team_worker"); + taskService.attachConversation(task.getId(), childConvId); + // Track the child run so graph nodes honor requestStop() — without a + // registered RunState, cancelling the task could never interrupt the + // member mid-run. + streamTracker.register(childConvId); + streamTracker.incrementFlux(childConvId); + // Renew the execution lease while the member works; the conditional + // UPDATE inside renewLock makes this a no-op once the task settles. + heartbeat = startLeaseHeartbeat(task.getId()); + broadcast(task, "team_task_dispatched", Map.of()); + log.info("Team {} task #{} dispatched to agent {} (conv {})", + teamId, task.getTaskNumber(), memberId, childConvId); - // Message persistence is the caller's contract (the graph expects the - // current user message to already be the conversation's last row), - // and the persisted pair is what makes the run's transcript - // reviewable from the task card. - String dispatchContent = buildDispatchContent(task); - conversationService.saveMessage(childConvId, "user", dispatchContent); - AgentService.ChatResult result = agentService.chatWithUsage( - memberId, dispatchContent, childConvId); - String reply = result == null ? null : result.content(); - if (reply != null && !reply.isBlank()) { - conversationService.saveMessage(childConvId, "assistant", reply); - } - - PendingApproval pending = approvalService.findPendingByConversation(childConvId); - if (pending != null) { - String summary = pending.getSummary() == null || pending.getSummary().isBlank() - ? pending.getReason() : pending.getSummary(); - if (taskService.parkForToolApproval(task.getId(), pending.getPendingId(), summary)) { - TeamTaskEntity parked = taskService.getTask(task.getId()); - broadcast(parked != null ? parked : task, "team_task_awaiting_approval", - Map.of("pendingId", pending.getPendingId(), - "toolName", pending.getToolName() == null ? "" : pending.getToolName(), - "summary", summary == null ? "Tool approval required" : summary)); + // Message persistence is the caller's contract (the graph expects the + // current user message to already be the conversation's last row), + // and the persisted pair is what makes the run's transcript + // reviewable from the task card. + String dispatchContent = buildDispatchContent(task); + conversationService.saveMessage(childConvId, "user", dispatchContent); + AgentService.ChatResult result = agentService.chatWithUsage( + memberId, dispatchContent, childConvId); + reply = result == null ? null : result.content(); + if (reply != null && !reply.isBlank()) { + conversationService.saveMessage(childConvId, "assistant", reply); } - return; - } - settleOutcome(task, reply, snapshot); - } catch (Exception e) { - if (DecisionRecordingException.find(e) != null) { - log.warn("Team task #{} decision recording failed; retaining current state", task.getTaskNumber()); - return; + PendingApproval pending = approvalService.findPendingByConversation(childConvId); + if (pending != null) { + String summary = pending.getSummary() == null || pending.getSummary().isBlank() + ? pending.getReason() : pending.getSummary(); + if (taskService.parkForToolApproval(task.getId(), pending.getPendingId(), summary)) { + TeamTaskEntity parked = taskService.getTask(task.getId()); + broadcast(parked != null ? parked : task, "team_task_awaiting_approval", + Map.of("pendingId", pending.getPendingId(), + "toolName", pending.getToolName() == null ? "" : pending.getToolName(), + "summary", summary == null ? "Tool approval required" : summary)); + } + return; + } + + } catch (Exception error) { + runFailure = error; + } finally { + if (heartbeat != null) { + heartbeat.cancel(false); + } + streamTracker.complete(childConvId); + runningMembers.remove(memberId); } - log.warn("Team {} task #{} member run ended exceptionally: {}", teamId, - task.getTaskNumber(), e.getMessage()); - // Only report a failure the guarded transition actually applied — an - // interrupted run whose task is already cancelled must not produce a - // misleading failed event on top of the terminal state. - boolean failed = taskService.failTask(task.getId(), - truncate("member run error: " + e.getMessage(), 1000)); - if (failed) { - broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(e.getMessage()))); - announceService.announceTaskSettled(taskService.getTask(task.getId())); + // Close the child before settlement can wake the parent or dispatch + // follow-up work. A lead must never observe a finished task with its + // worker still registered as running. + try { + if (runFailure != null) { + throw runFailure; + } + settleOutcome(task, reply, snapshot); + } catch (Exception error) { + settleRunFailure(teamId, task, error); } } finally { - if (heartbeat != null) { - heartbeat.cancel(false); - } - streamTracker.complete(childConvId); - runningMembers.remove(memberId); - // Chain: dispatch released dependents and the member's next task. requestDispatch(teamId); } } + private void settleRunFailure(Long teamId, TeamTaskEntity task, Exception error) { + if (DecisionRecordingException.find(error) != null) { + log.warn("Team task #{} decision recording failed; retaining current state", task.getTaskNumber()); + return; + } + log.warn("Team {} task #{} member run ended exceptionally: {}", teamId, + task.getTaskNumber(), error.getMessage()); + // A cancelled worker must not publish a failure over its terminal state. + boolean failed = taskService.failTask(task.getId(), + truncate("member run error: " + error.getMessage(), 1000)); + if (failed) { + broadcast(task, "team_task_failed", Map.of("reason", String.valueOf(error.getMessage()))); + announceService.announceTaskSettled(taskService.getTask(task.getId())); + } + } + /** Share the same DB-backed lease heartbeat with controlled worker replays. */ ScheduledFuture startLeaseHeartbeat(Long taskId) { return HEARTBEAT_SCHEDULER.scheduleAtFixedRate( @@ -398,7 +414,8 @@ public class TeamDispatchService { if (terminalAlreadyAcknowledged) { DecisionTicket ticket = (decisionAdapter == null || !decisionAdapter.enabled()) ? null : decisionAdapter.judge(current, "CHECKPOINT_ACK", reply).ticket(); - List released = completeWorker(task.getId(), truncate(reply, MAX_RESULT_CHARS), ticket, snapshot); + List released = completeWorker(task.getId(), + TeamResultExcerpt.render(reply, MAX_RESULT_CHARS, task.getId()), ticket, snapshot); if (released == null) return; TeamTaskEntity completed = taskService.getTask(task.getId()); log.info("Team task #{} completed after deferred {} acknowledgement " @@ -441,7 +458,8 @@ public class TeamDispatchService { } } List released = completeWorker(task.getId(), - truncate(reply == null || reply.isBlank() ? "(no output)" : reply, MAX_RESULT_CHARS), ticket, snapshot); + TeamResultExcerpt.render(reply == null || reply.isBlank() ? "(no output)" : reply, + MAX_RESULT_CHARS, task.getId()), ticket, snapshot); if (released == null) return; current = taskService.getTask(task.getId()); log.info("Team task #{} auto-completed ({} dependents released)", @@ -450,8 +468,12 @@ public class TeamDispatchService { String event = switch (current.getStatus()) { case TeamTaskStatus.FAILED -> "team_task_failed"; case TeamTaskStatus.IN_REVIEW -> "team_task_in_review"; - default -> "team_task_completed"; + case TeamTaskStatus.COMPLETED -> "team_task_completed"; + default -> null; }; + if (event == null) { + return; + } Map payload = new HashMap<>(); payload.put("status", current.getStatus()); if (current.getResult() != null) { @@ -674,36 +696,52 @@ public class TeamDispatchService { if (blockerIds.isEmpty()) { return; } - StringBuilder section = new StringBuilder(); - for (Long blockerId : blockerIds) { + int visibleCount = Math.min(blockerIds.size(), 8); + int itemBudget = (MAX_PREREQ_SECTION_CHARS - 256) / visibleCount; + StringBuilder section = new StringBuilder("\n[Prerequisite results]\n"); + for (Long blockerId : blockerIds.subList(0, visibleCount)) { TeamTaskEntity blocker = taskService.getTask(blockerId); if (blocker == null) { continue; } - section.append("- #").append(blocker.getTaskNumber()) - .append(" \"").append(blocker.getSubject()).append("\" (") - .append(blocker.getStatus()).append(')'); + String heading = "- #" + blocker.getTaskNumber() + " \"" + + TeamResultExcerpt.label(blocker.getSubject(), 120) + "\" (" + + blocker.getStatus() + ")\n"; + String reference = " Full worker output: " + TeamResultExcerpt.outputReference(blockerId) + "\n"; + StringBuilder row = new StringBuilder(heading); + int resultBudget = Math.min(MAX_PREREQ_RESULT_CHARS, + (itemBudget - heading.length() - reference.length() - 120) * 2 / 3); if (blocker.getResult() != null && !blocker.getResult().isBlank()) { - section.append(": ").append(truncate(blocker.getResult().strip(), - MAX_PREREQ_RESULT_CHARS)); + row.append(TeamResultExcerpt.render(blocker.getResult().strip(), resultBudget, blockerId)) + .append('\n'); } - section.append('\n'); + row.append(reference); + int omittedFiles = 0; for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) { - section.append(" File: ").append(file.name()).append(" → ") - .append(file.url()).append('\n'); + String fileLine = " File: " + file.name() + " → " + file.url() + "\n"; String inspectionPath = generatedFileInspectionPath(file.url()); if (inspectionPath != null) { - section.append(" Inspect locally: ").append(inspectionPath) - .append(" (do not guess an HTTP port)\n"); + fileLine += " Inspect locally: " + inspectionPath + " (do not guess an HTTP port)\n"; + } + if (row.length() + fileLine.length() + 120 <= itemBudget) { + row.append(fileLine); + } else { + omittedFiles++; } } + if (omittedFiles > 0) { + row.append(" ").append(omittedFiles).append(" deliverable(s) omitted; use team_tasks(action=\"get\", taskId=\"") + .append(blockerId).append("\").\n"); + } + section.append(row); } - if (section.isEmpty()) { - return; + if (blockerIds.size() > visibleCount) { + section.append(blockerIds.size() - visibleCount) + .append(" additional prerequisite(s) omitted; use team_tasks(action=\"get\", taskId=\"") + .append(task.getId()).append("\") to inspect dependencies.\n"); } - sb.append("\n[Prerequisite results]\n") - .append(truncate(section.toString(), MAX_PREREQ_SECTION_CHARS)) - .append("Use team_tasks(action=\"get\", taskId=...) for any full record.\n"); + section.append("Use team_tasks(action=\"get\", taskId=...) for task records and deliverable links.\n"); + sb.append(section); } static String generatedFileInspectionPath(String url) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamResultExcerpt.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamResultExcerpt.java new file mode 100644 index 00000000..8665c8b0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamResultExcerpt.java @@ -0,0 +1,51 @@ +package vip.mate.team.service; + +/** Bounded model-facing excerpts; persisted worker messages remain authoritative. */ +public final class TeamResultExcerpt { + + private TeamResultExcerpt() { + } + + public static String outputReference(Long taskId) { + return "team_tasks(action=\"output\", taskId=\"" + taskId + "\")"; + } + + /** Bound descriptive labels separately so names cannot consume an evidence budget. */ + public static String label(String text, int maxChars) { + if (text == null) return ""; + if (maxChars < 1) return ""; + int end = Math.min(text.length(), maxChars); + if (end < text.length() && end > 0 && Character.isHighSurrogate(text.charAt(end - 1)) + && Character.isLowSurrogate(text.charAt(end))) { + end--; + } + return text.substring(0, end).replace('\n', ' ').replace('\r', ' '); + } + + /** Keeps both the opening context and the final outcome, including the marker in the cap. */ + public static String render(String text, int maxChars, Long taskId) { + if (text == null || text.isEmpty()) { + return ""; + } + if (text.length() <= maxChars) { + return text; + } + String marker = "\n[... omitted; full output: " + outputReference(taskId) + + ". Do not infer omitted content.]\n"; + int available = maxChars - marker.length(); + if (available < 2) { + throw new IllegalArgumentException("Excerpt budget cannot hold the output reference"); + } + int headEnd = available * 3 / 4; + int tailStart = text.length() - (available - headEnd); + if (headEnd > 0 && Character.isHighSurrogate(text.charAt(headEnd - 1)) + && Character.isLowSurrogate(text.charAt(headEnd))) { + headEnd--; + } + if (tailStart > 0 && Character.isLowSurrogate(text.charAt(tailStart)) + && Character.isHighSurrogate(text.charAt(tailStart - 1))) { + tailStart++; + } + return text.substring(0, headEnd) + marker + text.substring(tailStart); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index dd477261..57cd81ae 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -48,6 +48,8 @@ import java.util.regex.Pattern; @RequiredArgsConstructor public class TeamTaskService { + public static final int MAX_RESULT_CHARS = 8000; + private static final int MAX_STAGED_REPLAY_RESULT_CHARS = 8000; private static final Pattern CHECKPOINT_RANGE = Pattern.compile( @@ -303,6 +305,11 @@ public class TeamTaskService { } private Completion completeTaskInternal(Long taskId, Long agentId, String result, boolean observe, TeamTaskEntity locked) { + if (result != null && result.length() > MAX_RESULT_CHARS) { + throw new IllegalArgumentException("result summary must not exceed " + MAX_RESULT_CHARS + + " characters; keep a concise summary here and put full content in your final reply" + + " or attach a deliverable"); + } TeamTaskEntity task = locked == null ? requireTask(taskId) : locked; if (TeamTaskStatus.PENDING.equals(task.getStatus()) && agentId != null) { if (task.getAssigneeAgentId() != null && !agentId.equals(task.getAssigneeAgentId())) { @@ -1297,7 +1304,7 @@ public class TeamTaskService { return JSONUtil.toJsonStr(ids.stream().map(String::valueOf).toList()); } - static List parseIdArray(String json) { + public static List parseIdArray(String json) { if (json == null || json.isBlank()) { return Collections.emptyList(); } diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java index 23ef8c98..1d29226e 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java @@ -48,15 +48,11 @@ public class TeamWorkerConversationGovernanceService { return Optional.empty(); } TeamRunEntity run = runMapper.selectById(task.getRunId()); - boolean legacyMissingParent = conversation.getParentConversationId() == null - && !"team_worker".equals(conversation.getConversationKind()) - && conversationId.startsWith("team-task-"); if (run == null || !Objects.equals(run.getTeamId(), task.getTeamId()) || !Objects.equals(conversation.getWorkspaceId(), run.getWorkspaceId()) || !Objects.equals(conversation.getAgentId(), task.getAssigneeAgentId()) - || !legacyMissingParent - && !Objects.equals(conversation.getParentConversationId(), run.getLeadConversationId())) { + || !matchesParent(conversation, run.getLeadConversationId())) { return Optional.empty(); } return Optional.of(new TeamWorkerConversationContext( @@ -64,6 +60,17 @@ public class TeamWorkerConversationGovernanceService { run.getLeadConversationId(), task.getAssigneeAgentId())); } + /** Shared canonical parent rule, including the established pre-kind legacy fallback. */ + public static boolean matchesParent(ConversationEntity conversation, String leadConversationId) { + if (conversation == null) return false; + boolean legacyMissingParent = conversation.getParentConversationId() == null + && !"team_worker".equals(conversation.getConversationKind()) + && conversation.getConversationId() != null + && conversation.getConversationId().startsWith("team-task-"); + return legacyMissingParent + || Objects.equals(conversation.getParentConversationId(), leadConversationId); + } + /** * Team worker transcripts are read-only evidence for a team run. The worker * conversation is owned by the executing agent/user, so workspace admins and diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java index b7de8a32..36bf8b8b 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -24,15 +24,18 @@ import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamRunService; import vip.mate.team.service.TeamTaskService; +import vip.mate.team.service.TeamWorkerConversationGovernanceService; import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.model.MessageEntity; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Objects; import java.util.regex.Pattern; /** @@ -65,6 +68,7 @@ public class TeamTasksTool { @Tool(description = "Operate your team's shared task board. Actions: " + "'list' all tasks; 'get' one task with comments (taskId); " + + "'output' reads the complete latest saved worker reply (taskId), useful when a result summary was shortened; " + "'start_run' (lead only; objective required, optional title) returns a runId; " + "'create' stages a task (lead only; runId, subject, description, assigneeAgentId required, " + "optional blockedBy comma-separated prerequisite task ids, priority, higher first, " @@ -79,9 +83,9 @@ public class TeamTasksTool { + "'retry' a failed/stale task back to pending (lead only; taskId). " + "Only usable when you belong to an agent team.") public String team_tasks( - @ToolParam(description = "One of: start_run, create, seal_run, list, get, complete, progress, comment, attach, cancel, retry") + @ToolParam(description = "One of: start_run, create, seal_run, list, get, output, complete, progress, comment, attach, cancel, retry") String action, - @ToolParam(description = "Task id (string form is fine) — required by get/complete/progress/comment/attach/cancel/retry", required = false) + @ToolParam(description = "Task id (string form is fine) — required by get/output/complete/progress/comment/attach/cancel/retry", required = false) String taskId, @ToolParam(description = "create/seal_run: explicit team run id", required = false) String runId, @@ -101,7 +105,7 @@ public class TeamTasksTool { Integer priority, @ToolParam(description = "create: true to require human approval before the finished task counts as done", required = false) Boolean requireApproval, - @ToolParam(description = "complete: result summary reported back to the lead", required = false) + @ToolParam(description = "complete: result summary reported back to the lead (maximum 8000 characters; put full content in the final reply or attach a deliverable)", required = false) String result, @ToolParam(description = "progress: completion percent 0-100", required = false) Integer percent, @@ -143,6 +147,7 @@ public class TeamTasksTool { conversationId, title, objective, ctx); case "list" -> renderBoard(team); case "get" -> renderDetail(team, parseId(taskId, "taskId")); + case "output" -> renderOutput(team, workspaceId, parseId(taskId, "taskId")); case "create" -> createTask(team, agentId, isLead, workspaceId, runId, subject, description, assigneeAgentId, blockedBy, priority, requireApproval, conversationId); @@ -154,7 +159,7 @@ public class TeamTasksTool { case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text); case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId")); default -> "Error: unknown action '" + action - + "'. Use one of: start_run, create, seal_run, list, get, complete, progress, " + + "'. Use one of: start_run, create, seal_run, list, get, output, complete, progress, " + "comment, attach, cancel, retry."; }; } catch (IllegalArgumentException | IllegalStateException e) { @@ -368,6 +373,47 @@ public class TeamTasksTool { return sb.toString(); } + private String renderOutput(AgentTeamEntity team, Long workspaceId, Long taskId) { + TeamTaskEntity task = requireTaskInTeam(team, taskId); + if (!Objects.equals(workspaceId, team.getWorkspaceId())) { + throw new IllegalArgumentException("team output is outside the calling workspace"); + } + String header = "Worker output: taskId=" + taskId + ", status=" + task.getStatus() + "\n"; + if (task.getConversationId() == null || task.getConversationId().isBlank()) { + return unavailableOutput(header, task); + } + if (task.getRunId() == null) { + throw new IllegalArgumentException("worker output has no canonical team run"); + } + TeamRunEntity run = runService.requireRun(task.getRunId(), workspaceId); + ConversationEntity worker = conversationService.findByConversationId(task.getConversationId()); + if (run == null || !Objects.equals(run.getTeamId(), team.getId()) + || !Objects.equals(run.getLeadConversationId(), task.getLeadConversationId()) + || !ConversationService.isTeamWorkerConversation(worker) + || !Objects.equals(worker.getWorkspaceId(), workspaceId) + || !Objects.equals(worker.getAgentId(), task.getAssigneeAgentId()) + || !TeamWorkerConversationGovernanceService.matchesParent(worker, run.getLeadConversationId())) { + throw new IllegalArgumentException("worker output linkage does not match this team's run"); + } + // Read only the last saved turn, never an earlier attempt or a reply + // preceding a new feedback/approval message that has not finished yet. + List recent = conversationService.listRecentMessages(task.getConversationId(), 1); + if (recent.isEmpty()) { + return unavailableOutput(header, task); + } + MessageEntity message = recent.getLast(); + if (!"assistant".equals(message.getRole()) || message.getContent() == null + || message.getContent().isBlank()) { + return unavailableOutput(header, task); + } + return header + "Latest saved worker reply (verbatim):\n" + message.getContent(); + } + + private static String unavailableOutput(String header, TeamTaskEntity task) { + return header + "Full worker output unavailable; stored task summary only:\n" + + (task.getResult() == null ? "(no result)" : task.getResult()); + } + private String renderDetail(AgentTeamEntity team, Long taskId) { TeamTaskEntity task = requireTaskInTeam(team, taskId); StringBuilder sb = new StringBuilder(512); @@ -379,6 +425,10 @@ public class TeamTasksTool { if (task.getDescription() != null && !task.getDescription().isBlank()) { sb.append("Description: ").append(task.getDescription()).append('\n'); } + List dependencies = TeamTaskService.parseIdArray(task.getBlockedBy()); + if (!dependencies.isEmpty()) { + sb.append("Dependencies (task ids): ").append(dependencies).append('\n'); + } if (task.getProgressStep() != null) { sb.append("Progress: ").append(task.getProgressPercent() == null ? "?" : task.getProgressPercent()).append("% — ").append(task.getProgressStep()).append('\n'); @@ -389,6 +439,13 @@ public class TeamTasksTool { if (task.getReason() != null && !task.getReason().isBlank()) { sb.append("Reason: ").append(task.getReason()).append('\n'); } + List deliverables = taskService.listDeliverables(task); + if (!deliverables.isEmpty()) { + sb.append("Deliverables:\n"); + for (TeamTaskService.Deliverable deliverable : deliverables) { + sb.append("- ").append(deliverable.name()).append(" → ").append(deliverable.url()).append('\n'); + } + } List comments = taskService.listComments(taskId); if (!comments.isEmpty()) { sb.append("Comments:\n"); diff --git a/mateclaw-server/src/main/resources/docs/en/teams.md b/mateclaw-server/src/main/resources/docs/en/teams.md index be295f24..6cdbe908 100644 --- a/mateclaw-server/src/main/resources/docs/en/teams.md +++ b/mateclaw-server/src/main/resources/docs/en/teams.md @@ -59,11 +59,12 @@ The run protocol is `start_run → create* → seal_run`: the lead creates a run | **Task** | One work item on the board: subject, description, assignee, dependencies (`blockedBy`), progress, result, deliverables, comments, timeline. | | **Board** | A kanban grouped by status. The state machine is guarded by conditional database updates — under concurrency the first successful writer wins, so a task can never hold two states. | -Eight task statuses: +Nine task statuses: ``` pending → in_progress → completed / failed / cancelled ↘ in_review (tasks that require human approval) + ↘ awaiting_approval (a worker tool call needs approval) blocked (waiting on prerequisites) stale (lease expired, retryable) ``` @@ -74,7 +75,7 @@ blocked (waiting on prerequisites) stale (lease expired, retryable) ## What one collaboration run looks like 1. **You give the lead a goal**: "Competitive analysis: research vendors A and B separately, then merge into one report." -2. **The lead puts tasks on the board**: three tasks via the `team_tasks` tool — "research A" and "research B" run in parallel, "merge report" declares `blockedBy` on both and enters `blocked`. +2. **The lead puts tasks on the board**: call `start_run`, create all three tasks with its `runId`, then call `seal_run` to start dispatch. "Research A" and "research B" run in parallel; "merge report" declares `blockedBy` on both and enters `blocked`. 3. **The dispatch engine takes over**: a resident sweep (every 30 s, plus an immediate pass after tool/REST actions) assigns pending tasks to their members — each task gets its own child conversation where the member runs the full agent graph. Each member executes one task at a time; the rest queue. 4. **Prerequisite results are handed off automatically**: once "research A/B" complete, "merge report" is released and its dispatch envelope **automatically carries the results and deliverable links of both prerequisites** — member C doesn't need the lead to retell what member A did. 5. **Settled results wake the lead**: completions and failures are announced to the lead in debounced batches; the lead wakes up in a real new turn — reviews results, dispatches follow-ups, or declares the job done. @@ -89,8 +90,11 @@ The lead (and members) operate the board through the `team_tasks` tool, with rol | Action | Who | What | |------|--------|--------| | `list` | any member | Render the current board (the lead also gets a live board snapshot injected every turn — see below) | -| `get` | any member | Task detail | -| `create` | lead | Create a task: subject, description, assignee, `blockedBy` dependencies, `requireApproval` for a human gate | +| `get` | any member | Task detail, prerequisite task IDs, comments and registered deliverables | +| `output` | any member | Read the latest complete saved worker reply by `taskId`; verifies the task/run/workspace/worker-conversation linkage | +| `start_run` | lead | Start a delegation batch with an objective; returns its `runId` | +| `create` | lead | Stage a task in that `runId`: subject, description, assignee, `blockedBy` dependencies, `requireApproval` for a human gate | +| `seal_run` | lead | Seal the completed task batch and start dispatch | | `complete` | assignee | Submit the result (approval-gated tasks move to `in_review`) | | `progress` | assignee | Report percent and current step (also renews the execution lease and broadcasts to the board) | | `comment` | any member | Leave a comment | @@ -108,6 +112,7 @@ Teams target deep research and long document work — tasks that run for tens of - **Execution lease + runtime heartbeat.** A dispatched task holds a 60-minute lease that is renewed automatically while the member runs; only genuinely lost tasks (process crash, restart) expire to `stale` and can be safely re-dispatched — no more "task re-dispatched while still running, two instances overwriting each other". - **Cancel means interrupt.** Member child conversations register with the stream tracker; graph nodes check the stop flag each round, so a cancelled member stops at the next node boundary — no more burning tokens to natural completion after a cancel. +- **Settlement follows actual task state.** A worker returning normally does not turn a cancelled, stale, blocked or approval-waiting task into a completed task. The worker stream closes before completion/failure notifications can wake the lead; tasks requiring approval still report `in_review`. - **Human approval gates.** Declare `requireApproval` at creation; the submitted task parks at `in_review` until you approve / reject on the Teams page — sensitive output passes a human before it leaves the team. - **Manual task creation.** Tasks don't have to come from the lead: create one directly on the Teams page, assigned to any member; the result lands on the board for you (with no lead conversation to wake, announcement gracefully no-ops). @@ -121,6 +126,8 @@ The right output shape for a complex task is **files + summary**, not one trunca - Every task detail has a **"view run"** entry: the member child conversation's full transcript — dispatch envelope, round-by-round thinking, tool calls, intermediate output. Opened mid-run it's a live typewriter (reconnects replay the buffer). - **Task timeline**: who created / dispatched / reported / attached / approved / cancelled, and when — each event lands in the `mate_team_task_event` audit table and renders as a timeline on the task detail. The collaboration has a historian. +Long worker replies are saved in full in the worker conversation. Automatic settlement keeps a head/tail task-result excerpt within 8,000 characters, preserving the opening findings and final outcome. An explicit `complete` submission must also fit 8,000 characters; a longer summary is rejected before claiming or changing the task, with guidance to put full text in the final assistant reply or a deliverable. Prerequisite context has a 6,000-character total budget; a merged lead announcement has a 16,000-character total budget. Omitted content is marked and points to `team_tasks(action="output", taskId="...")`; use `get` for prerequisite IDs and all registered deliverable links. These are excerpts, so retrieve missing evidence before relying on it. When no complete saved worker reply is available, `output` explicitly identifies its fallback as the stored task summary. Large retrieved outputs use the existing tool-result spill mechanism when enabled. + --- ## Plan-Execute leads: the plan becomes the board diff --git a/mateclaw-server/src/main/resources/docs/zh/teams.md b/mateclaw-server/src/main/resources/docs/zh/teams.md index 92621b7b..97461761 100644 --- a/mateclaw-server/src/main/resources/docs/zh/teams.md +++ b/mateclaw-server/src/main/resources/docs/zh/teams.md @@ -59,11 +59,12 @@ planning / running / awaiting_review → cancelled | **任务(Task)** | 板上的一条工作项:标题、描述、指派人、依赖(`blockedBy`)、进度、结果、交付物、评论、时间线。 | | **任务板(Board)** | 按状态分列的看板。状态机由数据库条件更新守卫——并发场景下谁先改成功谁算数,不会出现双重状态。 | -任务状态一共八种: +任务状态一共九种: ``` pending → in_progress → completed / failed / cancelled ↘ in_review(要求人工审批的任务) + ↘ awaiting_approval(worker 工具调用需要审批) blocked(等前置任务) stale(租约过期,可重试) ``` @@ -74,7 +75,7 @@ blocked(等前置任务) stale(租约过期,可重试) ## 一次典型协作长什么样 1. **你对 Lead 说目标**:“做一份竞品分析:先各自调研 A、B 两家,再汇总成一份报告。” -2. **Lead 拆任务上板**:调用 `team_tasks` 工具建三条任务——“调研 A”“调研 B”并行,“汇总报告”声明 `blockedBy` 前两条,自动进入 `blocked`。 +2. **Lead 拆任务上板**:先调用 `start_run`,用返回的 `runId` 建好全部三条任务,再调用 `seal_run` 开始派发。“调研 A”“调研 B”并行,“汇总报告”声明 `blockedBy` 前两条,自动进入 `blocked`。 3. **派发引擎接手**:常驻扫描(30 秒一轮,工具/REST 动作后立即加扫)把 pending 任务派给指派成员——每个任务开一个独立子会话,成员在里面跑完整的 Agent 图。每个成员同时只吃一个任务,其余排队。 4. **前置结果自动传递**:“调研 A/B”完成后,“汇总报告”被放行,派发信封里**自动带上两条前置任务的结果与交付物链接**——成员 C 不需要 Lead 人肉转述成员 A 干了什么。 5. **结果通报唤醒 Lead**:任务落定(完成/失败)后合批通报给 Lead,Lead 被唤醒发起真实新一轮——检查结果、补派任务或宣布收工。 @@ -89,8 +90,11 @@ Lead(和成员)通过 `team_tasks` 工具操作任务板,动作按角色 | 动作 | 谁能用 | 干什么 | |------|--------|--------| | `list` | 所有成员 | 渲染当前任务板(Lead 每轮还会自动收到实时看板快照,见下) | -| `get` | 所有成员 | 查看单个任务详情 | -| `create` | Lead | 建任务:标题、描述、指派成员、`blockedBy` 依赖、`requireApproval` 是否需人工审批 | +| `get` | 所有成员 | 查看任务详情、前置任务 ID、评论和已登记的交付物 | +| `output` | 所有成员 | 按 `taskId` 读取最近一条完整保存的 worker 回复,校验任务、run、工作区与 worker 会话关联 | +| `start_run` | Lead | 按目标开启一批委派任务,返回 `runId` | +| `create` | Lead | 在该 `runId` 内准备任务:标题、描述、指派成员、`blockedBy` 依赖、`requireApproval` 是否需人工审批 | +| `seal_run` | Lead | 封存已准备好的整批任务,开始派发 | | `complete` | 执行成员 | 提交结果完成任务(要求审批的任务转入 `in_review` 等人批) | | `progress` | 执行成员 | 上报进度百分比与当前步骤(同时给执行租约续期并广播到看板) | | `comment` | 所有成员 | 在任务下留言 | @@ -108,6 +112,7 @@ Lead(和成员)通过 `team_tasks` 工具操作任务板,动作按角色 - **执行租约 + 运行期心跳**。任务派发即持有 60 分钟执行租约,成员运行期间后台自动续期;真正失联的任务(进程崩溃、重启)租约到期被判 `stale`,可安全重派——**不会**出现"任务还在跑就被重派、两个实例互相覆盖"的双重执行。 - **取消即中断**。`cancel` 不只是状态转移:成员子会话注册进流跟踪器,图节点每轮检查停止位,取消后运行中的成员会话在下一个节点边界停下——不再有"取消了还在烧 token 到自然结束"。 +- **结算通知服从真实任务状态**。worker 正常返回不会把已取消、过期、阻塞或等待工具审批的任务误报为完成。先关闭 worker 流,再让完成或失败通知唤醒 Lead;需要人工审批的产出仍报告 `in_review`。 - **人工审批卡点**。建任务时声明 `requireApproval`,成员提交后任务停在 `in_review`,由你在 Teams 页 approve / reject——敏感产出离开团队前先过人。 - **手动投任务**。任务不非得 Lead 建:Teams 页可以直接建任务指派给某个成员,结果落板上由你查看(没有 Lead 会话要唤醒时,通报自动降级为 no-op)。 @@ -121,6 +126,8 @@ Lead(和成员)通过 `team_tasks` 工具操作任务板,动作按角色 - 每个任务详情都有**"查看执行过程"**入口:跳进成员子会话的完整转写——派发信封、逐轮思考、工具调用、中间产物一览无余。任务运行中打开,就是实时打字机直播(断线重连自动回放缓冲)。 - **任务时间线**:谁在什么时候创建/派发/上报进度/挂附件/审批/取消,逐条落 `mate_team_task_event` 审计表,任务详情按时间线渲染——协同过程有史可查。 +较长的 worker 回复先完整保存到 worker 会话,自动结算再生成不超过 8,000 字符的任务首尾摘要,保留开头发现与结尾结论。主动调用 `complete` 提交的摘要也需在 8,000 字符内;超限会在认领或修改任务前明确拒绝,并提示将完整内容放入最终 assistant 回复或附件交付物。前置任务上下文总预算为 6,000 字符,Lead 批量汇总通知总预算为 16,000 字符。省略内容会明确标记,并指向 `team_tasks(action="output", taskId="...")`;前置任务 ID 与全部交付物链接可用 `get` 查看。摘要不代表省略部分已经验证,需要时应检索原文。没有完整保存的 worker 回复时,`output` 会明确说明只能返回任务板摘要。较大的检索结果在启用工具结果落盘机制时复用现有完整落盘与读取路径。 + --- ## Plan-Execute Lead:计划直接变任务板 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java index 6ca2087f..3867fb58 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java @@ -305,6 +305,50 @@ class ReasoningNodeOutputTest { assertTrue(filtered.isEmpty()); } + @Test + void structuredWorkerSourceLengthDoesNotTriggerLongFormContinuation() throws Exception { + String answer = "{\"status\":\"COMPLETED\",\"result\":\"HEAD/TAIL/130 verified\",\"evidence\":\"team_tasks output and read_file\"}"; + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn( + new NodeStreamingChatHelper.StreamResult(answer, "", new AssistantMessage(answer), + List.of(), false, 100, 100)); + Map state = baseStateMap(); + String request = "[Team task]\n源任务完整长回复(约 10731 字符)已持久化。请检索并核验,最终 JSON 小于800字符。\n" + + "[Final result contract]\n" + + vip.mate.agent.runtime.WorkerResultContract.INSTRUCTIONS + + "Keep the entire JSON under 8000 characters.\n"; + state.put(USER_MESSAGE, request); + state.put(MAX_ITERATIONS, 30); + Map output = createNode().apply(new RunState(state)); + assertEquals(false, output.get(CONTINUE_REASONING)); + assertEquals(answer, output.get(FINAL_ANSWER)); + assertTrue(ReasoningNode.requestedLongFormChars(request).isEmpty()); + } + + @Test + void sourceSizeAndUpperBoundsDoNotRequestLongFormProse() { + for (String request : List.of("源任务完整长回复(约 10731 字符)已保存。请核验并简短回复。", + "完整回复10975字符,与前置约10731字符一致(含封装头尾)。", + "完整输出约10000字,请核验首尾。", "读取一万字报告,仅回复结论。", + "Read the 10,000 words source report; answer briefly.", + "请输出不超过 10000 字的报告", "Write at most 10,000 words.", + "Write no more than 10000 words", "请写一篇不要超过10000字的报告")) { + assertTrue(ReasoningNode.requestedLongFormChars(request).isEmpty(), request); + } + assertEquals(3000, ReasoningNode.requestedLongFormChars( + "读取 10731 字符原文,写一篇 3000 字摘要。").orElseThrow()); + assertEquals(10_000, ReasoningNode.requestedLongFormChars("帮我写一万字小说。").orElseThrow()); + assertEquals(10_000, ReasoningNode.requestedLongFormChars("Write 10,000 words of fiction.").orElseThrow()); + } + + @Test + void explicitChineseWritingIntentsStillEnforceTheirRequestedLength() { + for (String request : List.of("请你写一篇10000字小说", "请把这个故事扩写到10000字", + "请根据工具结果写一篇10000字技术报告", "请写一篇小说,要求10000字", + "Write a report; length: 10000 words")) { + assertEquals(10_000, ReasoningNode.requestedLongFormChars(request).orElseThrow(), request); + } + } + private static ToolCallback mockTool(String name) { ToolCallback callback = mock(ToolCallback.class); org.springframework.ai.tool.definition.ToolDefinition definition = diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java index f891b3d8..a70a518e 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java @@ -26,6 +26,40 @@ class ActionExecutionLedgerTest { assertTrue(merged.merge(failure).latestCompleteResult().isEmpty()); assertThrows(UnsupportedOperationException.class,()->merged.latestCompleteResult().put("forged","body")); } + @Test void teamProgressPreservesFullReadEvidenceAndSurvivesReceiptSerialization() throws Exception { + String body = "HEAD " + "evidence ".repeat(1000) + " TAIL"; + var read = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("read", "read_file", body, true))); + var progress = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), + GraphEventPublisher.toolComplete("progress", "team_tasks", "Progress recorded", true))); + var merged = read.merge(progress); + assertEquals(java.util.Map.of("read", body), merged.latestCompleteResult()); + assertFalse(merged.receipts().get("progress").substantive()); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var restored = mapper.readValue(mapper.writeValueAsString(merged.receipts().get("progress")), + ActionExecutionLedger.Receipt.class); + assertFalse(restored.substantive()); + var failed = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolStart("failed-progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\"}"), + GraphEventPublisher.toolComplete("failed-progress", "team_tasks", "Error", false))); + assertEquals(1, merged.merge(failed).failedCallCount()); + } + + @Test void ambiguousOrMutatingTeamCallsRemainSubstantive() { + for (String arguments : List.of("{\"action\":\"complete\",\"taskId\":\"5\"}", + "{\"action\":\"output\",\"taskId\":\"5\"}", + "{\"action\":\"progress\",\"action\":\"complete\"}", "not-json")) { + var ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolStart("call", "team_tasks", arguments), + GraphEventPublisher.toolComplete("call", "team_tasks", "receipt", true))); + assertTrue(ledger.receipts().get("call").substantive()); + } + var missingStart = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("legacy", "team_tasks", "Progress recorded", true))); + assertTrue(missingStart.receipts().get("legacy").substantive()); + } + @Test void legacyReceiptLengthIsUnknownAndCannotGroundLatestOutput() throws Exception { var receipt=new ActionExecutionLedger.Receipt("legacy","calc",ActionExecutionLedger.Status.SUCCEEDED,"x".repeat(512)+"...",1); assertEquals(-1,receipt.resultChars()); diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java index 9d9e3438..ba7fc8e2 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java @@ -40,6 +40,54 @@ import static org.mockito.Mockito.*; */ class TeamAnnounceServiceTest { + @Test + void twentyLargeResultsHaveOneAggregateBudgetAndKeepEveryTaskOutcome() { + List items = java.util.stream.LongStream.rangeClosed(1, 20) + .mapToObj(id -> new TeamAnnounceService.AnnounceItem(id, 10L, (int) id, + "task " + id, TeamTaskStatus.COMPLETED, "member " + id, + "opening " + id + "\n" + "body\n".repeat(2000) + "\nverified " + id)) + .toList(); + + String result = TeamAnnounceService.buildAnnouncement(items); + + assertTrue(result.length() <= 16000, "Announcement exceeds aggregate budget: " + result.length()); + for (long id = 1; id <= 20; id++) { + assertTrue(result.contains("Task #" + id + " ")); + assertTrue(result.contains("taskId=\"" + id + "\"")); + assertTrue(result.contains("verified " + id)); + } + } + + @Test + void longTaskTitlesAndMemberNamesCannotBypassAnnouncementBudget() { + var item = new TeamAnnounceService.AnnounceItem(42L, 10L, 42, + "title".repeat(10000), TeamTaskStatus.IN_REVIEW, "member".repeat(10000), + "approval still required"); + + String result = TeamAnnounceService.buildAnnouncement(List.of(item)); + + assertTrue(result.length() <= 16000); + assertTrue(result.contains("in_review")); + assertTrue(result.contains("approval still required")); + assertTrue(result.contains("taskId=\"42\"")); + } + + @Test + void retryMergedBatchBeyondTwentyExplicitlyIdentifiesOmittedDetails() { + List items = java.util.stream.LongStream.rangeClosed(1, 25) + .mapToObj(id -> new TeamAnnounceService.AnnounceItem(id, 10L, (int) id, + "task " + id, TeamTaskStatus.COMPLETED, "member", + "Opening\n" + "evidence\n".repeat(2000) + "Final verified")) + .toList(); + + String result = TeamAnnounceService.buildAnnouncement(items); + + assertTrue(result.length() <= 16000); + assertTrue(result.contains("25 delegated team tasks have settled")); + assertTrue(result.contains("5 additional task details omitted")); + assertTrue(result.contains("team_tasks(action=\"list\")")); + } + private static final Long TEAM_ID = 10L; private static final Long LEAD_ID = 1L; private static final Long RUN_ID = 90L; diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index d411e4f8..16fa93b1 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -5,6 +5,8 @@ import vip.mate.decision.api.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import vip.mate.agent.AgentService; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.PendingApproval; @@ -207,6 +209,58 @@ class TeamDispatchServiceTest { // ==================== outcome settling ==================== + @ParameterizedTest + @ValueSource(strings = {"cancelled", "stale", "pending", "blocked", "awaiting_approval"}) + void normalWorkerReturnDoesNotAnnounceUnsettledOrCancelledTask(String status) { + TeamTaskEntity current = task(1L, MEMBER_A); + current.setStatus(status); + when(taskService.getTask(1L)).thenReturn(current); + + service.settleOutcome(current, "late worker reply"); + + verifyNoInteractions(eventChannel, announceService); + verify(taskService, never()).completeTask(any(), any(), anyString()); + } + + @Test + void childStreamClosesBeforeLeadReceivesSuccessfulResult() { + TeamTaskEntity assigned = task(1L, MEMBER_A); + assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + TeamTaskEntity done = task(1L, MEMBER_A); + done.setStatus(TeamTaskStatus.COMPLETED); + when(taskService.getTask(1L)).thenReturn(assigned, done, done); + when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) + .thenReturn(AgentService.ChatResult.contentOnly("all done")); + + service.runTask(TEAM_ID, assigned); + + var order = inOrder(conversationService, streamTracker, eventChannel, announceService); + order.verify(conversationService).saveMessage(anyString(), eq("assistant"), eq("all done")); + order.verify(streamTracker).complete(startsWith("team-task-")); + order.verify(eventChannel).publishTaskEvent(any(), eq("team_task_completed"), any()); + order.verify(announceService).announceTaskSettled(done); + } + + @Test + void childStreamClosesBeforeLeadReceivesFailedResult() { + TeamTaskEntity assigned = task(1L, MEMBER_A); + assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + TeamTaskEntity failed = task(1L, MEMBER_A); + failed.setStatus(TeamTaskStatus.FAILED); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) + .thenThrow(new IllegalStateException("model unavailable")); + when(taskService.failTask(eq(1L), anyString())).thenReturn(true); + when(taskService.getTask(1L)).thenReturn(failed); + + service.runTask(TEAM_ID, assigned); + + var order = inOrder(streamTracker, eventChannel, announceService); + order.verify(streamTracker).complete(startsWith("team-task-")); + order.verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any()); + order.verify(announceService).announceTaskSettled(failed); + } + @Test @DisplayName("an in_progress task is auto-completed with the member's final reply") void settleAutoCompletes() { @@ -603,17 +657,94 @@ class TeamDispatchServiceTest { } @Test - @DisplayName("an oversized member reply is truncated before persisting") - void settleTruncatesLongReply() { + @DisplayName("a bounded task summary preserves the final outcome and a complete-output reference") + void settleSummarizesLongReplyWithoutLosingFinalOutcome() { TeamTaskEntity running = task(1L, MEMBER_A); running.setStatus(TeamTaskStatus.IN_PROGRESS); when(taskService.getTask(1L)).thenReturn(running, running); when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); - service.settleOutcome(running, "x".repeat(TeamDispatchService.MAX_RESULT_CHARS + 500)); + service.settleOutcome(running, "Opening findings\n" + + "x".repeat(TeamDispatchService.MAX_RESULT_CHARS + 500) + "\nFinal verification passed"); verify(taskService).completeTask(eq(1L), isNull(), argThat(r -> - r.length() <= TeamDispatchService.MAX_RESULT_CHARS + 20 - && r.endsWith("...(truncated)"))); + r.length() <= TeamDispatchService.MAX_RESULT_CHARS + && r.startsWith("Opening findings") + && r.endsWith("Final verification passed") + && r.contains("team_tasks(action=\"output\", taskId=\"1\")"))); + } + + @Test + void completeWorkerReplyIsSavedBeforeTaskSummaryIsDerived() { + TeamTaskEntity assigned = task(1L, MEMBER_A); + assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + TeamTaskEntity done = task(1L, MEMBER_A); + done.setStatus(TeamTaskStatus.COMPLETED); + when(taskService.getTask(1L)).thenReturn(assigned, done, done); + when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); + String full = "Opening\n" + "x".repeat(10000) + "\nFinal verification"; + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) + .thenReturn(AgentService.ChatResult.contentOnly(full)); + + service.runTask(TEAM_ID, assigned); + + var order = inOrder(conversationService, taskService); + order.verify(conversationService).saveMessage(anyString(), eq("assistant"), eq(full)); + order.verify(taskService).completeTask(eq(1L), isNull(), argThat(result -> + result.length() <= TeamDispatchService.MAX_RESULT_CHARS + && result.endsWith("Final verification") + && result.contains("action=\"output\""))); + } + + @Test + void prerequisiteContextKeepsFinalVerificationAndEveryIncludedTaskReference() { + TeamTaskEntity dependent = task(20L, MEMBER_B); + dependent.setBlockedBy("[1,2,3,4,5,6]"); + for (long id = 1; id <= 6; id++) { + TeamTaskEntity prerequisite = task(id, MEMBER_A); + prerequisite.setStatus(TeamTaskStatus.COMPLETED); + prerequisite.setResult("opening " + id + "\n" + "analysis\n".repeat(2000) + + "\nverification " + id + " passed"); + when(taskService.getTask(id)).thenReturn(prerequisite); + } + + StringBuilder context = new StringBuilder(); + service.appendPrerequisiteResults(context, dependent); + String result = context.toString(); + + assertTrue(result.length() <= TeamDispatchService.MAX_PREREQ_SECTION_CHARS); + for (long id = 1; id <= 6; id++) { + assertTrue(result.contains("taskId=\"" + id + "\""), "Missing task " + id); + assertTrue(result.contains("verification " + id + " passed"), "Lost tail for " + id); + } + } + + @Test + void manyPrerequisitesAndDeliverablesHaveExplicitRetrievalPathsInsideTotalBudget() { + TeamTaskEntity dependent = task(50L, MEMBER_B); + dependent.setBlockedBy("[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]"); + for (long id = 1; id <= 20; id++) { + TeamTaskEntity prerequisite = task(id, MEMBER_A); + prerequisite.setStatus(TeamTaskStatus.COMPLETED); + prerequisite.setSubject("Long subject ".repeat(50)); + prerequisite.setResult("Opening\n" + "evidence\n".repeat(2000) + "Final verified"); + when(taskService.getTask(id)).thenReturn(prerequisite); + when(taskService.listDeliverables(prerequisite)).thenReturn(java.util.stream.IntStream.range(0, 10) + .mapToObj(index -> new TeamTaskService.Deliverable("report-" + index + ".docx", + "/api/v1/files/generated/complete-generated-id-" + index, null)).toList()); + } + + StringBuilder context = new StringBuilder(); + service.appendPrerequisiteResults(context, dependent); + String result = context.toString(); + + assertTrue(result.length() <= TeamDispatchService.MAX_PREREQ_SECTION_CHARS, result); + assertTrue(result.contains("12 additional prerequisite(s) omitted")); + assertTrue(result.contains("taskId=\"50\"")); + assertTrue(result.contains("deliverable(s) omitted")); + for (long id = 1; id <= 8; id++) { + assertTrue(result.contains("taskId=\"" + id + "\"")); + assertTrue(result.contains("Final verified")); + } } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamResultExcerptTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamResultExcerptTest.java new file mode 100644 index 00000000..12bbdfc9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamResultExcerptTest.java @@ -0,0 +1,49 @@ +package vip.mate.team.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class TeamResultExcerptTest { + + @Test + void shortResultRemainsVerbatim() { + String json = "{\"path\":\"report.docx\",\"verified\":true}"; + assertEquals(json, TeamResultExcerpt.render(json, 300, 42L)); + assertEquals("", TeamResultExcerpt.render(null, 300, 42L)); + } + + @Test + void longResultPreservesOpeningAndFinalVerificationWithinStrictBudget() { + String full = "Produced report.docx\n" + "body\n".repeat(2000) + + "\nVerification: 18 tests passed; remaining issue: missing owner approval."; + + String excerpt = TeamResultExcerpt.render(full, 1500, 42L); + + assertTrue(excerpt.length() <= 1500); + assertTrue(excerpt.startsWith("Produced report.docx")); + assertTrue(excerpt.endsWith("remaining issue: missing owner approval.")); + assertTrue(excerpt.contains("team_tasks(action=\"output\", taskId=\"42\")")); + assertTrue(excerpt.contains("Do not infer omitted content")); + } + + @ParameterizedTest + @ValueSource(ints = {256, 257, 300, 999, 1500, 8000}) + void excerptsNeverSplitUnicodeSurrogatePairs(int budget) { + String full = "🙂".repeat(10000); + String excerpt = TeamResultExcerpt.render(full, budget, Long.MAX_VALUE); + + assertTrue(excerpt.length() <= budget); + for (int index = 0; index < excerpt.length(); index++) { + char ch = excerpt.charAt(index); + if (Character.isHighSurrogate(ch)) { + assertTrue(index + 1 < excerpt.length()); + assertTrue(Character.isLowSurrogate(excerpt.charAt(++index))); + } else { + assertFalse(Character.isLowSurrogate(ch)); + } + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java index 9d2af540..2406d616 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -9,6 +9,8 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import vip.mate.team.model.AgentTeamEntity; @@ -53,6 +55,19 @@ class TeamTaskServiceTest { private TeamRunService runService; private TeamTaskService service; + @ParameterizedTest + @ValueSource(strings = {"pending", "in_progress"}) + void explicitCompletionRejectsOversizedSummaryBeforeAnyTaskMutation(String status) { + when(taskMapper.selectById(5L)).thenReturn(task(5L, status)); + when(taskMapper.update(isNull(), any())).thenReturn(1); + var error = assertThrows(IllegalArgumentException.class, + () -> service.completeTask(5L, MEMBER_ID, "x".repeat(8001))); + + assertTrue(error.getMessage().contains("8000")); + assertTrue(error.getMessage().contains("final reply")); + verifyNoInteractions(taskMapper, commentMapper, eventMapper, projectionScheduler); + } + @Test void staleActiveCompletionCannotMutateOrProduceSuccessfulOutcome() { var adapter = mock(WorkerDecisionAdapter.class); diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerOutputPersistenceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerOutputPersistenceTest.java new file mode 100644 index 00000000..bac3250c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerOutputPersistenceTest.java @@ -0,0 +1,354 @@ +package vip.mate.team.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; +import com.baomidou.mybatisplus.spring.MybatisSqlSessionFactoryBean; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import vip.mate.agent.AgentService; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.agent.graph.executor.ToolResultProperties; +import vip.mate.agent.graph.executor.ToolResultStorage; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.auth.service.AuthService; +import vip.mate.channel.repository.ChannelSessionMapper; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.config.MybatisPlusConfig; +import vip.mate.i18n.I18nService; +import vip.mate.task.repository.AsyncTaskMapper; +import vip.mate.team.model.AgentTeamEntity; +import vip.mate.team.model.TeamTaskStatus; +import vip.mate.team.repository.TeamRunMapper; +import vip.mate.team.repository.TeamTaskCommentMapper; +import vip.mate.team.repository.TeamTaskEventMapper; +import vip.mate.team.repository.TeamTaskMapper; +import vip.mate.team.tool.TeamTasksTool; +import vip.mate.tool.builtin.ToolExecutionContext; +import vip.mate.tool.builtin.ReadFileTool; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.util.Optional; +import java.util.UUID; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real MyBatis/H2 persistence and service gates; only the model and external scheduling are stubbed. */ +class TeamWorkerOutputPersistenceTest { + private JdbcTemplate jdbc; + private ConversationService conversations; + private TeamTaskService tasks; + private TeamTasksTool tool; + private TeamDispatchService dispatch; + private AgentService agents; + private TeamEventChannel events; + private TeamAnnounceService announcements; + + @BeforeEach + void setup() throws Exception { + var source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:team_output_" + UUID.randomUUID() + + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + String[] migrations = {"V1__baseline_schema.sql", "V5__conversation_parent.sql", + "V114__conversation_pinned.sql", "V116__conversation_model.sql", + "V123__conversation_progress_ledger.sql", "V151__webchat_session_id.sql", + "V152__webchat_archive_and_revocation.sql", "V166__message_usage_detail.sql", + "V172__agent_team_foundation.sql", "V174__team_task_event_timeline.sql", + "V181__team_run_foundation.sql", "V183__conversation_kind.sql"}; + var populator = new ResourceDatabasePopulator(); + for (String migration : migrations) { + populator.addScript(new ClassPathResource("db/migration/h2/" + migration)); + } + populator.execute(source); + jdbc = new JdbcTemplate(source); + var configuration = new MybatisConfiguration(); + var global = GlobalConfigUtils.defaults().setMetaObjectHandler(new MybatisPlusConfig()); + GlobalConfigUtils.setGlobalConfig(configuration, global); + configuration.addMapper(TeamTaskMapper.class); + configuration.addMapper(TeamTaskCommentMapper.class); + configuration.addMapper(TeamTaskEventMapper.class); + configuration.addMapper(TeamRunMapper.class); + configuration.addMapper(ConversationMapper.class); + configuration.addMapper(MessageMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(source); + factory.setConfiguration(configuration); + factory.setGlobalConfig(global); + var session = new SqlSessionTemplate(factory.getObject()); + var teamService = mock(TeamService.class); + var team = new AgentTeamEntity(); + team.setId(10L); + team.setWorkspaceId(30L); + team.setLeadAgentId(1L); + when(teamService.getTeam(10L)).thenReturn(team); + when(teamService.getTeamForAgent(1L)).thenReturn(Optional.of(team)); + when(teamService.getTeamForAgent(2L)).thenReturn(Optional.of(team)); + when(teamService.isLead(team, 1L)).thenReturn(true); + var runService = new TeamRunService(session.getMapper(TeamRunMapper.class), + session.getMapper(TeamTaskMapper.class), teamService); + var taskTarget = new TeamTaskService(session.getMapper(TeamTaskMapper.class), + session.getMapper(TeamTaskCommentMapper.class), session.getMapper(TeamTaskEventMapper.class), + teamService, mock(TeamRunProjectionScheduler.class), runService); + var proxy = new ProxyFactory(taskTarget); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), + new AnnotationTransactionAttributeSource())); + tasks = (TeamTaskService) proxy.getProxy(); + var agentMapper = mock(AgentMapper.class); + conversations = new ConversationService(session.getMapper(ConversationMapper.class), + session.getMapper(MessageMapper.class), agentMapper, new ObjectMapper(), + mock(ToolApprovalMapper.class), mock(AsyncTaskMapper.class), mock(ChannelSessionMapper.class), + mock(ApplicationEventPublisher.class), mock(AuthService.class), mock(WorkspaceService.class), + mock(ChatUploadLocationResolver.class)); + conversations.getOrCreateConversation("lead-conv", 1L, "system", 30L); + jdbc.update(""" + INSERT INTO mate_team_run(id,team_id,workspace_id,lead_agent_id,lead_conversation_id, + title,objective,status) VALUES(20,10,30,1,'lead-conv','Run','Analyze results','running') + """); + jdbc.update(""" + INSERT INTO mate_team_task(id,team_id,run_id,task_number,subject,status,assignee_agent_id, + owner_agent_id,dispatch_count,lead_conversation_id) + VALUES(5,10,20,5,'Analyze results','in_progress',2,2,1,'lead-conv') + """); + agents = mock(AgentService.class); + events = mock(TeamEventChannel.class); + announcements = mock(TeamAnnounceService.class); + dispatch = spy(new TeamDispatchService(teamService, tasks, agents, conversations, + mock(ChatStreamTracker.class), announcements, events, mock(ApprovalWorkflowService.class))); + doNothing().when(dispatch).requestDispatch(anyLong()); + tool = new TeamTasksTool(teamService, tasks, runService, dispatch, events, conversations, agentMapper); + ToolExecutionContext.set("lead-conv", "system"); + } + + @AfterEach + void cleanup() { + ToolExecutionContext.clear(); + } + + private String output() { + return tool.team_tasks("output", "5", null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); + } + + private void runWorker(String reply) { + when(agents.chatWithUsage(eq(2L), anyString(), anyString())) + .thenReturn(AgentService.ChatResult.contentOnly(reply)); + dispatch.runTask(10L, tasks.getTask(5L)); + } + + @Test + void completeReplySurvivesPersistenceWhileBoardSummaryIsStrictlyBounded() { + String full = "Opening findings\n" + "Evidence line\n".repeat(2000) + "Final verification: passed"; + runWorker(full); + + var task = tasks.getTask(5L); + assertEquals(TeamTaskStatus.COMPLETED, task.getStatus()); + assertTrue(task.getResult().length() <= 8000); + assertTrue(task.getResult().endsWith("Final verification: passed")); + assertTrue(task.getResult().contains("action=\"output\"")); + assertEquals(full, jdbc.queryForObject( + "SELECT content FROM mate_message WHERE conversation_id=? AND role='assistant'", + String.class, task.getConversationId())); + assertTrue(output().endsWith(full)); + assertEquals(1, jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_team_task_event WHERE task_id=5 AND event_type='completed'", Integer.class)); + } + + @ParameterizedTest + @ValueSource(strings = {"worker_workspace", "run_workspace", "run_team", "task_parent", "task_assignee"}) + void persistedLinkageMismatchCannotDiscloseWorkerText(String mismatch) { + runWorker("CONFIDENTIAL-WORKER-RESULT"); + switch (mismatch) { + case "worker_workspace" -> jdbc.update( + "UPDATE mate_conversation SET workspace_id=999 WHERE conversation_id=?", + tasks.getTask(5L).getConversationId()); + case "run_workspace" -> jdbc.update("UPDATE mate_team_run SET workspace_id=999 WHERE id=20"); + case "run_team" -> jdbc.update("UPDATE mate_team_run SET team_id=999 WHERE id=20"); + case "task_parent" -> jdbc.update("UPDATE mate_team_task SET lead_conversation_id='unrelated' WHERE id=5"); + case "task_assignee" -> jdbc.update("UPDATE mate_team_task SET assignee_agent_id=999 WHERE id=5"); + default -> throw new AssertionError(mismatch); + } + + String result = output(); + + assertTrue(result.startsWith("Error:")); + assertFalse(result.contains("CONFIDENTIAL")); + } + + @Test + void retryReadsReplacementConversationEvenWhenDispatchCounterRepeats() { + runWorker("OLD-ATTEMPT-RESULT"); + String originalConversation = tasks.getTask(5L).getConversationId(); + assertEquals(1, tasks.getTask(5L).getDispatchCount()); + assertTrue(tasks.resumeForWorkerFeedback(5L)); + assertTrue(tasks.failTask(5L, "follow-up failed")); + assertTrue(tasks.retryTask(5L)); + assertTrue(tasks.assignTask(5L, 2L)); + assertTrue(tasks.tryAcquireDispatch(5L)); + + runWorker("NEW-ATTEMPT-RESULT"); + + assertEquals(1, tasks.getTask(5L).getDispatchCount()); + assertNotEquals(originalConversation, tasks.getTask(5L).getConversationId()); + String result = output(); + assertTrue(result.endsWith("NEW-ATTEMPT-RESULT")); + assertFalse(result.contains("OLD-ATTEMPT-RESULT")); + assertEquals("OLD-ATTEMPT-RESULT", jdbc.queryForObject( + "SELECT content FROM mate_message WHERE conversation_id=? AND role='assistant'", + String.class, originalConversation)); + } + + @Test + void normalReturnAfterDurableCancellationDoesNotAnnounceCompletion() { + when(agents.chatWithUsage(eq(2L), anyString(), anyString())).thenAnswer(invocation -> { + tasks.cancelTask(5L, "cancelled during model invocation"); + return AgentService.ChatResult.contentOnly("late normal return"); + }); + + dispatch.runTask(10L, tasks.getTask(5L)); + + assertEquals(TeamTaskStatus.CANCELLED, tasks.getTask(5L).getStatus()); + assertNull(tasks.getTask(5L).getResult()); + verify(events, never()).publishTaskEvent(any(), eq("team_task_completed"), any()); + verifyNoInteractions(announcements); + } + + @Test + void approvalTaskRemainsInReviewWithCompleteOutputAvailable() { + jdbc.update("UPDATE mate_team_task SET require_approval=TRUE WHERE id=5"); + + runWorker("Produced draft; human approval required"); + + assertEquals(TeamTaskStatus.IN_REVIEW, tasks.getTask(5L).getStatus()); + assertTrue(output().contains("Produced draft; human approval required")); + verify(events).publishTaskEvent(any(), eq("team_task_in_review"), any()); + verify(events, never()).publishTaskEvent(any(), eq("team_task_completed"), any()); + } + + @Test + void explicitOversizedCompletionIsRejectedAndFullFinalReplyRemainsRetrievable() { + String full = "Full analysis\n" + "evidence\n".repeat(2000) + "Final verification"; + when(agents.chatWithUsage(eq(2L), anyString(), anyString())).thenAnswer(invocation -> { + ToolExecutionContext.set(invocation.getArgument(2), "system"); + try { + String response = tool.team_tasks("complete", "5", null, null, null, null, null, + null, null, null, null, full, null, null, null, null, null, null, null); + assertTrue(response.startsWith("Error:")); + assertTrue(response.contains("8000")); + assertEquals(TeamTaskStatus.IN_PROGRESS, tasks.getTask(5L).getStatus()); + return AgentService.ChatResult.contentOnly(full); + } finally { + ToolExecutionContext.set("lead-conv", "system"); + } + }); + + dispatch.runTask(10L, tasks.getTask(5L)); + + assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(5L).getStatus()); + assertTrue(tasks.getTask(5L).getResult().length() <= 8000); + assertTrue(output().endsWith(full)); + } + + @Test + void largeRetrievedOutputSpillsLosslesslyAndItsTailCanBeRead(@TempDir Path workspace) throws Exception { + String full = "Opening findings\n" + "evidence line\n".repeat(4000) + "Final verification: passed"; + runWorker(full); + String retrieved = output(); + var properties = new ToolResultProperties(); + properties.setStorageBaseDir(workspace.resolve("tool-results").toString()); + var storage = new ToolResultStorage(properties); + + String preview = storage.persistIfOversized(retrieved, "team_tasks", "output-5", + "lead-conv", workspace.toString()); + + assertTrue(preview.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)); + assertTrue(preview.contains("read_file")); + var pathMatcher = java.util.regex.Pattern.compile("path=(\\S+)").matcher(preview); + assertTrue(pathMatcher.find()); + Path spill = Path.of(pathMatcher.group(1)); + assertEquals(retrieved, Files.readString(spill)); + ToolExecutionContext.set("lead-conv", "system", workspace.toString()); + try { + var reader = new ReadFileTool(mock(I18nService.class)); + String tail = reader.read_file(spill.toString(), 4000, null, null, null); + assertTrue(tail.contains("Final verification: passed"), tail); + } finally { + ToolExecutionContext.set("lead-conv", "system"); + } + } + + /** Opt-in verification across the real 60-minute lease and 20-minute heartbeat cadence. */ + @Test + @Timeout(value = 105, unit = TimeUnit.MINUTES) + @EnabledIfSystemProperty(named = "mateclaw.team.lease-soak", matches = "true") + void longWorkerRetainsLeaseAcrossOriginalExpiryAndSettlesAfterCleanup() throws Exception { + long minutes = Long.getLong("mateclaw.team.lease-soak.minutes", 95L); + assertTrue(minutes >= 65 && minutes <= 100, "Soak must cross the actual initial lease expiry"); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + when(agents.chatWithUsage(eq(2L), anyString(), anyString())).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(102, TimeUnit.MINUTES)); + return AgentService.ChatResult.contentOnly("Long-running worker verification complete"); + }); + jdbc.update("UPDATE mate_team_task SET lock_expires_at=? WHERE id=5", + LocalDateTime.now().plusMinutes(TeamTaskService.LOCK_MINUTES)); + LocalDateTime originalExpiry = tasks.getTask(5L).getLockExpiresAt(); + long started = System.nanoTime(); + long duration = Duration.ofMinutes(minutes).toNanos(); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var worker = executor.submit(() -> dispatch.runTask(10L, tasks.getTask(5L))); + try { + assertTrue(entered.await(10, TimeUnit.SECONDS)); + while (System.nanoTime() - started < duration) { + Thread.sleep(Duration.ofSeconds(30)); + assertTrue(tasks.recoverStaleTasks().isEmpty(), "Live worker was reclaimed as stale"); + var current = tasks.getTask(5L); + assertEquals(TeamTaskStatus.IN_PROGRESS, current.getStatus()); + assertTrue(current.getLockExpiresAt().isAfter(LocalDateTime.now())); + System.out.printf("TEAM_LEASE_SOAK elapsedSeconds=%d originalExpiry=%s renewedExpiry=%s status=%s%n", + TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - started), originalExpiry, + current.getLockExpiresAt(), current.getStatus()); + } + assertTrue(LocalDateTime.now().isAfter(originalExpiry)); + assertTrue(tasks.getTask(5L).getLockExpiresAt().isAfter(originalExpiry)); + } finally { + release.countDown(); + } + worker.get(10, TimeUnit.SECONDS); + } + assertEquals(TeamTaskStatus.COMPLETED, tasks.getTask(5L).getStatus()); + assertNull(tasks.getTask(5L).getLockExpiresAt()); + assertTrue(output().contains("Long-running worker verification complete")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java index e1b13762..4ee4b870 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java @@ -26,6 +26,7 @@ import vip.mate.team.service.TeamTaskService; import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.model.MessageEntity; import java.util.List; import java.util.Optional; @@ -122,6 +123,136 @@ class TeamTasksToolTest { return run; } + private TeamTaskEntity savedWorkerTask() { + TeamTaskEntity task = task(5L, TeamTaskStatus.COMPLETED); + task.setConversationId("team-task-worker"); + task.setLeadConversationId(CONV); + task.setRunId(RUN_ID); + task.setResult("short board summary"); + when(taskService.getTask(5L)).thenReturn(task); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.COMPLETED)); + ConversationEntity worker = new ConversationEntity(); + worker.setConversationId(task.getConversationId()); + worker.setWorkspaceId(WORKSPACE_ID); + worker.setAgentId(MEMBER_ID); + worker.setConversationKind("team_worker"); + worker.setParentConversationId(CONV); + when(conversationService.findByConversationId(task.getConversationId())).thenReturn(worker); + return task; + } + + @Test + void outputReturnsCompleteSavedWorkerReplyInsteadOfBoardSummary() { + callerIs(LEAD_ID); + TeamTaskEntity task = savedWorkerTask(); + MessageEntity message = new MessageEntity(); + message.setRole("assistant"); + message.setStatus("completed"); + String full = "HEAD\n" + "analysis\n".repeat(2000) + "FINAL VERIFICATION"; + message.setContent(full); + when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of(message)); + + String output = invoke("output", "5"); + + assertTrue(output.contains(full)); + assertTrue(output.contains("taskId=5")); + assertFalse(output.contains("short board summary")); + } + + @Test + void outputRejectsForeignTeamBeforeReadingAnyWorkerMessages() { + callerIs(LEAD_ID); + TeamTaskEntity foreign = task(5L, TeamTaskStatus.COMPLETED); + foreign.setTeamId(999L); + when(taskService.getTask(5L)).thenReturn(foreign); + + assertTrue(invoke("output", "5").contains("not found on this team's board")); + verify(conversationService, never()).listRecentMessages(anyString(), anyInt()); + } + + @Test + void outputRejectsWorkerConversationOutsideTeamWorkspace() { + callerIs(LEAD_ID); + TeamTaskEntity task = savedWorkerTask(); + conversationService.findByConversationId(task.getConversationId()).setWorkspaceId(999L); + + assertTrue(invoke("output", "5").startsWith("Error:")); + verify(conversationService, never()).listRecentMessages(anyString(), anyInt()); + } + + @Test + void outputRejectsWorkerConversationWithDifferentParent() { + callerIs(LEAD_ID); + TeamTaskEntity task = savedWorkerTask(); + conversationService.findByConversationId(task.getConversationId()).setParentConversationId("unrelated-lead"); + + assertTrue(invoke("output", "5").startsWith("Error:")); + verify(conversationService, never()).listRecentMessages(anyString(), anyInt()); + } + + @Test + void outputRejectsWorkerConversationWithDifferentAgent() { + callerIs(MEMBER_ID); + TeamTaskEntity task = savedWorkerTask(); + conversationService.findByConversationId(task.getConversationId()).setAgentId(999L); + + assertTrue(invoke("output", "5").startsWith("Error:")); + verify(conversationService, never()).listRecentMessages(anyString(), anyInt()); + } + + @Test + void outputWithoutSavedWorkerReplyClearlyIdentifiesFallbackSummary() { + callerIs(LEAD_ID); + TeamTaskEntity task = savedWorkerTask(); + when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of()); + + String output = invoke("output", "5"); + + assertTrue(output.contains("Full worker output unavailable")); + assertTrue(output.contains("short board summary")); + } + + @Test + void fullTaskRecordExposesDependenciesAndDeliverablesForOmittedContext() { + callerIs(MEMBER_ID); + TeamTaskEntity task = task(5L, TeamTaskStatus.COMPLETED); + task.setBlockedBy("[11,12,13,14,15,16,17,18,19]"); + when(taskService.getTask(5L)).thenReturn(task); + when(taskService.listDeliverables(task)).thenReturn(List.of( + new TeamTaskService.Deliverable("report.docx", "/api/v1/files/generated/report", null))); + + String output = invoke("get", "5"); + + assertTrue(output.contains("Dependencies (task ids): [11, 12, 13, 14, 15, 16, 17, 18, 19]")); + assertTrue(output.contains("report.docx → /api/v1/files/generated/report")); + } + + @Test + void outputAcceptsTheSameBoundedLegacyParentFallbackAsTranscriptGovernance() { + callerIs(LEAD_ID); + TeamTaskEntity task = savedWorkerTask(); + ConversationEntity worker = conversationService.findByConversationId(task.getConversationId()); + worker.setConversationKind("primary"); + worker.setParentConversationId(null); + MessageEntity reply = new MessageEntity(); + reply.setRole("assistant"); + reply.setContent("legacy saved worker result"); + when(conversationService.listRecentMessages(task.getConversationId(), 1)).thenReturn(List.of(reply)); + + assertTrue(invoke("output", "5").endsWith("legacy saved worker result")); + } + + @Test + void modernWorkerWithMissingParentCannotUseLegacyFallback() { + callerIs(LEAD_ID); + TeamTaskEntity task = savedWorkerTask(); + conversationService.findByConversationId(task.getConversationId()).setParentConversationId(null); + + assertTrue(invoke("output", "5").contains("linkage does not match")); + verify(conversationService, never()).listRecentMessages(anyString(), anyInt()); + } + // ==================== context & membership gating ==================== @Test diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java index cc847ea5..ac789fa1 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import vip.mate.i18n.I18nService; +import vip.mate.tool.guard.WorkspacePathGuard; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -31,6 +32,7 @@ import static org.mockito.Mockito.mock; class ReadFileToolLargeLineTest { private ReadFileTool tool; + private Path previousDefaultRoot; @BeforeEach void setUp() { @@ -40,12 +42,15 @@ class ReadFileToolLargeLineTest { "msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null); tool = new ReadFileTool(i18n); // Ensure no workspace boundary is active so absolute temp paths validate. + previousDefaultRoot = WorkspacePathGuard.getDefaultRoot(); + WorkspacePathGuard.setDefaultRoot(null); ToolExecutionContext.clear(); } @AfterEach void tearDown() { ToolExecutionContext.clear(); + WorkspacePathGuard.setDefaultRoot(previousDefaultRoot == null ? null : previousDefaultRoot.toString()); } /** Build a single-line JSON array of {@code n} string elements. */