mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat-stream): streaming UX overhaul + multi-agent stability layer
This commit is contained in:
parent
d1099f1524
commit
66f09a968a
@ -134,6 +134,19 @@ public class AgentGraphBuilder {
|
||||
/** PR-0b: DashScope-specific construction lives here now; we only call into it for the search-on log. */
|
||||
private final vip.mate.agent.chatmodel.AgentDashScopeChatModelBuilder dashScopeBuilder;
|
||||
|
||||
/**
|
||||
* Optional audit pipeline. Setter injection (rather than a constructor
|
||||
* parameter) keeps existing constructor-based wiring + tests intact.
|
||||
* When present, the executor receives it so child-agent denied-tool
|
||||
* attempts can be recorded.
|
||||
*/
|
||||
private vip.mate.audit.service.AuditEventService auditEventService;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
|
||||
this.auditEventService = s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 AgentEntity 构建完整的 Agent 实例
|
||||
*/
|
||||
@ -347,6 +360,11 @@ public class AgentGraphBuilder {
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
executor.setSkillRuntimeService(skillRuntimeService);
|
||||
// Optional: route child-agent denied-tool audit events through
|
||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||
if (auditEventService != null) {
|
||||
executor.setAuditEventService(auditEventService);
|
||||
}
|
||||
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
|
||||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
|
||||
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
|
||||
@ -478,6 +496,11 @@ public class AgentGraphBuilder {
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
executor.setSkillRuntimeService(skillRuntimeService);
|
||||
// Optional: route child-agent denied-tool audit events through
|
||||
// the audit pipeline. Null when audit is not wired (legacy / test).
|
||||
if (auditEventService != null) {
|
||||
executor.setAuditEventService(auditEventService);
|
||||
}
|
||||
// PR-1.2 (RFC-049 L1-B): propagate the bound model's capability so ReasoningNode
|
||||
// can gate the ThinkingLevelHolder override explicitly, rather than inferring
|
||||
// capability from reasoningEffort == null.
|
||||
|
||||
@ -81,10 +81,15 @@ public final class GraphEventPublisher {
|
||||
|
||||
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
|
||||
long ts = System.currentTimeMillis();
|
||||
// Carry the full tool result; transport-layer chunking lives in
|
||||
// ChatStreamTracker.broadcastChunked, which splits oversize payloads
|
||||
// into ordered tool_result_chunk events when they exceed the 8 KB
|
||||
// single-event budget. The previous unconditional 500-char truncation
|
||||
// here destroyed data that the front-end could otherwise render in full.
|
||||
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
||||
"toolCallId", toolCallId != null ? toolCallId : "",
|
||||
"toolName", toolName,
|
||||
"result", result != null ? truncateResult(result) : "",
|
||||
"result", result != null ? result : "",
|
||||
"success", success,
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
@ -110,9 +115,11 @@ public final class GraphEventPublisher {
|
||||
|
||||
public static GraphEvent stepCompleted(int index, String result) {
|
||||
long ts = System.currentTimeMillis();
|
||||
// Full step result; broadcastChunked splits at the transport layer
|
||||
// when the payload exceeds the per-event size budget.
|
||||
return new GraphEvent(EVENT_STEP_COMPLETED, Map.of(
|
||||
"index", index,
|
||||
"result", result != null ? truncateResult(result) : "",
|
||||
"result", result != null ? result : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
}
|
||||
@ -123,7 +130,7 @@ public final class GraphEventPublisher {
|
||||
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
|
||||
"pendingId", pendingId,
|
||||
"toolName", toolName != null ? toolName : "",
|
||||
"arguments", arguments != null ? truncateResult(arguments) : "",
|
||||
"arguments", arguments != null ? arguments : "",
|
||||
"reason", reason != null ? reason : "",
|
||||
"timestamp", ts
|
||||
), ts);
|
||||
@ -140,7 +147,7 @@ public final class GraphEventPublisher {
|
||||
java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("pendingId", pendingId);
|
||||
data.put("toolName", toolName != null ? toolName : "");
|
||||
data.put("arguments", arguments != null ? truncateForBroadcast(arguments) : "");
|
||||
data.put("arguments", arguments != null ? arguments : "");
|
||||
data.put("reason", reason != null ? reason : "");
|
||||
data.put("summary", summary);
|
||||
data.put("maxSeverity", maxSeverity);
|
||||
@ -196,14 +203,90 @@ public final class GraphEventPublisher {
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through; preserved for source/binary compatibility with older callers.
|
||||
* Truncation at the SSE transport layer is now handled by {@code
|
||||
* ChatStreamTracker.broadcastChunked} which splits oversize payloads into
|
||||
* ordered chunk events instead of dropping bytes. Logs a one-time
|
||||
* deprecation hint when invoked.
|
||||
*
|
||||
* @deprecated callers should pass full payloads and let the transport layer
|
||||
* decide whether to chunk.
|
||||
*/
|
||||
@Deprecated
|
||||
private static String truncateResult(String result) {
|
||||
return result.length() > 500 ? result.substring(0, 500) + "..." : result;
|
||||
warnTruncateDeprecation();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串用于直推广播(公共方法,供 Node 直接构造广播数据时使用)
|
||||
* Pass-through, kept for source compatibility with code that built broadcast
|
||||
* payloads directly. Same deprecation reason as {@link #truncateResult}.
|
||||
*
|
||||
* @deprecated callers should pass full payloads.
|
||||
*/
|
||||
@Deprecated
|
||||
public static String truncateForBroadcast(String text) {
|
||||
return truncateResult(text);
|
||||
warnTruncateDeprecation();
|
||||
return text;
|
||||
}
|
||||
|
||||
private static final java.util.concurrent.atomic.AtomicBoolean TRUNCATE_WARNED =
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
|
||||
private static void warnTruncateDeprecation() {
|
||||
if (TRUNCATE_WARNED.compareAndSet(false, true)) {
|
||||
org.slf4j.LoggerFactory.getLogger(GraphEventPublisher.class)
|
||||
.warn("GraphEventPublisher.truncateResult/truncateForBroadcast are deprecated " +
|
||||
"no-op pass-throughs; payloads are no longer truncated here. " +
|
||||
"Move callers to send the full string and rely on " +
|
||||
"ChatStreamTracker.broadcastChunked for transport-level chunking.");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Iteration lifecycle events =====
|
||||
|
||||
public static final String EVENT_ITERATION_START = "iteration_start";
|
||||
public static final String EVENT_ITERATION_END = "iteration_end";
|
||||
|
||||
/**
|
||||
* Marks the entry of an iteration boundary so consumers can group later
|
||||
* tool / content / thinking events under a single logical step. The
|
||||
* {@code scope} field distinguishes the parent agent ("parent") from a
|
||||
* delegated sub-agent ("subagent"); when scope is "subagent" the
|
||||
* {@code subagentId} payload field is populated by the producer.
|
||||
*/
|
||||
public static GraphEvent iterationStart(int index, String reason, String scope, String subagentId) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("index", index);
|
||||
data.put("reason", reason != null ? reason : "");
|
||||
data.put("scope", scope != null ? scope : "parent");
|
||||
if (subagentId != null && !subagentId.isEmpty()) {
|
||||
data.put("subagentId", subagentId);
|
||||
}
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_ITERATION_START, Map.copyOf(data), ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the matching {@link #iterationStart} boundary. {@code contentChars}
|
||||
* and {@code thinkingChars} let consumers render a compact "this turn
|
||||
* produced X content / Y thinking" header without re-aggregating the
|
||||
* underlying delta events.
|
||||
*/
|
||||
public static GraphEvent iterationEnd(int index, String scope, String subagentId,
|
||||
int contentChars, int thinkingChars) {
|
||||
long ts = System.currentTimeMillis();
|
||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||
data.put("index", index);
|
||||
data.put("scope", scope != null ? scope : "parent");
|
||||
if (subagentId != null && !subagentId.isEmpty()) {
|
||||
data.put("subagentId", subagentId);
|
||||
}
|
||||
data.put("contentChars", contentChars);
|
||||
data.put("thinkingChars", thinkingChars);
|
||||
data.put("timestamp", ts);
|
||||
return new GraphEvent(EVENT_ITERATION_END, Map.copyOf(data), ts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* REST surface for managing live sub-agents:
|
||||
* <ul>
|
||||
* <li>{@code POST /interrupt} — stop a running sub-agent.</li>
|
||||
* <li>{@code POST /spawn-pause} — toggle the per-parent spawn-pause flag.</li>
|
||||
* <li>{@code GET /active} — list sub-agents under one parent conversation.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Every endpoint authorizes the caller against the parent conversation's
|
||||
* owner before mutating or revealing anything; the {@code parentConversationId}
|
||||
* query parameter on {@code /active} is mandatory so the route cannot be used
|
||||
* to enumerate cross-tenant sub-agents.
|
||||
*
|
||||
* <p>Authorization mirrors the {@link vip.mate.workspace.conversation.ConversationService#isConversationOwner}
|
||||
* pattern used by the chat stop / fork routes — usernames are the principal
|
||||
* identity carried on {@link Authentication#getName()}, and shared "system"
|
||||
* conversations are accessible to all logged-in users (matches the existing
|
||||
* cron-job convention).
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "Sub-agents")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/subagents")
|
||||
@RequiredArgsConstructor
|
||||
public class SubagentController {
|
||||
|
||||
private final SubagentRegistry registry;
|
||||
private final ConversationService conversationService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Resolve the record and verify the caller owns its parent conversation.
|
||||
* Throws a 403-coded exception when ownership fails so the global handler
|
||||
* can render a uniform JSON error envelope.
|
||||
*/
|
||||
private SubagentRegistry.SubagentRecord requireOwnership(String subagentId, Authentication auth) {
|
||||
Optional<SubagentRegistry.SubagentRecord> opt = registry.get(subagentId);
|
||||
if (opt.isEmpty()) {
|
||||
throw new MateClawException(404, "subagent " + subagentId + " not found");
|
||||
}
|
||||
SubagentRegistry.SubagentRecord rec = opt.get();
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(rec.parentConversationId(), username)) {
|
||||
// Audit denial separately from the operation itself so admins can
|
||||
// see what cross-tenant attempts hit the registry. Best-effort
|
||||
// serialization — the audit insert is async on the service side.
|
||||
auditEventService.record("subagent.interrupt.denied", "subagent",
|
||||
subagentId, rec.subagentId(),
|
||||
safeJson(Map.of(
|
||||
"callerUsername", username,
|
||||
"parent", rec.parentConversationId(),
|
||||
"agentId", rec.agentId() == null ? -1L : rec.agentId()
|
||||
)));
|
||||
throw new MateClawException(403, "not the owner of subagent's parent conversation");
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running sub-agent. The registry flips status to {@code interrupted}
|
||||
* and disposes the streaming subscription if one was registered. Returns
|
||||
* the {@code interrupted} flag so the caller can distinguish "we did stop
|
||||
* something" from "the subagent was already finished" (404 case is handled
|
||||
* separately by {@link #requireOwnership}).
|
||||
*/
|
||||
@Operation(summary = "Interrupt a running sub-agent")
|
||||
@PostMapping("/{subagentId}/interrupt")
|
||||
public R<Map<String, Object>> interrupt(@PathVariable String subagentId, Authentication auth) {
|
||||
SubagentRegistry.SubagentRecord rec = requireOwnership(subagentId, auth);
|
||||
boolean ok = registry.interrupt(subagentId);
|
||||
auditEventService.record("subagent.interrupt", "subagent",
|
||||
subagentId, rec.subagentId(),
|
||||
safeJson(Map.of(
|
||||
"by", currentUsername(auth),
|
||||
"parent", rec.parentConversationId(),
|
||||
"result", ok
|
||||
)));
|
||||
return R.ok(Map.of("interrupted", ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether new sub-agent spawns are accepted under a parent
|
||||
* conversation. Used by the operator UI to halt runaway parent agents
|
||||
* mid-turn without killing the parent's own LLM call.
|
||||
*/
|
||||
@Operation(summary = "Set sub-agent spawn-pause for a conversation")
|
||||
@PostMapping("/spawn-pause")
|
||||
public R<Map<String, Object>> setPaused(@RequestBody Map<String, Object> body, Authentication auth) {
|
||||
Object parentObj = body == null ? null : body.get("parentConversationId");
|
||||
String parent = parentObj == null ? null : parentObj.toString();
|
||||
if (parent == null || parent.isBlank()) {
|
||||
throw new MateClawException(400, "parentConversationId required");
|
||||
}
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(parent, username)) {
|
||||
throw new MateClawException(403, "not the owner of conversation " + parent);
|
||||
}
|
||||
boolean paused = Boolean.TRUE.equals(body.get("paused"));
|
||||
registry.setSpawnPaused(parent, paused);
|
||||
auditEventService.record("subagent.spawn-pause", "conversation",
|
||||
parent, parent,
|
||||
safeJson(Map.of(
|
||||
"paused", paused,
|
||||
"by", username
|
||||
)));
|
||||
return R.ok(Map.of("paused", paused));
|
||||
}
|
||||
|
||||
/**
|
||||
* List the sub-agents currently active under {@code parentConversationId}.
|
||||
* The query parameter is mandatory: returning all subagents process-wide
|
||||
* would let any logged-in user enumerate other tenants' delegation trees.
|
||||
*/
|
||||
@Operation(summary = "List active sub-agents under a parent conversation")
|
||||
@GetMapping("/active")
|
||||
public R<Map<String, Object>> listActive(@RequestParam(required = false) String parentConversationId,
|
||||
Authentication auth) {
|
||||
if (parentConversationId == null || parentConversationId.isBlank()) {
|
||||
throw new MateClawException(400, "parentConversationId required");
|
||||
}
|
||||
String username = currentUsername(auth);
|
||||
if (!conversationService.isConversationOwner(parentConversationId, username)) {
|
||||
throw new MateClawException(403, "not the owner of conversation " + parentConversationId);
|
||||
}
|
||||
List<Map<String, Object>> snapshot = registry.snapshot(parentConversationId).stream()
|
||||
.map(this::toResponseDto)
|
||||
.toList();
|
||||
return R.ok(Map.of("subagents", snapshot));
|
||||
}
|
||||
|
||||
/** Username from auth context; falls back to "anonymous" only when null. */
|
||||
private String currentUsername(Authentication auth) {
|
||||
return auth != null ? auth.getName() : "anonymous";
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO projection that drops the {@link reactor.core.Disposable} (not
|
||||
* serializable to the wire) and exposes only the user-facing fields.
|
||||
*/
|
||||
private Map<String, Object> toResponseDto(SubagentRegistry.SubagentRecord rec) {
|
||||
Map<String, Object> dto = new LinkedHashMap<>();
|
||||
dto.put("subagentId", rec.subagentId());
|
||||
dto.put("parentConversationId", rec.parentConversationId());
|
||||
dto.put("childConversationId", rec.childConversationId());
|
||||
dto.put("agentId", rec.agentId());
|
||||
dto.put("goal", rec.goal());
|
||||
dto.put("startedAt", rec.startedAt());
|
||||
dto.put("status", rec.status().get());
|
||||
dto.put("toolCount", rec.toolCount().get());
|
||||
dto.put("lastTool", rec.lastTool().get());
|
||||
dto.put("currentPhase", rec.currentPhase().get());
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON serialization for audit detail. Falling back to a
|
||||
* marker string keeps the audit row insertable when payload contains
|
||||
* a non-serializable value — the alternative (throwing) would lose the
|
||||
* audit record entirely.
|
||||
*/
|
||||
private String safeJson(Map<String, Object> payload) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{\"error\":\"audit_serialization_failed\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Periodic watchdog that flips a sub-agent's status to {@code stale} when its
|
||||
* child stream stops making observable progress.
|
||||
*
|
||||
* <p>Progress is probed via {@link ChatStreamTracker#getRunningToolName} and
|
||||
* {@link ChatStreamTracker#getCurrentPhase}. When neither has changed across
|
||||
* the configured number of cycles, the record is marked stale and a
|
||||
* {@code subagent_stale} event is broadcast on the parent conversation so the
|
||||
* UI can surface the issue. Cycle count uses two separate thresholds — one
|
||||
* for idle children and one for children mid-tool — because legitimately slow
|
||||
* tools (large file scans, slow LLM calls) need a longer window than an idle
|
||||
* model that has simply gone quiet.
|
||||
*
|
||||
* <p>The runtime tool name + phase combination is a deliberately coarse
|
||||
* progress signal: it does not require introspecting LLM token deltas, which
|
||||
* keeps the watchdog cheap and avoids racing with the streaming hot path.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SubagentHeartbeat {
|
||||
|
||||
private final SubagentRegistry registry;
|
||||
private final SubagentHeartbeatConfig cfg;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
|
||||
/**
|
||||
* Scheduled tick. Defaults to every 30 s; controlled by
|
||||
* {@code mateclaw.delegation.heartbeat.intervalSec}.
|
||||
*/
|
||||
@Scheduled(fixedRateString = "#{@subagentHeartbeatConfig.intervalSec * 1000L}")
|
||||
public void check() {
|
||||
for (var rec : registry.allActive()) {
|
||||
if (!"running".equals(rec.status().get())) {
|
||||
continue;
|
||||
}
|
||||
evaluate(rec);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible for testing — apply one heartbeat tick to a single record so
|
||||
* tests can drive the watchdog deterministically without scheduling.
|
||||
*/
|
||||
void evaluate(SubagentRegistry.SubagentRecord rec) {
|
||||
// Probe child progress. We use (currentTool, currentPhase) as the
|
||||
// monotonic-progress signal: any change in either implies the child
|
||||
// advanced at least one observable step. We deliberately do NOT
|
||||
// depend on a private apiCallCount field — the RunState does not
|
||||
// expose one, and counting deltas across the streaming hot path
|
||||
// would race with token emission. Tool/phase ticks are atomic
|
||||
// volatile writes from the streaming layer, so reading them here
|
||||
// is cheap and correct.
|
||||
String currentTool = streamTracker.getRunningToolName(rec.childConversationId());
|
||||
String currentPhase = streamTracker.getCurrentPhase(rec.childConversationId());
|
||||
int phaseHash = currentPhase != null ? currentPhase.hashCode() : 0;
|
||||
|
||||
boolean toolChanged = !Objects.equals(currentTool, rec.lastSeenTool().get());
|
||||
boolean phaseChanged = phaseHash != rec.lastSeenIter().get();
|
||||
|
||||
if (toolChanged || phaseChanged) {
|
||||
rec.lastSeenTool().set(currentTool);
|
||||
rec.lastSeenIter().set(phaseHash);
|
||||
rec.staleCount().set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
int sc = rec.staleCount().incrementAndGet();
|
||||
int limit = (currentTool != null && !currentTool.isEmpty())
|
||||
? cfg.getStaleCyclesInTool()
|
||||
: cfg.getStaleCyclesIdle();
|
||||
|
||||
if (sc >= limit) {
|
||||
// Atomic transition: only the first thread to flip running -> stale
|
||||
// emits the event. Subsequent ticks fall through the running guard
|
||||
// in check().
|
||||
if (rec.status().compareAndSet("running", "stale")) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("subagentId", rec.subagentId());
|
||||
payload.put("cycles", sc);
|
||||
payload.put("lastTool", currentTool != null ? currentTool : "");
|
||||
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
|
||||
streamTracker.broadcastObject(rec.parentConversationId(), "subagent_stale", payload);
|
||||
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
|
||||
rec.subagentId(), sc, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Configuration knobs for {@link SubagentHeartbeat}.
|
||||
*
|
||||
* <p>Defaults are tuned so a wedged child surfaces visibly to the parent UI
|
||||
* without firing on legitimately slow tool runs:
|
||||
* <ul>
|
||||
* <li>Idle (no tool running) → 5 cycles × 30 s = 150 s before stale.</li>
|
||||
* <li>In a tool → 20 cycles × 30 s = 600 s before stale.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The in-tool threshold MUST stay greater than or equal to
|
||||
* {@code child_hard_timeout / intervalSec}. If it fires before the per-child
|
||||
* hard cap then the cap stops being the source of truth for "this child is
|
||||
* dead" and operators see ambiguous telemetry.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties("mateclaw.delegation.heartbeat")
|
||||
public class SubagentHeartbeatConfig {
|
||||
|
||||
/**
|
||||
* Heartbeat check interval in seconds. Lower values make the parent
|
||||
* transcript more responsive at the cost of scheduler overhead.
|
||||
*/
|
||||
private int intervalSec = 30;
|
||||
|
||||
/**
|
||||
* Stale threshold (in heartbeat cycles) when the child has no current
|
||||
* tool in flight. With the default 30 s interval this is 150 s, tight
|
||||
* enough that a wedged child does not mask a legitimate gateway timeout.
|
||||
*/
|
||||
private int staleCyclesIdle = 5;
|
||||
|
||||
/**
|
||||
* Stale threshold (in heartbeat cycles) while the child is inside a
|
||||
* tool. Generous enough to tolerate slow tools (large file reads, slow
|
||||
* LLM calls). Must be at least the per-child hard timeout divided by
|
||||
* {@link #intervalSec}, otherwise stale fires before the hard cap and
|
||||
* obscures fallback semantics.
|
||||
*/
|
||||
private int staleCyclesInTool = 20;
|
||||
|
||||
public int getIntervalSec() {
|
||||
return intervalSec;
|
||||
}
|
||||
|
||||
public void setIntervalSec(int intervalSec) {
|
||||
this.intervalSec = intervalSec;
|
||||
}
|
||||
|
||||
public int getStaleCyclesIdle() {
|
||||
return staleCyclesIdle;
|
||||
}
|
||||
|
||||
public void setStaleCyclesIdle(int staleCyclesIdle) {
|
||||
this.staleCyclesIdle = staleCyclesIdle;
|
||||
}
|
||||
|
||||
public int getStaleCyclesInTool() {
|
||||
return staleCyclesInTool;
|
||||
}
|
||||
|
||||
public void setStaleCyclesInTool(int staleCyclesInTool) {
|
||||
this.staleCyclesInTool = staleCyclesInTool;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Process-wide registry of live sub-agents spawned through the delegation flow.
|
||||
*
|
||||
* <p>Holds the in-memory subagent tree so the parent transcript, the heartbeat
|
||||
* watcher, and the operator UI can observe / interrupt children that the parent
|
||||
* conversation spawned. Records use atomic accessors throughout because the
|
||||
* heartbeat thread may mutate {@code staleCount} / {@code status} concurrently
|
||||
* with the spawning thread that registered the record.
|
||||
*
|
||||
* <p>The pause flag is keyed per parent conversation so two unrelated users
|
||||
* cannot freeze each other's spawning by toggling a global switch.
|
||||
*/
|
||||
@Component
|
||||
public class SubagentRegistry {
|
||||
|
||||
/**
|
||||
* Single live sub-agent.
|
||||
*
|
||||
* <p>Mutable counters are atomics so the heartbeat scheduler and the
|
||||
* spawn / completion thread can update them without locking. Status is
|
||||
* driven by external lifecycle events; allowed values are
|
||||
* {@code running} / {@code completed} / {@code interrupted} / {@code stale}
|
||||
* / {@code timeout}.
|
||||
*/
|
||||
public record SubagentRecord(
|
||||
String subagentId,
|
||||
String parentConversationId,
|
||||
String childConversationId,
|
||||
Long agentId,
|
||||
String goal,
|
||||
long startedAt,
|
||||
AtomicReference<String> status,
|
||||
AtomicInteger toolCount,
|
||||
AtomicReference<String> lastTool,
|
||||
AtomicReference<String> currentPhase,
|
||||
AtomicInteger lastSeenIter,
|
||||
AtomicReference<String> lastSeenTool,
|
||||
AtomicInteger staleCount,
|
||||
AtomicLong firstApiCallAt,
|
||||
Disposable disposable
|
||||
) {}
|
||||
|
||||
private final ConcurrentMap<String, SubagentRecord> active = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Per-parent pause flag set: scoping prevents one user from freezing
|
||||
* another user's spawning. A parent conversation appears in this set iff
|
||||
* spawning is currently paused for it.
|
||||
*/
|
||||
private final Set<String> pausedParents = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private final SecureRandom rng = new SecureRandom();
|
||||
|
||||
/**
|
||||
* Register a freshly spawned sub-agent. Returns the assigned subagentId
|
||||
* which the caller must thread through to {@link #unregister(String)} on
|
||||
* completion (success / failure / timeout) so the registry does not leak.
|
||||
*
|
||||
* <p>ID format {@code sa-<epoch_ms>-<8 hex chars>} keeps IDs sortable by
|
||||
* spawn time while the random suffix prevents collisions when many
|
||||
* children spawn within the same millisecond.
|
||||
*/
|
||||
public String register(String parentConvId, String childConvId, Long agentId, String goal, Disposable d) {
|
||||
String sid = "sa-" + System.currentTimeMillis() + "-" + nextHexSuffix();
|
||||
active.put(sid, new SubagentRecord(
|
||||
sid,
|
||||
parentConvId,
|
||||
childConvId,
|
||||
agentId,
|
||||
goal,
|
||||
System.currentTimeMillis(),
|
||||
new AtomicReference<>("running"),
|
||||
new AtomicInteger(0),
|
||||
new AtomicReference<>(""),
|
||||
new AtomicReference<>("starting"),
|
||||
new AtomicInteger(0),
|
||||
new AtomicReference<>(null),
|
||||
new AtomicInteger(0),
|
||||
new AtomicLong(0),
|
||||
d));
|
||||
return sid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a sub-agent as interrupted and dispose its underlying stream
|
||||
* subscription if one was registered. Returns {@code false} when the
|
||||
* subagentId is unknown (already cleaned up or never registered) so
|
||||
* callers can distinguish "not running anymore" from "interrupted".
|
||||
*/
|
||||
public boolean interrupt(String subagentId) {
|
||||
if (subagentId == null) return false;
|
||||
SubagentRecord r = active.get(subagentId);
|
||||
if (r == null) return false;
|
||||
r.status().set("interrupted");
|
||||
Disposable d = r.disposable();
|
||||
if (d != null && !d.isDisposed()) {
|
||||
d.dispose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<SubagentRecord> get(String subagentId) {
|
||||
return subagentId == null ? Optional.empty() : Optional.ofNullable(active.get(subagentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all sub-agents whose parent matches {@code parentConvId}.
|
||||
* Filtering at the registry boundary prevents callers from accidentally
|
||||
* surfacing other tenants' subagents in API responses.
|
||||
*/
|
||||
public List<SubagentRecord> snapshot(String parentConvId) {
|
||||
if (parentConvId == null) return List.of();
|
||||
return active.values().stream()
|
||||
.filter(r -> parentConvId.equals(r.parentConversationId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void unregister(String subagentId) {
|
||||
if (subagentId == null) return;
|
||||
active.remove(subagentId);
|
||||
}
|
||||
|
||||
public boolean isSpawnPaused(String parentConvId) {
|
||||
if (parentConvId == null) return false;
|
||||
return pausedParents.contains(parentConvId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the pause flag for one parent conversation. Returns the new
|
||||
* paused state so the caller can echo the resulting flag without an
|
||||
* extra read.
|
||||
*/
|
||||
public boolean setSpawnPaused(String parentConvId, boolean paused) {
|
||||
if (parentConvId == null) return false;
|
||||
if (paused) {
|
||||
pausedParents.add(parentConvId);
|
||||
} else {
|
||||
pausedParents.remove(parentConvId);
|
||||
}
|
||||
return paused;
|
||||
}
|
||||
|
||||
public Collection<SubagentRecord> allActive() {
|
||||
return active.values();
|
||||
}
|
||||
|
||||
/** Lowercase 8-hex-char suffix sourced from a SecureRandom. */
|
||||
private String nextHexSuffix() {
|
||||
byte[] bytes = new byte[4];
|
||||
rng.nextBytes(bytes);
|
||||
StringBuilder sb = new StringBuilder(8);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -701,12 +701,47 @@ public class NodeStreamingChatHelper {
|
||||
AtomicInteger cacheReadTokens = new AtomicInteger(0);
|
||||
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
|
||||
|
||||
// 重复检测器:检测 LLM 退化输出(如不断重复同一句话)
|
||||
RepetitionDetector contentRepDetector = new RepetitionDetector();
|
||||
RepetitionDetector thinkingRepDetector = new RepetitionDetector();
|
||||
// Cross-call repetition detectors: scoped to the conversation rather
|
||||
// than this single LLM call so the sentence-level path can catch
|
||||
// adjacent-iteration loops. Tracker returns a fresh detector when
|
||||
// conversationId is unknown (tests, legacy callers); mocked trackers
|
||||
// without stubs may return null, so we fall back defensively.
|
||||
final RepetitionDetector contentRepDetector =
|
||||
pickDetector(streamTracker != null ? streamTracker.getContentRepDetector(conversationId) : null);
|
||||
final RepetitionDetector thinkingRepDetector =
|
||||
pickDetector(streamTracker != null ? streamTracker.getThinkingRepDetector(conversationId) : null);
|
||||
// 重复检测触发后设为 true,外层轮询线程据此 dispose 订阅
|
||||
AtomicBoolean repetitionTriggered = new AtomicBoolean(false);
|
||||
|
||||
// Lifecycle events emitted at most once per call so consumers can
|
||||
// pivot the UI between "thinking" and "drafting" without inspecting
|
||||
// delta rates.
|
||||
AtomicBoolean thinkingStartEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean thinkingEndEmitted = new AtomicBoolean(false);
|
||||
AtomicBoolean firstTokenSignaled = new AtomicBoolean(false);
|
||||
|
||||
// Pre-stream lifecycle: tell the front-end how the prompt was sized
|
||||
// and which provider/model is being asked. These events ride on the
|
||||
// existing SSE bus, so the heartbeat / first_token signaling stays
|
||||
// consistent.
|
||||
if (broadcast && streamTracker != null && conversationId != null && !conversationId.isEmpty()) {
|
||||
int messageCount = prompt.getInstructions() != null ? prompt.getInstructions().size() : 0;
|
||||
int contextChars = approximatePromptChars(prompt);
|
||||
streamTracker.broadcastObject(conversationId, "context_prepared", Map.of(
|
||||
"messages", messageCount,
|
||||
"contextChars", contextChars,
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
String modelId = identifyModel(chatModel);
|
||||
String providerId = primaryProviderId != null ? primaryProviderId : "";
|
||||
streamTracker.broadcastObject(conversationId, "llm_request_sent", Map.of(
|
||||
"provider", providerId,
|
||||
"model", modelId != null ? modelId : "",
|
||||
"phase", phase != null ? phase : "",
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
Disposable subscription = chatModel.stream(prompt)
|
||||
@ -729,9 +764,25 @@ public class NodeStreamingChatHelper {
|
||||
if (contentRepDetector.appendAndCheck(contentDelta)) {
|
||||
log.warn("[{}] Content repetition detected, will cancel stream " +
|
||||
"for conversation {}", phase, conversationId);
|
||||
broadcastContentTruncated(conversationId,
|
||||
contentRepDetector.lastTriggerReason(),
|
||||
contentAccum.length());
|
||||
repetitionTriggered.set(true);
|
||||
return;
|
||||
}
|
||||
// First content delta closes the thinking phase if one
|
||||
// was open, and arms first-token heartbeat relaxation.
|
||||
if (broadcast && streamTracker != null
|
||||
&& firstTokenSignaled.compareAndSet(false, true)) {
|
||||
streamTracker.markFirstTokenReceived(conversationId);
|
||||
}
|
||||
if (broadcast && thinkingAccum.length() > 0
|
||||
&& thinkingEndEmitted.compareAndSet(false, true)) {
|
||||
streamTracker.broadcastObject(conversationId, "thinking_end", Map.of(
|
||||
"thinkingChars", thinkingAccum.length(),
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
contentAccum.append(contentDelta);
|
||||
if (broadcast) {
|
||||
broadcastDelta(conversationId, "content_delta", contentDelta);
|
||||
@ -744,9 +795,29 @@ public class NodeStreamingChatHelper {
|
||||
if (thinkingRepDetector.appendAndCheck(thinkingDelta)) {
|
||||
log.warn("[{}] Thinking repetition detected, will cancel stream " +
|
||||
"for conversation {}", phase, conversationId);
|
||||
broadcastContentTruncated(conversationId,
|
||||
thinkingRepDetector.lastTriggerReason(),
|
||||
thinkingAccum.length());
|
||||
repetitionTriggered.set(true);
|
||||
return;
|
||||
}
|
||||
// First-token signaling fires for thinking too — UI
|
||||
// shows "thinking" activity before any content streams.
|
||||
if (broadcast && streamTracker != null
|
||||
&& firstTokenSignaled.compareAndSet(false, true)) {
|
||||
streamTracker.markFirstTokenReceived(conversationId);
|
||||
}
|
||||
// First thinking delta opens the thinking phase. We
|
||||
// emit the start lazily (on first delta) rather than
|
||||
// before subscription so models that never produce
|
||||
// thinking don't ghost-pair an empty segment.
|
||||
if (broadcast && thinkingAccum.length() == 0
|
||||
&& thinkingStartEmitted.compareAndSet(false, true)) {
|
||||
streamTracker.broadcastObject(conversationId, "thinking_start", Map.of(
|
||||
"phase", phase != null ? phase : "",
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
thinkingAccum.append(thinkingDelta);
|
||||
// thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示)
|
||||
boolean suppressThinking = "off".equalsIgnoreCase(
|
||||
@ -1411,6 +1482,79 @@ public class NodeStreamingChatHelper {
|
||||
streamTracker.broadcast(conversationId, eventName, json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a {@code content_truncated} lifecycle event so consumers can
|
||||
* surface why the stream stopped early. {@code reason} is "char_pattern"
|
||||
* or "sentence_repetition" depending on which detector fired.
|
||||
*/
|
||||
private void broadcastContentTruncated(String conversationId, String reason, int truncatedChars) {
|
||||
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
streamTracker.broadcastObject(conversationId, "content_truncated", Map.of(
|
||||
"reason", reason != null ? reason : "char_pattern",
|
||||
"truncatedChars", truncatedChars,
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to broadcast content_truncated for {}: {}", conversationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a non-null {@link RepetitionDetector}. Returns the supplied
|
||||
* detector when present; otherwise creates a fresh per-call instance so
|
||||
* mocked trackers (Mockito returns null for unstubbed methods) and
|
||||
* legacy code paths never trip a NullPointerException.
|
||||
*/
|
||||
private static RepetitionDetector pickDetector(RepetitionDetector candidate) {
|
||||
return candidate != null ? candidate : new RepetitionDetector();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort character count of the outbound prompt for the
|
||||
* {@code context_prepared} event. Cheaper than tokenizing and only used
|
||||
* for UI presentation, so an exact figure is unnecessary.
|
||||
*/
|
||||
private static int approximatePromptChars(Prompt prompt) {
|
||||
if (prompt == null || prompt.getInstructions() == null) return 0;
|
||||
int total = 0;
|
||||
for (Message m : prompt.getInstructions()) {
|
||||
String text = m.getText();
|
||||
if (text != null) total += text.length();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a stable model identifier from whatever {@link ChatModel}
|
||||
* implementation we received — Spring AI doesn't expose a single accessor.
|
||||
* We try the well-known fields by reflection so this stays decoupled from
|
||||
* concrete provider classes (Anthropic / OpenAI / DashScope all expose
|
||||
* {@code defaultOptions.model} or equivalent).
|
||||
*/
|
||||
private static String identifyModel(ChatModel chatModel) {
|
||||
if (chatModel == null) return "";
|
||||
try {
|
||||
// Common Spring AI shape: getDefaultOptions().getModel()
|
||||
java.lang.reflect.Method getDefaultOptions = chatModel.getClass().getMethod("getDefaultOptions");
|
||||
Object opts = getDefaultOptions.invoke(chatModel);
|
||||
if (opts != null) {
|
||||
try {
|
||||
java.lang.reflect.Method getModel = opts.getClass().getMethod("getModel");
|
||||
Object model = getModel.invoke(opts);
|
||||
if (model != null) return model.toString();
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fall through to class-name fallback
|
||||
}
|
||||
return chatModel.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 {"delta":"..."} JSON
|
||||
*/
|
||||
|
||||
@ -2,14 +2,28 @@ package vip.mate.agent.graph;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 流式输出重复检测器
|
||||
* <p>
|
||||
* 检测 LLM 流式输出中的退化重复模式(degenerate repetition),
|
||||
* 当检测到内容在滑动窗口内高度重复时返回 true,调用方应截断 LLM 流。
|
||||
* <p>
|
||||
* 算法:维护一个滑动窗口缓冲区,每次追加新 delta 后,
|
||||
* 检查窗口尾部是否存在连续重复的 n-gram 模式。
|
||||
* Two complementary detection paths run on every {@link #appendAndCheck}:
|
||||
* <ol>
|
||||
* <li>Character-level n-gram repetition (continuous "X X X X" loops),
|
||||
* which catches the classic degenerate-output failure within a single
|
||||
* LLM stream.</li>
|
||||
* <li>Sentence-level Jaccard similarity over the trailing N sentences,
|
||||
* which catches "two near-identical sentences ~5 sentences apart" —
|
||||
* a softer failure that the character path misses because the loop is
|
||||
* not adjacent.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@ -31,8 +45,42 @@ public class RepetitionDetector {
|
||||
/** 已累积内容的最小长度才开始检测(避免误判短内容) */
|
||||
private static final int MIN_CONTENT_LEN = 200;
|
||||
|
||||
// ===== Sentence-level detection =====
|
||||
|
||||
/**
|
||||
* Number of trailing sentences to compare against the prior window. 3
|
||||
* tail sentences plus a 10-sentence lookback is enough to catch "ABC
|
||||
* (other) ABC (other)" duplication while keeping the cost bounded.
|
||||
*/
|
||||
private static final int SENTENCE_TAIL_COUNT = 3;
|
||||
private static final int SENTENCE_LOOKBACK = 10;
|
||||
private static final double JACCARD_THRESHOLD = 0.85;
|
||||
/**
|
||||
* Lower bound on the per-sentence token count. Below this both the
|
||||
* tail and the historical sentence are too short to make Jaccard
|
||||
* meaningful (single phrases like "好的。" would otherwise false-positive).
|
||||
*/
|
||||
private static final int SENTENCE_MIN_TOKENS = 6;
|
||||
/**
|
||||
* Buffer must hold at least this many characters before sentence-level
|
||||
* detection runs — keeps the cost off the early hot path.
|
||||
*/
|
||||
private static final int SENTENCE_MIN_BUFFER = 1500;
|
||||
/**
|
||||
* Sentence delimiters: full-width Chinese punctuation plus ASCII end-of
|
||||
* -sentence punctuation and newline. Splitting greedily on any of these
|
||||
* is good enough for Jaccard's set-of-tokens semantics.
|
||||
*/
|
||||
private static final Pattern SENTENCE_SPLIT =
|
||||
Pattern.compile("[\\u3002\\uff01\\uff1f.!?\\n]+");
|
||||
|
||||
private final StringBuilder buffer = new StringBuilder();
|
||||
private boolean repetitionDetected = false;
|
||||
/**
|
||||
* Marks repetition detected via the sentence path so callers can tell
|
||||
* "char_pattern" from "sentence_repetition" in their warning broadcasts.
|
||||
*/
|
||||
private boolean lastTriggerWasSentence = false;
|
||||
|
||||
/**
|
||||
* 追加新的 delta 并检测是否存在重复
|
||||
@ -52,9 +100,13 @@ public class RepetitionDetector {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保持窗口大小
|
||||
if (buffer.length() > WINDOW_SIZE * 2) {
|
||||
buffer.delete(0, buffer.length() - WINDOW_SIZE);
|
||||
// Window trim policy: the char-level path only needs the last ~1024
|
||||
// chars, but the sentence path benefits from a larger horizon so it
|
||||
// can compare against the full 10-sentence lookback. Keep up to
|
||||
// SENTENCE_MIN_BUFFER * 2 so the sentence detector has room.
|
||||
int retainCap = Math.max(WINDOW_SIZE, SENTENCE_MIN_BUFFER * 2);
|
||||
if (buffer.length() > retainCap * 2) {
|
||||
buffer.delete(0, buffer.length() - retainCap);
|
||||
}
|
||||
|
||||
// 在窗口尾部检测重复模式
|
||||
@ -89,6 +141,7 @@ public class RepetitionDetector {
|
||||
}
|
||||
|
||||
repetitionDetected = true;
|
||||
lastTriggerWasSentence = false;
|
||||
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
|
||||
"pattern length={}, repeats={}, pattern preview=\"{}\"",
|
||||
patternLen, count,
|
||||
@ -97,9 +150,139 @@ public class RepetitionDetector {
|
||||
}
|
||||
}
|
||||
|
||||
// Sentence-level path: cheap to skip until the buffer is long enough
|
||||
// to actually contain multiple sentences worth comparing.
|
||||
if (buffer.length() >= SENTENCE_MIN_BUFFER && checkSentenceRepetition(window)) {
|
||||
repetitionDetected = true;
|
||||
lastTriggerWasSentence = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when one of the trailing {@link #SENTENCE_TAIL_COUNT}
|
||||
* sentences is near-duplicate to one of the previous {@link #SENTENCE_LOOKBACK}
|
||||
* sentences (Jaccard over token unigram sets). Both candidates must clear
|
||||
* {@link #SENTENCE_MIN_TOKENS} so we don't false-positive on common short
|
||||
* acknowledgements.
|
||||
*/
|
||||
private boolean checkSentenceRepetition(String window) {
|
||||
String[] split = SENTENCE_SPLIT.split(window);
|
||||
// Drop trailing whitespace-only segments produced by the splitter.
|
||||
List<String> sentences = new ArrayList<>(split.length);
|
||||
for (String s : split) {
|
||||
String trimmed = s.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
sentences.add(trimmed);
|
||||
}
|
||||
}
|
||||
if (sentences.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int total = sentences.size();
|
||||
int tailStart = Math.max(0, total - SENTENCE_TAIL_COUNT);
|
||||
int lookbackStart = Math.max(0, tailStart - SENTENCE_LOOKBACK);
|
||||
|
||||
for (int i = tailStart; i < total; i++) {
|
||||
Set<String> tailTokens = tokenize(sentences.get(i));
|
||||
if (tailTokens.size() < SENTENCE_MIN_TOKENS) continue;
|
||||
|
||||
for (int j = lookbackStart; j < tailStart; j++) {
|
||||
Set<String> earlierTokens = tokenize(sentences.get(j));
|
||||
if (earlierTokens.size() < SENTENCE_MIN_TOKENS) continue;
|
||||
|
||||
double jaccard = jaccard(tailTokens, earlierTokens);
|
||||
if (jaccard >= JACCARD_THRESHOLD) {
|
||||
String preview = sentences.get(i);
|
||||
log.warn("[RepetitionDetector] Sentence-level repetition: " +
|
||||
"tailIdx={}, earlierIdx={}, jaccard={}, preview=\"{}\"",
|
||||
i, j, String.format("%.2f", jaccard),
|
||||
preview.length() > 60 ? preview.substring(0, 60) + "..." : preview);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap tokenizer that treats each Chinese char as its own token and
|
||||
* splits ASCII / European text on whitespace. Producing token sets keeps
|
||||
* Jaccard symmetric on lengths, which is the property we rely on.
|
||||
*/
|
||||
private static Set<String> tokenize(String sentence) {
|
||||
Set<String> tokens = new HashSet<>();
|
||||
StringBuilder asciiWord = new StringBuilder();
|
||||
for (int i = 0; i < sentence.length(); i++) {
|
||||
char c = sentence.charAt(i);
|
||||
if (isCjk(c)) {
|
||||
if (asciiWord.length() > 0) {
|
||||
tokens.add(asciiWord.toString().toLowerCase());
|
||||
asciiWord.setLength(0);
|
||||
}
|
||||
tokens.add(String.valueOf(c));
|
||||
} else if (Character.isLetterOrDigit(c)) {
|
||||
asciiWord.append(c);
|
||||
} else {
|
||||
if (asciiWord.length() > 0) {
|
||||
tokens.add(asciiWord.toString().toLowerCase());
|
||||
asciiWord.setLength(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (asciiWord.length() > 0) {
|
||||
tokens.add(asciiWord.toString().toLowerCase());
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static boolean isCjk(char c) {
|
||||
return (c >= 0x4E00 && c <= 0x9FFF)
|
||||
|| (c >= 0x3400 && c <= 0x4DBF)
|
||||
|| (c >= 0xF900 && c <= 0xFAFF);
|
||||
}
|
||||
|
||||
private static double jaccard(Set<String> a, Set<String> b) {
|
||||
if (a.isEmpty() && b.isEmpty()) return 1.0;
|
||||
int intersect = 0;
|
||||
Set<String> smaller = a.size() <= b.size() ? a : b;
|
||||
Set<String> larger = smaller == a ? b : a;
|
||||
for (String t : smaller) {
|
||||
if (larger.contains(t)) intersect++;
|
||||
}
|
||||
int union = a.size() + b.size() - intersect;
|
||||
return union == 0 ? 0.0 : (double) intersect / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a logical iteration boundary. Buffer is intentionally NOT cleared
|
||||
* — sentence-level detection across LLM call boundaries is the whole
|
||||
* reason this detector lives on the conversation, not on a single call.
|
||||
* The debug log lets fixtures observe that the boundary signal arrived.
|
||||
*/
|
||||
public void markIterationBoundary() {
|
||||
log.debug("[RepetitionDetector] iteration boundary marked (buffer chars={})",
|
||||
buffer.length());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns "sentence_repetition" when the most recent trigger was the
|
||||
* Jaccard path; otherwise "char_pattern". Useful for warning broadcasts
|
||||
* that need to differentiate the two failure modes.
|
||||
*/
|
||||
public String lastTriggerReason() {
|
||||
if (!repetitionDetected) return null;
|
||||
return lastTriggerWasSentence ? "sentence_repetition" : "char_pattern";
|
||||
}
|
||||
|
||||
/** Number of characters currently held in the detector's buffer. */
|
||||
public int bufferLength() {
|
||||
return buffer.length();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 pattern 是否为装饰性字符(不应判定为退化重复)。
|
||||
* <p>
|
||||
@ -146,6 +329,7 @@ public class RepetitionDetector {
|
||||
public void reset() {
|
||||
buffer.setLength(0);
|
||||
repetitionDetected = false;
|
||||
lastTriggerWasSentence = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -164,6 +164,27 @@ public class ToolExecutionExecutor {
|
||||
this.skillRuntimeService = s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional audit sink. When set, child-agent denied-tool attempts are
|
||||
* recorded so admins can see what children are trying that gets blocked.
|
||||
* Left optional because legacy constructors and tests run without an
|
||||
* audit pipeline.
|
||||
*/
|
||||
private vip.mate.audit.service.AuditEventService auditEventService;
|
||||
|
||||
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
|
||||
this.auditEventService = s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn deduplication key set for child-agent denial audit. Without
|
||||
* this, a child that retries the same denied tool many times in one
|
||||
* turn would write one audit row per call. Cleared at the start of
|
||||
* every {@code execute(...)} so it does not retain entries across turns.
|
||||
*/
|
||||
private final ThreadLocal<Set<String>> auditedDenials =
|
||||
ThreadLocal.withInitial(HashSet::new);
|
||||
|
||||
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) {
|
||||
this(toolSet, toolGuardService, null, approvalService, streamTracker, null, null, null);
|
||||
@ -284,6 +305,10 @@ public class ToolExecutionExecutor {
|
||||
String workspaceBasePath,
|
||||
ChatOrigin origin) {
|
||||
ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
// Reset per-turn audit dedupe state. A retried denied tool inside the
|
||||
// same turn writes a single audit row; the set is repopulated by the
|
||||
// denial branch below.
|
||||
auditedDenials.get().clear();
|
||||
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
||||
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
||||
// RFC-052: accumulate full-text outputs from returnDirect tools so the
|
||||
@ -329,6 +354,22 @@ public class ToolExecutionExecutor {
|
||||
if (denied.contains(toolName)) {
|
||||
String msg = "[安全限制] 子 Agent 不允许使用工具: " + toolName;
|
||||
log.info("[ToolExecutor] Child agent blocked from using tool: {}", toolName);
|
||||
// Audit per (toolName, conversationId) tuple at most once
|
||||
// per turn so a child retrying the same denied tool many
|
||||
// times does not spam the audit table.
|
||||
if (auditEventService != null && auditedDenials.get().add(toolName)) {
|
||||
try {
|
||||
String detail = "{\"toolName\":\"" + toolName + "\",\"conversationId\":\""
|
||||
+ (conversationId != null ? conversationId : "")
|
||||
+ "\",\"agentId\":\"" + (agentId != null ? agentId : "")
|
||||
+ "\"}";
|
||||
auditEventService.record("subagent.tool.denied", "tool",
|
||||
toolName, toolName, detail);
|
||||
} catch (Exception auditEx) {
|
||||
log.debug("[ToolExecutor] Audit write failed for denied tool {}: {}",
|
||||
toolName, auditEx.getMessage());
|
||||
}
|
||||
}
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, msg));
|
||||
|
||||
@ -4,6 +4,7 @@ import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import com.alibaba.cloud.ai.graph.action.NodeAction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.observation.ObservationProcessor;
|
||||
import vip.mate.agent.graph.state.MateClawStateAccessor;
|
||||
|
||||
@ -120,6 +121,17 @@ public class ObservationNode implements NodeAction {
|
||||
.shouldSummarize(shouldSummarize)
|
||||
.toolCallCount(newToolCallCount);
|
||||
|
||||
// Close out the iteration we just observed. We use currentIteration
|
||||
// (not nextIteration) so the index pairs with whatever
|
||||
// iteration_start the ReasoningNode emitted at the top of this turn.
|
||||
// Char totals are best-effort: ObservationNode doesn't see the LLM
|
||||
// delta stream directly, so 0/0 is acceptable for now — consumers
|
||||
// that care fall back to summing the deltas themselves.
|
||||
if (streamTracker == null || streamTracker.isIterationEventsEnabled()) {
|
||||
builder.events(List.of(
|
||||
GraphEventPublisher.iterationEnd(currentIteration, "parent", null, 0, 0)));
|
||||
}
|
||||
|
||||
// 重复观察时标记错误,让 ObservationDispatcher 路由到 limitExceededNode
|
||||
if (duplicateObservation) {
|
||||
builder.put(ERROR, "连续 3 次工具调用返回相同结果,已强制终止循环");
|
||||
|
||||
@ -364,6 +364,17 @@ public class ReasoningNode implements NodeAction {
|
||||
|
||||
GraphEventPublisher.GraphEvent phaseEvent = GraphEventPublisher.phase("reasoning",
|
||||
Map.of("iteration", accessor.iterationCount()));
|
||||
// Iteration boundary marker for the parent ReAct loop. Reason
|
||||
// distinguishes the very first turn of the conversation from a
|
||||
// mid-loop repeat for consumers grouping events into per-turn cards.
|
||||
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
||||
GraphEventPublisher.GraphEvent iterStartEvent = iterationEventsOn
|
||||
? GraphEventPublisher.iterationStart(
|
||||
accessor.iterationCount(),
|
||||
accessor.iterationCount() == 0 ? "first_turn" : "react_step",
|
||||
"parent",
|
||||
null)
|
||||
: null;
|
||||
pushPhase(conversationId, "reasoning", Map.of(
|
||||
"iteration", accessor.iterationCount(),
|
||||
"llmCallCount", nextLlmCallCount
|
||||
@ -481,7 +492,7 @@ public class ReasoningNode implements NodeAction {
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(List.of(phaseEvent))
|
||||
.events(buildEvents(phaseEvent, iterStartEvent))
|
||||
.build();
|
||||
} else {
|
||||
String content = result.text();
|
||||
@ -491,6 +502,14 @@ public class ReasoningNode implements NodeAction {
|
||||
"answerChars", content != null ? content.length() : 0
|
||||
));
|
||||
|
||||
// Final-answer path: iteration ends in this same node because
|
||||
// ReAct never re-enters the loop afterwards.
|
||||
GraphEventPublisher.GraphEvent iterEndEvent = iterationEventsOn
|
||||
? GraphEventPublisher.iterationEnd(accessor.iterationCount(),
|
||||
"parent", null,
|
||||
content != null ? content.length() : 0,
|
||||
result.thinking() != null ? result.thinking().length() : 0)
|
||||
: null;
|
||||
return MateClawStateAccessor.output()
|
||||
.needsToolCall(false)
|
||||
.shouldSummarize(false)
|
||||
@ -502,7 +521,7 @@ public class ReasoningNode implements NodeAction {
|
||||
.thinkingStreamed(!result.thinking().isEmpty())
|
||||
.llmCallCount(nextLlmCallCount)
|
||||
.mergeUsage(state, result)
|
||||
.events(List.of(phaseEvent))
|
||||
.events(buildEvents(phaseEvent, iterStartEvent, iterEndEvent))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -523,6 +542,19 @@ public class ReasoningNode implements NodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the per-call event list, dropping any null entries so the
|
||||
* iteration-boundary toggle ({@code mateclaw.stream.iteration-events})
|
||||
* works without forcing every caller into branching code.
|
||||
*/
|
||||
private static List<GraphEventPublisher.GraphEvent> buildEvents(GraphEventPublisher.GraphEvent... events) {
|
||||
List<GraphEventPublisher.GraphEvent> out = new ArrayList<>(events.length);
|
||||
for (GraphEventPublisher.GraphEvent ev : events) {
|
||||
if (ev != null) out.add(ev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void pushPhase(String conversationId, String phase, Map<String, Object> extra) {
|
||||
if (streamTracker == null || !StringUtils.hasText(conversationId)) {
|
||||
return;
|
||||
|
||||
@ -146,6 +146,15 @@ public class StepExecutionNode implements NodeAction {
|
||||
log.info("[StepExecution] Executing step {}/{}: {}", stepIndex + 1, steps.size(), step);
|
||||
|
||||
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
|
||||
// Iteration boundary for the plan-execute loop: each step is one
|
||||
// iteration that may itself fan out to multiple LLM calls. Reason is
|
||||
// "plan_step" so consumers can distinguish it from ReAct's
|
||||
// "react_step" / "first_turn" markers when both stream into the
|
||||
// same SSE feed.
|
||||
boolean iterationEventsOn = streamTracker == null || streamTracker.isIterationEventsEnabled();
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationStart(stepIndex, "plan_step", "parent", null));
|
||||
}
|
||||
events.add(GraphEventPublisher.stepStarted(stepIndex, step));
|
||||
events.add(GraphEventPublisher.phase("executing", Map.of("stepIndex", stepIndex, "stepTitle", step)));
|
||||
|
||||
@ -344,6 +353,10 @@ public class StepExecutionNode implements NodeAction {
|
||||
"Plan completed via returnDirect tool: " +
|
||||
stepDirectOutputs.get(0).toolName());
|
||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, assembled));
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||
assembled != null ? assembled.length() : 0, 0));
|
||||
}
|
||||
return PlanStateAccessor.output()
|
||||
.currentStepResult(assembled)
|
||||
.currentStepIndex(steps.size()) // 越界 → dispatcher 收束
|
||||
@ -376,6 +389,10 @@ public class StepExecutionNode implements NodeAction {
|
||||
planningService.updateSubPlanFailure(planId, stepIndex, shortError);
|
||||
planningService.markPlanFailed(planId, "步骤" + (stepIndex + 1) + " 执行失败:" + shortError);
|
||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, shortError));
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||
shortError != null ? shortError.length() : 0, 0));
|
||||
}
|
||||
return PlanStateAccessor.output()
|
||||
.currentStepResult(shortError)
|
||||
.currentPhase("plan_aborted")
|
||||
@ -388,6 +405,11 @@ public class StepExecutionNode implements NodeAction {
|
||||
|
||||
planningService.updateSubPlanResult(planId, stepIndex, finalResult);
|
||||
events.add(GraphEventPublisher.stepCompleted(stepIndex, finalResult));
|
||||
if (iterationEventsOn) {
|
||||
events.add(GraphEventPublisher.iterationEnd(stepIndex, "parent", null,
|
||||
finalResult != null ? finalResult.length() : 0,
|
||||
stepThinking != null ? stepThinking.length() : 0));
|
||||
}
|
||||
|
||||
log.info("[StepExecution] Step {}/{} completed: {}",
|
||||
stepIndex + 1, steps.size(),
|
||||
|
||||
@ -448,6 +448,19 @@ public class ChatController {
|
||||
registerEmitterCallbacks(emitter, conversationId);
|
||||
streamTracker.attach(conversationId, emitter);
|
||||
|
||||
// Per-emitter "the SSE channel is open and you should reset any
|
||||
// pending placeholder UI". Sent directly to the emitter rather than
|
||||
// broadcast so reconnecting subscribers don't see a duplicate marker
|
||||
// for an already-open conversation.
|
||||
try {
|
||||
sendEvent(emitter, "stream_started", Map.of(
|
||||
"conversationId", conversationId,
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to send stream_started event for {}: {}", conversationId, e.getMessage());
|
||||
}
|
||||
|
||||
// 标记 emitter 是否已结束,防止 Flux 回调再次写入已关闭的 emitter
|
||||
AtomicBoolean emitterDone = new AtomicBoolean(false);
|
||||
|
||||
|
||||
@ -3,9 +3,11 @@ package vip.mate.channel.web;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import reactor.core.Disposable;
|
||||
import vip.mate.agent.graph.RepetitionDetector;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -57,10 +59,71 @@ public class ChatStreamTracker {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Maximum size, in bytes, of a single SSE event JSON payload before
|
||||
* {@link #broadcastChunked} splits the body into ordered
|
||||
* {@code tool_result_chunk} events.
|
||||
*/
|
||||
static final int CHUNK_SIZE = 8192;
|
||||
|
||||
// ===== Configurable knobs (mateclaw.stream.*) =====
|
||||
|
||||
/**
|
||||
* Gate for chunked tool-result transport. When {@code false},
|
||||
* {@link #broadcastChunked} falls back to a single broadcast call so
|
||||
* environments that prefer the legacy single-event behavior can opt out.
|
||||
*/
|
||||
@Value("${mateclaw.stream.chunked-tool-results:true}")
|
||||
private boolean chunkedToolResultsEnabled = true;
|
||||
|
||||
/**
|
||||
* Gate for {@code iteration_start} / {@code iteration_end} events emitted
|
||||
* from graph nodes. Off-by-default deployments can suppress them without
|
||||
* touching node code.
|
||||
*/
|
||||
@Value("${mateclaw.stream.iteration-events:true}")
|
||||
private boolean iterationEventsEnabled = true;
|
||||
|
||||
/**
|
||||
* Heartbeat cadence (seconds) before the first model token arrives. Short
|
||||
* because pre-token gaps strand the UI on a blank "正在生成中" placeholder
|
||||
* with no visible activity.
|
||||
*/
|
||||
@Value("${mateclaw.stream.heartbeat.pre-token-sec:2}")
|
||||
private int heartbeatPreTokenSec = 2;
|
||||
|
||||
/**
|
||||
* Heartbeat cadence (seconds) once the model is actively streaming tokens —
|
||||
* deltas themselves keep the connection warm, so heartbeats relax.
|
||||
*/
|
||||
@Value("${mateclaw.stream.heartbeat.streaming-sec:10}")
|
||||
private int heartbeatStreamingSec = 10;
|
||||
|
||||
/**
|
||||
* Heartbeat cadence (seconds) while a tool call is in flight. Tools can
|
||||
* take longer than streaming chunks but should still tick faster than the
|
||||
* default proxy idle timeout.
|
||||
*/
|
||||
@Value("${mateclaw.stream.heartbeat.tool-sec:5}")
|
||||
private int heartbeatToolSec = 5;
|
||||
|
||||
public ChatStreamTracker(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** Test-only setters; production paths use Spring property binding. */
|
||||
void setChunkedToolResultsEnabled(boolean enabled) {
|
||||
this.chunkedToolResultsEnabled = enabled;
|
||||
}
|
||||
|
||||
void setIterationEventsEnabled(boolean enabled) {
|
||||
this.iterationEventsEnabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isIterationEventsEnabled() {
|
||||
return iterationEventsEnabled;
|
||||
}
|
||||
|
||||
record SseEvent(String name, String json) {}
|
||||
|
||||
/**
|
||||
@ -121,12 +184,44 @@ public class ChatStreamTracker {
|
||||
/** 心跳定时器 */
|
||||
volatile ScheduledFuture<?> heartbeatFuture;
|
||||
|
||||
/**
|
||||
* Flips the first time any content/thinking delta is observed for this
|
||||
* run. Heartbeat scheduling watches this flag to switch from the short
|
||||
* pre-token cadence to the streaming cadence — pre-token gaps need
|
||||
* frequent keep-alives because the UI has no other signal of activity.
|
||||
*/
|
||||
volatile boolean firstTokenReceived = false;
|
||||
|
||||
/**
|
||||
* Cross-call repetition detectors scoped to the conversation, not the
|
||||
* single LLM call. Sharing across {@code streamLLMChat} invocations
|
||||
* lets the sentence-level path catch "the model produces N near-
|
||||
* identical sentences across two consecutive iterations" — a common
|
||||
* failure that the per-call detectors used to miss.
|
||||
*/
|
||||
volatile RepetitionDetector contentRepDetector = new RepetitionDetector();
|
||||
volatile RepetitionDetector thinkingRepDetector = new RepetitionDetector();
|
||||
|
||||
/** 已广播的 pending approval ID 集合(用于幂等去重) */
|
||||
final java.util.Set<String> broadcastedApprovalIds = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** 创建时间(用于 stale 检测和清理) */
|
||||
final long createdAt = System.currentTimeMillis();
|
||||
|
||||
/**
|
||||
* Wall-clock millis of the most recent meaningful event on this run.
|
||||
* Updated whenever {@link #broadcast(String, String, String)} pushes a
|
||||
* non-heartbeat event so a watchdog can tell "actively producing"
|
||||
* apart from "alive but silent".
|
||||
*/
|
||||
volatile long lastEventAt = System.currentTimeMillis();
|
||||
|
||||
/** Bound agent identifier; null while not yet resolved. */
|
||||
volatile Long agentId;
|
||||
|
||||
/** Username that owns this run; null for system-driven runs. */
|
||||
volatile String username;
|
||||
|
||||
RunState(String conversationId) {
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
@ -158,6 +253,168 @@ public class ChatStreamTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batching variant of {@link #addEventRelay} for sub-conversation streams
|
||||
* whose tool-call chatter would flood the parent transcript. Tool start /
|
||||
* complete events accumulate into a buffer; lifecycle and error events
|
||||
* (subagent_*, error, tool_approval_requested, phase, done) bypass the
|
||||
* buffer but flush it first so ordering is preserved.
|
||||
* <p>
|
||||
* Buffered events are emitted as a single {@code delegation_batch}
|
||||
* envelope on the parent conversation listener:
|
||||
* <pre>
|
||||
* {
|
||||
* "kind": "delegation_batch",
|
||||
* "scope": "subagent",
|
||||
* "events": [{ "event": "tool_call_started", "data": "<json>" }, ...]
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* @param sourceConversationId conversation to listen on
|
||||
* @param parentConversationId parent conversation context (currently
|
||||
* forwarded only as listener metadata; the
|
||||
* tracker itself does not target it)
|
||||
* @param batchSize flush threshold by event count
|
||||
* @param flushMs flush threshold by elapsed millis since
|
||||
* first buffered event
|
||||
* @return Runnable that deregisters the relay (and flushes any pending
|
||||
* events first)
|
||||
*/
|
||||
public Runnable addBatchedEventRelay(String sourceConversationId,
|
||||
String parentConversationId,
|
||||
int batchSize,
|
||||
long flushMs,
|
||||
java.util.function.BiConsumer<String, String> listener) {
|
||||
BatchedRelay relay = new BatchedRelay(parentConversationId, listener,
|
||||
Math.max(1, batchSize), Math.max(1, flushMs));
|
||||
java.util.function.BiConsumer<String, String> wrapper = relay::accept;
|
||||
eventRelays.computeIfAbsent(sourceConversationId,
|
||||
k -> new java.util.concurrent.CopyOnWriteArrayList<>())
|
||||
.add(wrapper);
|
||||
log.debug("Batched relay registered for conversation {} -> parent={}",
|
||||
sourceConversationId, parentConversationId);
|
||||
return () -> {
|
||||
relay.shutdown();
|
||||
List<java.util.function.BiConsumer<String, String>> listeners =
|
||||
eventRelays.get(sourceConversationId);
|
||||
if (listeners != null) {
|
||||
listeners.remove(wrapper);
|
||||
if (listeners.isEmpty()) {
|
||||
eventRelays.remove(sourceConversationId);
|
||||
}
|
||||
}
|
||||
log.debug("Batched relay removed for {} -> parent={}",
|
||||
sourceConversationId, parentConversationId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper holding the batch buffer and the scheduled flush. Each
|
||||
* relay owns its own state but reuses {@link #heartbeatScheduler} for
|
||||
* timer ticks (sharing the daemon-thread scheduler avoids one-thread-per
|
||||
* -relay sprawl in long agent sessions).
|
||||
*/
|
||||
private final class BatchedRelay {
|
||||
private final String parentConversationId;
|
||||
private final java.util.function.BiConsumer<String, String> downstream;
|
||||
private final int batchSize;
|
||||
private final long flushMs;
|
||||
private final List<Map<String, String>> buffer = new ArrayList<>();
|
||||
private final Object lock = new Object();
|
||||
private ScheduledFuture<?> pendingFlush;
|
||||
private volatile boolean closed;
|
||||
|
||||
BatchedRelay(String parentConversationId,
|
||||
java.util.function.BiConsumer<String, String> downstream,
|
||||
int batchSize, long flushMs) {
|
||||
this.parentConversationId = parentConversationId;
|
||||
this.downstream = downstream;
|
||||
this.batchSize = batchSize;
|
||||
this.flushMs = flushMs;
|
||||
}
|
||||
|
||||
void accept(String eventName, String json) {
|
||||
if (closed) return;
|
||||
// Pass-through (with prior flush to preserve ordering) for any
|
||||
// event that conveys lifecycle or critical state. Tool call
|
||||
// boundaries are the only batched class today; the explicit list
|
||||
// here is the source of truth.
|
||||
if (isPassThrough(eventName)) {
|
||||
flushNow();
|
||||
downstream.accept(eventName, json);
|
||||
return;
|
||||
}
|
||||
if (!"tool_call_started".equals(eventName)
|
||||
&& !"tool_call_completed".equals(eventName)) {
|
||||
downstream.accept(eventName, json);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean shouldFlush = false;
|
||||
synchronized (lock) {
|
||||
Map<String, String> entry = new java.util.LinkedHashMap<>();
|
||||
entry.put("event", eventName);
|
||||
entry.put("data", json);
|
||||
buffer.add(entry);
|
||||
if (buffer.size() >= batchSize) {
|
||||
shouldFlush = true;
|
||||
} else if (pendingFlush == null || pendingFlush.isDone()) {
|
||||
pendingFlush = heartbeatScheduler.schedule(this::flushNow,
|
||||
flushMs, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
if (shouldFlush) {
|
||||
flushNow();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPassThrough(String eventName) {
|
||||
return "subagent_start".equals(eventName)
|
||||
|| "subagent_complete".equals(eventName)
|
||||
|| "error".equals(eventName)
|
||||
|| "tool_approval_requested".equals(eventName)
|
||||
|| "phase".equals(eventName)
|
||||
|| "done".equals(eventName);
|
||||
}
|
||||
|
||||
void flushNow() {
|
||||
List<Map<String, String>> snapshot;
|
||||
synchronized (lock) {
|
||||
if (buffer.isEmpty()) {
|
||||
if (pendingFlush != null) {
|
||||
pendingFlush.cancel(false);
|
||||
pendingFlush = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
snapshot = new ArrayList<>(buffer);
|
||||
buffer.clear();
|
||||
if (pendingFlush != null) {
|
||||
pendingFlush.cancel(false);
|
||||
pendingFlush = null;
|
||||
}
|
||||
}
|
||||
Map<String, Object> envelope = new java.util.LinkedHashMap<>();
|
||||
envelope.put("kind", "delegation_batch");
|
||||
envelope.put("scope", "subagent");
|
||||
if (parentConversationId != null && !parentConversationId.isEmpty()) {
|
||||
envelope.put("parent", parentConversationId);
|
||||
}
|
||||
envelope.put("events", snapshot);
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(envelope);
|
||||
downstream.accept("delegation_batch", json);
|
||||
} catch (Exception e) {
|
||||
log.warn("Batched relay flush failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
void shutdown() {
|
||||
closed = true;
|
||||
flushNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** 心跳调度线程池(守护线程) */
|
||||
private final ScheduledExecutorService heartbeatScheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
@ -269,6 +526,13 @@ public class ChatStreamTracker {
|
||||
boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_");
|
||||
boolean isHeartbeat = "heartbeat".equals(eventName);
|
||||
|
||||
// Stamp last activity for stuck detection. Heartbeats are excluded
|
||||
// because they fire on a timer regardless of model progress; counting
|
||||
// them would mask a wedged turn behind a healthy timestamp.
|
||||
if (state != null && !isHeartbeat) {
|
||||
state.lastEventAt = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
if (isDone || isAsyncTask) {
|
||||
if (state == null) return;
|
||||
SseEvent ev = new SseEvent(eventName, jsonData);
|
||||
@ -377,6 +641,156 @@ public class ChatStreamTracker {
|
||||
broadcast(conversationId, eventName, json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast {@code payload} as a single SSE event when its serialized form
|
||||
* fits within {@link #CHUNK_SIZE}; otherwise extract the long {@code result}
|
||||
* field and emit it as ordered {@code tool_result_chunk} events.
|
||||
* <p>
|
||||
* Each chunk carries:
|
||||
* <pre>
|
||||
* {
|
||||
* "kind": "tool_result",
|
||||
* "scope": "parent", // sub-agent producers will set "subagent"
|
||||
* "ref": "<refKey>",
|
||||
* "seq": <0..N>,
|
||||
* "final": <true|false>,
|
||||
* "delta": "<text>"
|
||||
* }
|
||||
* </pre>
|
||||
* The last chunk has {@code "final": true}; consumers reassemble by
|
||||
* concatenating {@code delta} in seq order keyed on {@code ref}. When the
|
||||
* payload's {@code result} field cannot be located (or chunked transport
|
||||
* is disabled), the entire envelope is sent unchanged.
|
||||
*
|
||||
* @param conversationId target conversation
|
||||
* @param eventName SSE event name for the small-payload path
|
||||
* @param payload envelope; the {@code result} field (or, failing
|
||||
* that, the {@code arguments} field) is split
|
||||
* @param refKey identifier consumers use to group chunks; usually
|
||||
* {@code toolCallId} or the step index as a string
|
||||
*/
|
||||
public void broadcastChunked(String conversationId, String eventName,
|
||||
Object payload, String refKey) {
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize chunked broadcast for event {}: {}",
|
||||
eventName, e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chunkedToolResultsEnabled || json.length() <= CHUNK_SIZE) {
|
||||
broadcast(conversationId, eventName, json);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a long string field worth splitting; tool results live under
|
||||
// "result", approval payloads under "arguments". Falling back to the
|
||||
// whole envelope keeps the transport correct even for unknown shapes
|
||||
// (the consumer can still concatenate by ref+seq and decode itself).
|
||||
Map<String, Object> envelope = asMap(payload);
|
||||
String fieldKey = null;
|
||||
String longText = null;
|
||||
if (envelope != null) {
|
||||
Object resultField = envelope.get("result");
|
||||
Object argsField = envelope.get("arguments");
|
||||
if (resultField instanceof String s && s.length() > CHUNK_SIZE / 2) {
|
||||
fieldKey = "result";
|
||||
longText = s;
|
||||
} else if (argsField instanceof String s && s.length() > CHUNK_SIZE / 2) {
|
||||
fieldKey = "arguments";
|
||||
longText = s;
|
||||
}
|
||||
}
|
||||
|
||||
if (longText == null) {
|
||||
// No splittable string field — emit unchanged and let the client
|
||||
// handle the larger envelope as best it can.
|
||||
broadcast(conversationId, eventName, json);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Send a header event with the long field replaced by an empty
|
||||
// placeholder so consumers see the same envelope shape; the body
|
||||
// arrives via the chunk events that follow.
|
||||
Map<String, Object> headerEnvelope = new java.util.LinkedHashMap<>(envelope);
|
||||
headerEnvelope.put(fieldKey, "");
|
||||
headerEnvelope.put("chunked", true);
|
||||
headerEnvelope.put("chunkRef", refKey != null ? refKey : "");
|
||||
try {
|
||||
String headerJson = objectMapper.writeValueAsString(headerEnvelope);
|
||||
broadcast(conversationId, eventName, headerJson);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize chunk header for {}: {}", eventName, e.getMessage());
|
||||
broadcast(conversationId, eventName, json);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Stream the body in fixed-size slices.
|
||||
int total = longText.length();
|
||||
int offset = 0;
|
||||
int seq = 0;
|
||||
// Reserve room in CHUNK_SIZE for the JSON envelope around the slice;
|
||||
// 256 bytes covers kind/scope/ref/seq/final + JSON escapes.
|
||||
final int sliceMax = Math.max(512, CHUNK_SIZE - 256);
|
||||
while (offset < total) {
|
||||
int end = Math.min(offset + sliceMax, total);
|
||||
String slice = longText.substring(offset, end);
|
||||
boolean isFinal = end >= total;
|
||||
Map<String, Object> chunk = new java.util.LinkedHashMap<>();
|
||||
chunk.put("kind", "tool_result");
|
||||
chunk.put("scope", "parent");
|
||||
chunk.put("ref", refKey != null ? refKey : "");
|
||||
chunk.put("seq", seq);
|
||||
chunk.put("final", isFinal);
|
||||
chunk.put("delta", slice);
|
||||
try {
|
||||
String chunkJson = objectMapper.writeValueAsString(chunk);
|
||||
broadcast(conversationId, "tool_result_chunk", chunkJson);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize tool_result_chunk seq={}: {}", seq, e.getMessage());
|
||||
return;
|
||||
}
|
||||
offset = end;
|
||||
seq++;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> asMap(Object payload) {
|
||||
if (payload instanceof Map<?, ?> m) {
|
||||
try {
|
||||
return (Map<String, Object>) m;
|
||||
} catch (ClassCastException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation-scoped repetition detector for content deltas. Lazily
|
||||
* instantiated (a tracker created without a registered conversation
|
||||
* receives a fresh detector so callers never get null).
|
||||
*/
|
||||
public RepetitionDetector getContentRepDetector(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state == null) {
|
||||
return new RepetitionDetector();
|
||||
}
|
||||
return state.contentRepDetector;
|
||||
}
|
||||
|
||||
/** Conversation-scoped repetition detector for thinking deltas. */
|
||||
public RepetitionDetector getThinkingRepDetector(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state == null) {
|
||||
return new RepetitionDetector();
|
||||
}
|
||||
return state.thinkingRepDetector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic helper for the multi-node deployment edge case (issue #17):
|
||||
* tells the caller whether a {@link RunState} for this conversation
|
||||
@ -559,8 +973,18 @@ public class ChatStreamTracker {
|
||||
|
||||
// ===== Heartbeat =====
|
||||
|
||||
/** 心跳间隔(秒) */
|
||||
private static final int HEARTBEAT_INTERVAL_SEC = 10;
|
||||
/**
|
||||
* Pick the heartbeat cadence (seconds) that matches the run's current
|
||||
* phase. Pre-token gaps need fast keep-alives so the UI shows activity;
|
||||
* tool execution stretches slightly; mid-stream is rate-limited because
|
||||
* deltas already keep the connection warm.
|
||||
*/
|
||||
private int currentHeartbeatIntervalSec(RunState state) {
|
||||
if (state.runningToolName != null && !state.runningToolName.isEmpty()) {
|
||||
return heartbeatToolSec;
|
||||
}
|
||||
return state.firstTokenReceived ? heartbeatStreamingSec : heartbeatPreTokenSec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳定时器。在流注册后调用,定期向前端发送 heartbeat 事件。
|
||||
@ -572,6 +996,7 @@ public class ChatStreamTracker {
|
||||
// 避免重复启动
|
||||
if (state.heartbeatFuture != null && !state.heartbeatFuture.isDone()) return;
|
||||
|
||||
int intervalSec = currentHeartbeatIntervalSec(state);
|
||||
state.heartbeatFuture = heartbeatScheduler.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
RunState s = runs.get(conversationId);
|
||||
@ -605,7 +1030,38 @@ public class ChatStreamTracker {
|
||||
} catch (Exception e) {
|
||||
log.debug("Heartbeat error for {}: {}", conversationId, e.getMessage());
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_SEC, HEARTBEAT_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
}, intervalSec, intervalSec, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that the first content/thinking token has been received for this
|
||||
* run and reschedule the heartbeat at the streaming cadence.
|
||||
* <p>
|
||||
* Called from the LLM streaming layer so the heartbeat relaxes once the
|
||||
* connection is naturally being kept warm by data deltas. Idempotent — a
|
||||
* second call is a no-op.
|
||||
*/
|
||||
public void markFirstTokenReceived(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state == null) return;
|
||||
if (state.firstTokenReceived) return;
|
||||
state.firstTokenReceived = true;
|
||||
rescheduleHeartbeat(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the active heartbeat (if any) and starts a new one at the
|
||||
* cadence currently appropriate for the run state. Public so callers that
|
||||
* mutate {@code runningToolName} can request a tool-cadence heartbeat.
|
||||
*/
|
||||
public void rescheduleHeartbeat(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state == null || state.done) return;
|
||||
if (state.heartbeatFuture != null) {
|
||||
state.heartbeatFuture.cancel(false);
|
||||
state.heartbeatFuture = null;
|
||||
}
|
||||
startHeartbeat(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -637,10 +1093,39 @@ public class ChatStreamTracker {
|
||||
public void updateRunningTool(String conversationId, String toolName) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state != null) {
|
||||
String previous = state.runningToolName;
|
||||
state.runningToolName = toolName;
|
||||
// Heartbeat cadence depends on whether a tool is in flight; switch
|
||||
// cadences when the tool slot transitions in either direction.
|
||||
boolean wasRunning = previous != null && !previous.isEmpty();
|
||||
boolean nowRunning = toolName != null && !toolName.isEmpty();
|
||||
if (wasRunning != nowRunning) {
|
||||
rescheduleHeartbeat(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only accessor for the currently running tool name on a conversation.
|
||||
* Returns {@code null} when no run state exists or no tool is in flight.
|
||||
* Used by external observers (heartbeat watchdog, status APIs) that need
|
||||
* to probe progress without mutating the run.
|
||||
*/
|
||||
public String getRunningToolName(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
return state != null ? state.runningToolName : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only accessor for the current execution phase. Returns {@code null}
|
||||
* when no run state exists. Mirrors {@link #getRunningToolName(String)} so
|
||||
* external observers can read both fields without touching internals.
|
||||
*/
|
||||
public String getCurrentPhase(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
return state != null ? state.currentPhase : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置等待原因
|
||||
*/
|
||||
@ -1063,4 +1548,118 @@ public class ChatStreamTracker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Runtime snapshot surface (admin Backstage) =====
|
||||
|
||||
/**
|
||||
* Bind the resolved agent + owner to the active run so the runtime
|
||||
* snapshot can label cards without re-querying the conversation table.
|
||||
* Idempotent — overwrites are fine because both fields are observation-
|
||||
* only metadata.
|
||||
*/
|
||||
public void bindRunMeta(String conversationId, Long agentId, String username) {
|
||||
RunState s = runs.get(conversationId);
|
||||
if (s == null) return;
|
||||
if (agentId != null) s.agentId = agentId;
|
||||
if (username != null) s.username = username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable view of one in-flight run. Computed eagerly under the
|
||||
* RunState lock so the receiver sees a consistent picture even if the
|
||||
* underlying state mutates while it iterates.
|
||||
*/
|
||||
public record RunSnapshot(
|
||||
String conversationId,
|
||||
Long agentId,
|
||||
String username,
|
||||
String currentPhase,
|
||||
String runningToolName,
|
||||
String waitingReason,
|
||||
boolean done,
|
||||
boolean stopRequested,
|
||||
boolean firstTokenReceived,
|
||||
int subscriberCount,
|
||||
int queueLen,
|
||||
int activeFluxCount,
|
||||
long createdAt,
|
||||
long lastEventAt,
|
||||
long ageMs,
|
||||
long msSinceLastEvent
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Snapshot every active run. Used by the admin Backstage to render the
|
||||
* global "what are my agents doing right now" view. Returned list is a
|
||||
* defensive copy — callers may freely sort / filter it.
|
||||
*/
|
||||
public List<RunSnapshot> getAllSnapshot() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<RunSnapshot> out = new ArrayList<>(runs.size());
|
||||
for (RunState s : runs.values()) {
|
||||
int subs;
|
||||
int queue;
|
||||
synchronized (s.lock) {
|
||||
subs = s.subscribers.size();
|
||||
queue = s.messageQueue.size();
|
||||
}
|
||||
out.add(new RunSnapshot(
|
||||
s.conversationId,
|
||||
s.agentId,
|
||||
s.username,
|
||||
s.currentPhase,
|
||||
s.runningToolName,
|
||||
s.waitingReason,
|
||||
s.done,
|
||||
s.stopRequested.get(),
|
||||
s.firstTokenReceived,
|
||||
subs,
|
||||
queue,
|
||||
s.activeFluxCount,
|
||||
s.createdAt,
|
||||
s.lastEventAt,
|
||||
now - s.createdAt,
|
||||
now - s.lastEventAt
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a wedged run to terminate. Used by the admin Backstage's
|
||||
* "End it" action when the friendly stop has been observed not to take
|
||||
* effect (model wedged in a tool call beyond the timeout). Sequence
|
||||
* matches what {@link #onShutdown()} does for individual runs.
|
||||
*
|
||||
* @return true when a run was found and torn down; false if already gone
|
||||
*/
|
||||
public boolean forceRecycle(String conversationId) {
|
||||
RunState state = runs.get(conversationId);
|
||||
if (state == null) return false;
|
||||
try {
|
||||
state.stopRequested.set(true);
|
||||
state.interruptType = InterruptType.USER_STOP;
|
||||
Disposable d = state.disposable;
|
||||
if (d != null && !d.isDisposed()) {
|
||||
d.dispose();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("forceRecycle: dispose failed for {}: {}", conversationId, e.getMessage());
|
||||
}
|
||||
try {
|
||||
state.done = true;
|
||||
stopHeartbeat(conversationId);
|
||||
} catch (Exception e) {
|
||||
log.warn("forceRecycle: heartbeat stop failed for {}: {}", conversationId, e.getMessage());
|
||||
}
|
||||
synchronized (state.lock) {
|
||||
for (SseEmitter em : state.subscribers) {
|
||||
try { em.complete(); } catch (Exception ignored) {}
|
||||
}
|
||||
state.subscribers.clear();
|
||||
}
|
||||
runs.remove(conversationId);
|
||||
log.info("forceRecycle: run {} torn down", conversationId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,10 +10,13 @@ import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.agent.delegation.SubagentRegistry;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
@ -66,11 +69,35 @@ public class DelegateAgentTool {
|
||||
*/
|
||||
private static final int PARALLEL_TIMEOUT_SECONDS = 120;
|
||||
|
||||
/** Tools blocked for child agents — prevents recursion and side effects. */
|
||||
private static final Set<String> CHILD_DENIED_TOOLS = Set.of(
|
||||
"delegateToAgent", // no recursive serial delegation
|
||||
"delegateParallel", // no recursive parallel delegation
|
||||
"listAvailableAgents" // child agents do not need to discover other agents
|
||||
/**
|
||||
* Default deny list for child agents. Names are matched against the
|
||||
* canonical tool names exposed by the runtime, so they MUST mirror the
|
||||
* actual {@code @Tool}-annotated method names.
|
||||
*
|
||||
* <p>Categories:
|
||||
* <ul>
|
||||
* <li>Recursion guards (delegate*, listAvailableAgents) — prevent a
|
||||
* child from spawning another child or enumerating sibling agents.</li>
|
||||
* <li>Memory writers (remember, *_structured) — children must not
|
||||
* persist into the parent's shared MEMORY.md / SOUL.md surface;
|
||||
* the parent owns long-term memory.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code execute_shell_command} is intentionally NOT in the default
|
||||
* deny list because legitimate dev-tooling agents rely on shell access.
|
||||
* Operators that need a stricter posture can append it via
|
||||
* {@code mateclaw.delegation.child-denied-tools}.
|
||||
*/
|
||||
static final Set<String> DEFAULT_CHILD_DENIED_TOOLS = Set.of(
|
||||
// Recursion guards.
|
||||
"delegateToAgent",
|
||||
"delegateParallel",
|
||||
"listAvailableAgents",
|
||||
// Memory writes from children would pollute the parent's shared
|
||||
// long-term memory surface.
|
||||
"remember",
|
||||
"remember_structured",
|
||||
"forget_structured"
|
||||
);
|
||||
|
||||
/** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */
|
||||
@ -82,6 +109,36 @@ public class DelegateAgentTool {
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final ConversationService conversationService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SubagentRegistry subagentRegistry;
|
||||
private final AuditEventService auditEventService;
|
||||
|
||||
/**
|
||||
* Operator-supplied deny-list extension. Configured via
|
||||
* {@code mateclaw.delegation.child-denied-tools} as a comma-separated
|
||||
* list. Empty by default — the {@link #DEFAULT_CHILD_DENIED_TOOLS} set
|
||||
* already covers the recursion + memory cases that matter for safety.
|
||||
*/
|
||||
@Value("${mateclaw.delegation.child-denied-tools:}")
|
||||
private List<String> additionalDeniedTools;
|
||||
|
||||
/**
|
||||
* Effective deny list = defaults ∪ operator additions. Computed on each
|
||||
* delegation entry rather than cached because Spring applies
|
||||
* {@code @Value} after construction and we want operator overrides to
|
||||
* take effect on the next delegation, not on the next restart.
|
||||
*/
|
||||
Set<String> deniedToolsForChild() {
|
||||
if (additionalDeniedTools == null || additionalDeniedTools.isEmpty()) {
|
||||
return DEFAULT_CHILD_DENIED_TOOLS;
|
||||
}
|
||||
Set<String> merged = new HashSet<>(DEFAULT_CHILD_DENIED_TOOLS);
|
||||
for (String name : additionalDeniedTools) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
merged.add(name.trim());
|
||||
}
|
||||
}
|
||||
return Set.copyOf(merged);
|
||||
}
|
||||
|
||||
// ==================== Single-task delegation ====================
|
||||
|
||||
@ -126,6 +183,14 @@ public class DelegateAgentTool {
|
||||
}
|
||||
|
||||
String parentConversationId = resolveParentConversationId();
|
||||
|
||||
// Spawn-pause: when the operator paused this conversation's tree
|
||||
// (via /api/v1/subagents/spawn-pause), short-circuit before creating
|
||||
// child state so no conversation rows / relays / registry entries leak.
|
||||
if (parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) {
|
||||
return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause";
|
||||
}
|
||||
|
||||
String childConversationId = createChildConv(target, parentConversationId);
|
||||
|
||||
// RFC-03 Lane C2: optionally prepend a parent-context prefix to the task.
|
||||
@ -152,16 +217,38 @@ public class DelegateAgentTool {
|
||||
"childAgentName", target.getName(),
|
||||
"task", truncate(task, 200)));
|
||||
}
|
||||
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
|
||||
Runnable stopRelay = hasParent ? registerBatchedRelay(childConversationId, parentConversationId, target.getName()) : null;
|
||||
|
||||
// Register the live sub-agent so the operator UI / heartbeat watchdog
|
||||
// can observe it. Disposable is null in the synchronous single-task
|
||||
// path because the executor blocks on AgentService#chat directly —
|
||||
// there is no Flux subscription to dispose. Interrupts in this path
|
||||
// are best-effort (status flip; no underlying cancel).
|
||||
String subagentId = parentConversationId != null
|
||||
? subagentRegistry.register(parentConversationId, childConversationId,
|
||||
target.getId(), task, null)
|
||||
: null;
|
||||
|
||||
// Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent
|
||||
// ChatOrigin and only swap the agentId, so channel binding /
|
||||
// workspace / requester all flow into the child.
|
||||
ChatOrigin parentOrigin = ChatOrigin.from(ctx);
|
||||
ChildResult result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, parentOrigin);
|
||||
|
||||
// Cleanup relay, then broadcast final result
|
||||
if (stopRelay != null) stopRelay.run();
|
||||
ChildResult result;
|
||||
try {
|
||||
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, parentOrigin);
|
||||
} finally {
|
||||
// Cleanup relay + registry regardless of how the child returned
|
||||
// (success / exception / interruption) so we never leak entries.
|
||||
if (stopRelay != null) stopRelay.run();
|
||||
if (subagentId != null) {
|
||||
subagentRegistry.get(subagentId).ifPresent(rec -> {
|
||||
if ("running".equals(rec.status().get())) {
|
||||
rec.status().set("completed");
|
||||
}
|
||||
});
|
||||
subagentRegistry.unregister(subagentId);
|
||||
}
|
||||
}
|
||||
if (hasParent) {
|
||||
broadcastEnd(parentConversationId, childConversationId, target.getName(), result);
|
||||
}
|
||||
@ -204,10 +291,19 @@ public class DelegateAgentTool {
|
||||
}
|
||||
|
||||
String parentConversationId = resolveParentConversationId();
|
||||
|
||||
// Spawn-pause: short-circuit before allocating any per-child state so
|
||||
// we don't leak conversation rows / relays / registry entries when an
|
||||
// operator paused this conversation's tree.
|
||||
if (parentConversationId != null && subagentRegistry.isSpawnPaused(parentConversationId)) {
|
||||
return "[错误] Spawning paused for this conversation; resume via /api/v1/subagents/spawn-pause";
|
||||
}
|
||||
|
||||
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
|
||||
|
||||
// 2. Main thread: validate agents, create child conversations, register relays
|
||||
record PreparedChild(int index, AgentEntity agent, String task, String childConvId, Runnable stopRelay) {}
|
||||
record PreparedChild(int index, AgentEntity agent, String task, String childConvId,
|
||||
Runnable stopRelay, String subagentId) {}
|
||||
List<PreparedChild> prepared = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
@ -228,8 +324,14 @@ public class DelegateAgentTool {
|
||||
}
|
||||
|
||||
String childConvId = createChildConv(agent, parentConversationId);
|
||||
Runnable stopRelay = hasParent ? registerRelay(childConvId, parentConversationId, agent.getName()) : null;
|
||||
prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay));
|
||||
Runnable stopRelay = hasParent
|
||||
? registerBatchedRelay(childConvId, parentConversationId, agent.getName())
|
||||
: null;
|
||||
String subagentId = parentConversationId != null
|
||||
? subagentRegistry.register(parentConversationId, childConvId,
|
||||
agent.getId(), task, null)
|
||||
: null;
|
||||
prepared.add(new PreparedChild(i, agent, task, childConvId, stopRelay, subagentId));
|
||||
}
|
||||
|
||||
if (prepared.isEmpty()) {
|
||||
@ -331,9 +433,20 @@ public class DelegateAgentTool {
|
||||
|
||||
long totalDurationMs = System.currentTimeMillis() - startTime;
|
||||
|
||||
// 6. Stop all relays
|
||||
// 6. Stop all relays + drain registry entries. Both must run for every
|
||||
// prepared child regardless of whether the future succeeded, timed
|
||||
// out, or threw — otherwise the registry leaks one entry per stuck
|
||||
// child until the JVM restarts.
|
||||
for (PreparedChild p : prepared) {
|
||||
if (p.stopRelay != null) p.stopRelay.run();
|
||||
if (p.subagentId != null) {
|
||||
subagentRegistry.get(p.subagentId).ifPresent(rec -> {
|
||||
if ("running".equals(rec.status().get())) {
|
||||
rec.status().set("completed");
|
||||
}
|
||||
});
|
||||
subagentRegistry.unregister(p.subagentId);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Broadcast delegation_end with per-child structured summary
|
||||
@ -440,7 +553,7 @@ public class DelegateAgentTool {
|
||||
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
||||
String parentConversationId, String childConversationId,
|
||||
ChatOrigin parentOrigin) {
|
||||
DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS);
|
||||
DelegationContext.enter(parentConversationId, deniedToolsForChild());
|
||||
try {
|
||||
long startTime = System.currentTimeMillis();
|
||||
// RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId
|
||||
@ -684,6 +797,43 @@ public class DelegateAgentTool {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a batched relay so a chatty child does not flood the parent
|
||||
* transcript with one tool-call event per LLM step. The streaming layer
|
||||
* batches {@code tool_call_started} / {@code tool_call_completed} into
|
||||
* envelopes (5 events / 500 ms) and flushes immediately on lifecycle
|
||||
* events ({@code subagent_*}, {@code error}, {@code phase}, etc.).
|
||||
*
|
||||
* <p>The wrapper keeps the on-the-wire shape identical to
|
||||
* {@link #registerRelay} so frontend consumers do not need to change
|
||||
* — both batched envelopes and pass-through events surface as
|
||||
* {@code delegation_progress} on the parent.
|
||||
*/
|
||||
private Runnable registerBatchedRelay(String childConvId, String parentConvId, String childAgentName) {
|
||||
return streamTracker.addBatchedEventRelay(childConvId, parentConvId, 5, 500L,
|
||||
(eventName, jsonData) -> {
|
||||
if ("tool_call_started".equals(eventName)
|
||||
|| "tool_call_completed".equals(eventName)
|
||||
|| "phase".equals(eventName)) {
|
||||
try {
|
||||
Object parsedData;
|
||||
try {
|
||||
parsedData = objectMapper.readValue(jsonData, Object.class);
|
||||
} catch (Exception ignored) {
|
||||
parsedData = jsonData;
|
||||
}
|
||||
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
|
||||
"childConversationId", childConvId,
|
||||
"childAgentName", childAgentName,
|
||||
"originalEvent", eventName,
|
||||
"data", parsedData));
|
||||
} catch (Exception e) {
|
||||
log.debug("Batched relay error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void broadcastEnd(String parentConvId, String childConvId, String agentName, ChildResult result) {
|
||||
streamTracker.broadcastObject(parentConvId, "delegation_end", Map.of(
|
||||
"childConversationId", childConvId,
|
||||
|
||||
@ -23,10 +23,26 @@
|
||||
<div class="segments-view">
|
||||
<!-- 计划步骤面板(始终显示在 segments 之上) -->
|
||||
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
|
||||
<template v-for="(seg, index) in segments" :key="seg.id">
|
||||
<ThinkingSegment v-if="seg.type === 'thinking'" :segment="seg" />
|
||||
<ToolCallSegment v-if="seg.type === 'tool_call'" :segment="seg" />
|
||||
<ContentSegment v-if="seg.type === 'content'" :segment="seg" :show-cursor="showCursor && seg.status === 'running'" />
|
||||
<template v-for="iter in groupedIterations" :key="iter.key">
|
||||
<!-- Iteration interrupted before any output landed — surface a chip
|
||||
so the user knows the agent moved on instead of silently
|
||||
skipping a turn. -->
|
||||
<div v-if="iter.empty" class="iter-empty-chip">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>{{ $t('chat.iterationEmpty', { index: iter.index + 1 }) }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ThinkingSegment v-for="t in iter.thinkings" :key="t.id" :segment="t" />
|
||||
<ToolCallSegment v-for="tool in iter.tools" :key="tool.id" :segment="tool" />
|
||||
<template v-for="c in iter.contents" :key="c.id">
|
||||
<div v-if="c.repetitionWarning" class="repetition-warning">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span class="repetition-warning__text">{{ $t('chat.contentRepetitionWarning') }}</span>
|
||||
<span v-if="c.truncatedChars" class="repetition-warning__meta">({{ c.truncatedChars }} chars)</span>
|
||||
</div>
|
||||
<ContentSegment :segment="c" :show-cursor="showCursor && c.status === 'running'" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@ -751,6 +767,45 @@ const segments = computed<MessageSegment[]>(() => {
|
||||
/** 是否使用分段模式渲染(有 segments 数据且包含多个分段) */
|
||||
const useSegmentedView = computed(() => segments.value.length > 1)
|
||||
|
||||
/**
|
||||
* Group segments by iterationIndex so each ReAct iteration renders as its own
|
||||
* thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket
|
||||
* for legacy messages (no iterationIndex tagged) so historical conversations
|
||||
* keep rendering as before — including the existing "single-thinking reorder"
|
||||
* normalization done in the `segments` computed above.
|
||||
*/
|
||||
const groupedIterations = computed(() => {
|
||||
const segs = segments.value || []
|
||||
const anyTagged = segs.some(s => typeof s.iterationIndex === 'number')
|
||||
if (!anyTagged) {
|
||||
return [{
|
||||
key: 'all',
|
||||
index: 0,
|
||||
empty: false,
|
||||
thinkings: segs.filter(s => s.type === 'thinking'),
|
||||
tools: segs.filter(s => s.type === 'tool_call'),
|
||||
contents: segs.filter(s => s.type === 'content'),
|
||||
}]
|
||||
}
|
||||
const buckets = new Map<number, { thinkings: MessageSegment[]; tools: MessageSegment[]; contents: MessageSegment[] }>()
|
||||
for (const s of segs) {
|
||||
const idx = s.iterationIndex ?? 0
|
||||
if (!buckets.has(idx)) buckets.set(idx, { thinkings: [], tools: [], contents: [] })
|
||||
const b = buckets.get(idx)!
|
||||
if (s.type === 'thinking') b.thinkings.push(s)
|
||||
else if (s.type === 'tool_call') b.tools.push(s)
|
||||
else if (s.type === 'content') b.contents.push(s)
|
||||
}
|
||||
return [...buckets.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([index, b]) => ({
|
||||
key: `iter-${index}`,
|
||||
index,
|
||||
empty: b.thinkings.length === 0 && b.tools.length === 0 && b.contents.length === 0,
|
||||
...b,
|
||||
}))
|
||||
})
|
||||
|
||||
const toolCallsMeta = computed<ToolCallMeta[]>(() => {
|
||||
return parsedMetadata.value?.toolCalls || []
|
||||
})
|
||||
@ -859,6 +914,43 @@ watch(isGenerating, (generating) => {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Iteration "no output" chip (interrupted iteration). */
|
||||
.iter-empty-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
padding: 4px 10px;
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
background: var(--mc-bg-elevated, #f8fafc);
|
||||
border: 1px dashed var(--mc-border, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Inline informational banner shown when the backend trimmed a repetitive
|
||||
tail off a content segment. Amber, not red — this is informational. */
|
||||
.repetition-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
margin: 6px 0 2px;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-left: 3px solid var(--mc-warning, #f59e0b);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.repetition-warning__text {
|
||||
flex: 1;
|
||||
}
|
||||
.repetition-warning__meta {
|
||||
color: var(--mc-text-tertiary, #94a3b8);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@ -28,6 +28,14 @@ import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PhaseEventData, StreamPhase } from '@/types'
|
||||
|
||||
/** Pre-token lifecycle stage from useChat. Drives the loading copy in the
|
||||
* window between "user sent" and "first token landed". */
|
||||
interface LifecycleStage {
|
||||
stage: 'connecting' | 'started' | 'context_prepared' | 'llm_request_sent' | 'streaming'
|
||||
detail?: any
|
||||
since: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean
|
||||
toolCount?: number
|
||||
@ -43,6 +51,8 @@ interface Props {
|
||||
runningToolName?: string
|
||||
/** 是否有排队消息 */
|
||||
hasQueued?: boolean
|
||||
/** Fine-grained pre-token stage. Preferred over `phase` while no token has arrived. */
|
||||
lifecycleStage?: LifecycleStage | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@ -55,6 +65,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
phaseInfo: null,
|
||||
runningToolName: '',
|
||||
hasQueued: false,
|
||||
lifecycleStage: null,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -95,7 +106,35 @@ const userPhaseI18nMap: Record<string, string> = {
|
||||
stopped: 'chat.streamStopped',
|
||||
}
|
||||
|
||||
const lifecycleI18nMap: Record<string, string> = {
|
||||
connecting: 'chat.streamConnecting',
|
||||
started: 'chat.streamStarted',
|
||||
context_prepared: 'chat.streamContextPrepared',
|
||||
llm_request_sent: 'chat.streamLlmRequestSent',
|
||||
}
|
||||
|
||||
/** True iff the pre-token lifecycle is active (no first delta yet). */
|
||||
const inPreTokenWindow = computed(() => {
|
||||
const ls = props.lifecycleStage
|
||||
return !!ls && ls.stage !== 'streaming'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
// Prefer fine-grained pre-token text when no first delta has arrived yet.
|
||||
if (inPreTokenWindow.value && props.lifecycleStage) {
|
||||
const key = lifecycleI18nMap[props.lifecycleStage.stage]
|
||||
if (key) {
|
||||
const base = t(key)
|
||||
// Append "(Xs elapsed)" once the same stage has lingered for >= 5s, so
|
||||
// the user can see something is still happening even if the next stage
|
||||
// is delayed (e.g. a slow context_prepared → llm_request_sent gap).
|
||||
const sec = Math.floor((Date.now() - props.lifecycleStage.since) / 1000)
|
||||
if (sec >= 5 && stageTickSec.value >= 5) {
|
||||
return base + t('chat.streamElapsedSuffix', { sec: stageTickSec.value })
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
const key = userPhaseI18nMap[userFacingPhase.value]
|
||||
if (!key) return ''
|
||||
return t(key)
|
||||
@ -137,6 +176,12 @@ const phaseTextClass = computed(() => {
|
||||
const elapsedSeconds = ref(0)
|
||||
const elapsedTime = ref('0s')
|
||||
|
||||
/** Seconds elapsed since the current lifecycleStage became active. Used by
|
||||
* statusText to decide whether to append the "(Xs elapsed)" hint. Tick at
|
||||
* 1 Hz only while the pre-token window is open to keep idle cost minimal. */
|
||||
const stageTickSec = ref(0)
|
||||
let stageTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@ -166,11 +211,28 @@ watch(() => props.isLoading, (loading) => {
|
||||
}, { immediate: true })
|
||||
|
||||
|
||||
// Tick the stage-elapsed counter only while the pre-token window is open.
|
||||
// Reset on every stage transition so the "(Xs elapsed)" hint reflects time
|
||||
// in the *current* stage, not total time since send.
|
||||
watch(() => props.lifecycleStage, (ls) => {
|
||||
if (stageTimer) { clearInterval(stageTimer); stageTimer = null }
|
||||
stageTickSec.value = 0
|
||||
if (ls && ls.stage !== 'streaming') {
|
||||
stageTimer = setInterval(() => {
|
||||
stageTickSec.value = Math.floor((Date.now() - ls.since) / 1000)
|
||||
}, 1000)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
if (stageTimer) {
|
||||
clearInterval(stageTimer)
|
||||
stageTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -11,13 +11,16 @@ const props = defineProps<{
|
||||
segment: MessageSegment
|
||||
}>()
|
||||
|
||||
// Expand while streaming so the user can watch the model think in real time;
|
||||
// auto-collapse the moment streaming ends so the assistant's final answer
|
||||
// stays the focal point without a long reasoning block above it. The user
|
||||
// can click the header to re-expand at any time.
|
||||
const expanded = ref(props.segment.status === 'running')
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
|
||||
const renderedThinking = computed(() => renderMarkdown(props.segment.thinkingText || ''))
|
||||
const isRunning = computed(() => props.segment.status === 'running')
|
||||
|
||||
// running 结束后自动折叠
|
||||
watch(() => props.segment.status, (val) => {
|
||||
if (val === 'completed') expanded.value = false
|
||||
})
|
||||
|
||||
@ -62,6 +62,16 @@ export interface UseChatReturn {
|
||||
queueSize: import('vue').ComputedRef<number>
|
||||
/** Latest heartbeat data */
|
||||
heartbeat: import('vue').Ref<HeartbeatData | null>
|
||||
/**
|
||||
* Fine-grained pre-token lifecycle stage. Drives the loading bar copy in the
|
||||
* window between "send pressed" and "first delta arrived". `null` once a
|
||||
* delta is observed (StreamLoadingBar then falls back to `phase`-derived text).
|
||||
*/
|
||||
lifecycleStage: import('vue').Ref<{
|
||||
stage: 'connecting' | 'started' | 'context_prepared' | 'llm_request_sent' | 'streaming'
|
||||
detail?: any
|
||||
since: number
|
||||
} | null>
|
||||
/** Send a message (can be called while generating — automatically routes to interrupt/queue) */
|
||||
sendMessage: (content: string, options: SendMessageOptions) => Promise<void>
|
||||
/** Stop generation (user-initiated stop; does not auto-resume queued messages) */
|
||||
@ -123,6 +133,27 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
const segIdCounter = { value: 0 }
|
||||
const genSegId = () => `seg-${Date.now()}-${segIdCounter.value++}`
|
||||
|
||||
/**
|
||||
* Fine-grained lifecycle stage exposed to the UI for the "connecting → started
|
||||
* → context_prepared → llm_request_sent → streaming" loading bar. Reset on
|
||||
* every new turn; transitions to `streaming` implicitly when the first
|
||||
* thinking/content delta lands.
|
||||
*/
|
||||
const lifecycleStage = ref<{
|
||||
stage: 'connecting' | 'started' | 'context_prepared' | 'llm_request_sent' | 'streaming'
|
||||
detail?: any
|
||||
since: number
|
||||
} | null>(null)
|
||||
|
||||
/** Helper: tag a freshly-created segment with the active iteration / scope. */
|
||||
function applyIterationTags(seg: MessageSegment) {
|
||||
const stash = currentSegments.value as any
|
||||
const idx = stash._currentIteration
|
||||
if (typeof idx === 'number') seg.iterationIndex = idx
|
||||
const subId = stash._currentSubagentId
|
||||
if (subId) seg.subagentId = subId
|
||||
}
|
||||
|
||||
/** Unique ID for the current turn — prevents flushSegmentsToMessage from writing stale segments to a new message */
|
||||
let activeTurnId = ''
|
||||
|
||||
@ -296,6 +327,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) {
|
||||
streamPhase.value = 'streaming'
|
||||
}
|
||||
// First visible delta — flip lifecycleStage so the loading bar drops the
|
||||
// pre-stream messaging and yields to the per-phase status text.
|
||||
if (lifecycleStage.value && lifecycleStage.value.stage !== 'streaming') {
|
||||
lifecycleStage.value = { stage: 'streaming', since: Date.now() }
|
||||
}
|
||||
// Segments: append to the current running content segment, or create a new one
|
||||
const segs = currentSegments.value
|
||||
let contentSeg = segs.findLast((s: MessageSegment) => s.type === 'content' && s.status === 'running')
|
||||
@ -304,6 +340,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
const thinkingSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running')
|
||||
if (thinkingSeg) thinkingSeg.status = 'completed'
|
||||
contentSeg = { id: genSegId(), type: 'content', status: 'running', text: '', timestamp: Date.now() }
|
||||
applyIterationTags(contentSeg)
|
||||
segs.push(contentSeg)
|
||||
flushSegmentsToMessage() // sync once when a new content segment is created
|
||||
}
|
||||
@ -330,12 +367,17 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
)
|
||||
if (!thinkSeg) {
|
||||
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
|
||||
applyIterationTags(thinkSeg)
|
||||
// Append in timeline order (interleaved with tool_calls) — old behavior unshift'd to top,
|
||||
// but with per-round splitting that misorders rounds 2+ relative to their tool calls.
|
||||
segs.push(thinkSeg)
|
||||
flushSegmentsToMessage()
|
||||
}
|
||||
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
|
||||
// First thinking delta — same lifecycle flip as content_delta.
|
||||
if (lifecycleStage.value && lifecycleStage.value.stage !== 'streaming') {
|
||||
lifecycleStage.value = { stage: 'streaming', since: Date.now() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -458,6 +500,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
: data.status === 'stopped' ? 'stopped' : 'completed'
|
||||
if (data.status !== 'awaiting_approval') {
|
||||
phaseInfo.value = null
|
||||
lifecycleStage.value = null
|
||||
expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed')
|
||||
}
|
||||
|
||||
@ -533,6 +576,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
error.value = new Error(errorMessage)
|
||||
streamPhase.value = 'idle'
|
||||
phaseInfo.value = null
|
||||
lifecycleStage.value = null
|
||||
// Clear queue on error to avoid stale state
|
||||
messageQueue.clear()
|
||||
expirePendingApprovals('failed')
|
||||
@ -550,7 +594,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
|
||||
// ===== Agent event handlers =====
|
||||
|
||||
stream.on('tool_call_started', (data) => {
|
||||
// Body of tool_call_started — extracted so delegation_batch can replay the
|
||||
// same behavior for buffered child events without duplicating logic.
|
||||
function handleToolCallStarted(data: any) {
|
||||
if (isStaleEvent(data)) return
|
||||
streamPhase.value = 'executing_tool'
|
||||
if (currentAssistantId.value) {
|
||||
@ -577,17 +623,20 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
const segs = currentSegments.value
|
||||
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
|
||||
if (runningSeg) runningSeg.status = 'completed'
|
||||
segs.push({
|
||||
const toolSeg: MessageSegment = {
|
||||
id: genSegId(), type: 'tool_call', status: 'running',
|
||||
toolCallId: data.toolCallId || '',
|
||||
toolName: data.toolName, toolArgs: data.arguments,
|
||||
timestamp: data.timestamp || Date.now(),
|
||||
})
|
||||
}
|
||||
applyIterationTags(toolSeg)
|
||||
segs.push(toolSeg)
|
||||
flushSegmentsToMessage()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stream.on('tool_call_completed', (data) => {
|
||||
// Body of tool_call_completed — see handleToolCallStarted.
|
||||
function handleToolCallCompleted(data: any) {
|
||||
if (isStaleEvent(data)) return
|
||||
if (currentAssistantId.value) {
|
||||
const msg = getMessage(currentAssistantId.value)
|
||||
@ -641,7 +690,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
pendingAsyncTaskIds.add(taskId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stream.on('tool_call_started', handleToolCallStarted)
|
||||
stream.on('tool_call_completed', handleToolCallCompleted)
|
||||
|
||||
// ===== Browser action events =====
|
||||
|
||||
@ -875,6 +927,123 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Stream lifecycle (pre-token) events =====
|
||||
// These give the loading bar substantive status text in the gap between
|
||||
// "user pressed send" and "first token arrived" — eliminating the dead air
|
||||
// where the user only saw a spinner with no progress signal.
|
||||
stream.on('stream_started', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
lifecycleStage.value = { stage: 'started', since: Date.now() }
|
||||
})
|
||||
|
||||
stream.on('context_prepared', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
lifecycleStage.value = { stage: 'context_prepared', detail: data, since: Date.now() }
|
||||
})
|
||||
|
||||
stream.on('llm_request_sent', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
lifecycleStage.value = { stage: 'llm_request_sent', detail: data, since: Date.now() }
|
||||
})
|
||||
|
||||
// ===== Per-iteration boundaries (single-turn UX overhaul) =====
|
||||
stream.on('iteration_start', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
// Force-close any running thinking/content segments — guarantees no segment
|
||||
// belonging to the next iteration is appended onto the previous one's tail.
|
||||
const segs = currentSegments.value
|
||||
for (const seg of segs) {
|
||||
if (seg.status === 'running' && (seg.type === 'thinking' || seg.type === 'content')) {
|
||||
seg.status = 'completed'
|
||||
}
|
||||
}
|
||||
// Stash iteration / scope on the array so segment factories pick them up.
|
||||
;(currentSegments.value as any)._currentIteration = data.index ?? 0
|
||||
;(currentSegments.value as any)._currentScope = data.scope ?? 'parent'
|
||||
;(currentSegments.value as any)._currentSubagentId = data.subagentId
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
stream.on('iteration_end', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
// Sentence-level repetition warning runs on the backend (content_truncated
|
||||
// event); we don't recompute it here. Just close any still-running
|
||||
// thinking/content segments belonging to the iteration that's wrapping up.
|
||||
const segs = currentSegments.value
|
||||
for (const seg of segs) {
|
||||
if (seg.status === 'running' && (seg.type === 'thinking' || seg.type === 'content')) {
|
||||
seg.status = 'completed'
|
||||
}
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
stream.on('thinking_start', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
// Pure analytics signal — thinking_delta will create the segment as needed.
|
||||
if (currentAssistantId.value) streamPhase.value = 'thinking'
|
||||
})
|
||||
|
||||
stream.on('thinking_end', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
// Auto-collapse decisions belong to ThinkingSegment.vue. We deliberately
|
||||
// do not flip status here — thinking_delta after thinking_end is rare but
|
||||
// valid (e.g. a late provider chunk), and we want it to extend the same
|
||||
// segment rather than open a new one.
|
||||
})
|
||||
|
||||
stream.on('content_truncated', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
// Mark the running content segment with a repetition warning so the UI
|
||||
// can render an inline informational banner.
|
||||
const segs = currentSegments.value
|
||||
const contentSeg = segs.findLast((s: MessageSegment) => s.type === 'content' && s.status === 'running')
|
||||
if (contentSeg) {
|
||||
contentSeg.repetitionWarning = data.reason
|
||||
contentSeg.truncatedChars = data.truncatedChars
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
stream.on('tool_result_chunk', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
// Streamed tool result delta — append to the matching tool_call segment.
|
||||
// tool_call_completed still fires separately and carries the canonical
|
||||
// success/result fields; this just lets large results stream in instead
|
||||
// of arriving as one giant blob.
|
||||
const segs = currentSegments.value
|
||||
const toolSeg = segs.find((s: MessageSegment) =>
|
||||
s.type === 'tool_call' && s.toolCallId === data.ref)
|
||||
if (toolSeg) {
|
||||
toolSeg.toolResult = (toolSeg.toolResult || '') + (data.delta || '')
|
||||
// Note: data.final = true just means the buffer for this tool's result
|
||||
// is exhausted. Final success/error state still arrives via
|
||||
// tool_call_completed, so we don't terminate the segment here.
|
||||
}
|
||||
flushSegmentsToMessage()
|
||||
})
|
||||
|
||||
stream.on('delegation_batch', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
// Buffered child events from a delegated subagent. Replay them in order
|
||||
// through the same handlers as live events so segment state stays
|
||||
// consistent with the rest of the timeline.
|
||||
const events = Array.isArray(data?.events) ? data.events : []
|
||||
for (const ev of events) {
|
||||
const evData = ev?.data ?? {}
|
||||
switch (ev?.event) {
|
||||
case 'tool_call_started':
|
||||
handleToolCallStarted(evData)
|
||||
break
|
||||
case 'tool_call_completed':
|
||||
handleToolCallCompleted(evData)
|
||||
break
|
||||
// Other event kinds (phase / thinking_delta / content_delta / etc.)
|
||||
// are not currently produced inside batches; extend here when added.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stream.on('plan_created', (data) => {
|
||||
if (isStaleEvent(data)) return
|
||||
if (currentAssistantId.value) {
|
||||
@ -1140,6 +1309,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
currentAssistantId.value = assistantMessage.id as string
|
||||
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
|
||||
phaseInfo.value = null
|
||||
// New turn — reset lifecycle so the loading bar shows pre-token progress.
|
||||
lifecycleStage.value = { stage: 'connecting', since: Date.now() }
|
||||
})
|
||||
|
||||
// ===== Async task completion events (video / image / music generation) =====
|
||||
@ -1293,6 +1464,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
streamConversationId = conversationId
|
||||
streamPhase.value = thinkingLevelRef?.value === 'off' ? 'streaming' : 'thinking'
|
||||
phaseInfo.value = null
|
||||
// Begin pre-token lifecycle. Subsequent stream_started / context_prepared /
|
||||
// llm_request_sent events override this; first delta clears it.
|
||||
lifecycleStage.value = { stage: 'connecting', since: Date.now() }
|
||||
|
||||
try {
|
||||
if (!isApprovalCommand) {
|
||||
@ -1555,6 +1729,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
segIdCounter.value = 0
|
||||
streamPhase.value = 'idle'
|
||||
phaseInfo.value = null
|
||||
lifecycleStage.value = null
|
||||
error.value = null
|
||||
messageQueue.clear()
|
||||
if (stopFallbackTimer) {
|
||||
@ -1573,6 +1748,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
hasQueued: messageQueue.hasQueued,
|
||||
queueSize: messageQueue.queueSize,
|
||||
heartbeat,
|
||||
lifecycleStage,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
cancelQueued,
|
||||
|
||||
@ -44,6 +44,20 @@ export type SSEEventType =
|
||||
| 'delegation_progress'
|
||||
| 'delegation_end'
|
||||
| 'delegation_child_complete'
|
||||
// Stream lifecycle + per-iteration boundaries (single-turn UX overhaul).
|
||||
// The parser handles arbitrary `event:` lines via parseEvent — these names
|
||||
// exist in the union purely so TypeScript callers can register handlers
|
||||
// with a typed `on(event, handler)` signature.
|
||||
| 'stream_started'
|
||||
| 'context_prepared'
|
||||
| 'llm_request_sent'
|
||||
| 'thinking_start'
|
||||
| 'thinking_end'
|
||||
| 'iteration_start'
|
||||
| 'iteration_end'
|
||||
| 'content_truncated'
|
||||
| 'tool_result_chunk'
|
||||
| 'delegation_batch'
|
||||
|
||||
export interface SSEEvent {
|
||||
type: SSEEventType
|
||||
|
||||
@ -248,6 +248,15 @@ export default {
|
||||
streamSlowSummarizing: 'Organizing a larger tool result. This step often takes longer.',
|
||||
streamSlowReasoning: 'The model is doing heavier analysis here. This is not a frozen connection.',
|
||||
streamSlowGeneral: 'Processing is still ongoing. The time is currently being spent in internal analysis.',
|
||||
// Pre-token lifecycle stages
|
||||
streamConnecting: 'Connecting to server...',
|
||||
streamStarted: 'Connection established, preparing context...',
|
||||
streamContextPrepared: 'Context ready, calling LLM...',
|
||||
streamLlmRequestSent: 'Waiting for first token...',
|
||||
streamElapsedSuffix: ' ({sec}s elapsed)',
|
||||
// Per-iteration grouping
|
||||
iterationEmpty: 'Iteration {index} interrupted (no output)',
|
||||
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
|
||||
// Approval bar
|
||||
approvalAllow: 'Allow',
|
||||
approvalExecute: 'to execute?',
|
||||
|
||||
@ -248,6 +248,15 @@ export default {
|
||||
streamSlowSummarizing: '正在整理较长的工具结果。因为信息较多,这一步可能需要更久。',
|
||||
streamSlowReasoning: '正在做较复杂的分析和取舍,不是卡住。',
|
||||
streamSlowGeneral: '处理还在继续,当前主要耗时在内部分析,不代表连接中断。',
|
||||
// 首 token 前的生命周期阶段
|
||||
streamConnecting: '正在连接服务器...',
|
||||
streamStarted: '连接已建立,正在准备上下文...',
|
||||
streamContextPrepared: '上下文已就绪,正在调用 LLM...',
|
||||
streamLlmRequestSent: '等待模型首个响应...',
|
||||
streamElapsedSuffix: '(已等 {sec} 秒)',
|
||||
// 按轮次分组渲染
|
||||
iterationEmpty: '第 {index} 轮被中断(无输出)',
|
||||
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)',
|
||||
// 审批栏
|
||||
approvalAllow: '允许',
|
||||
approvalExecute: '执行?',
|
||||
|
||||
@ -162,6 +162,18 @@ export interface MessageSegment {
|
||||
plan?: PlanMeta
|
||||
/** 时间戳 */
|
||||
timestamp?: number
|
||||
/**
|
||||
* Iteration index this segment belongs to (0-based). Set by iteration_start —
|
||||
* lets MessageBubble group thinking/tool/content segments per iteration so
|
||||
* the next iteration's output never appends onto the previous one's tail.
|
||||
*/
|
||||
iterationIndex?: number
|
||||
/** Subagent / delegation child ID, when this segment was emitted under a child scope. */
|
||||
subagentId?: string
|
||||
/** Backend signaled the running content was truncated to break a repetition pattern. */
|
||||
repetitionWarning?: 'char_pattern' | 'sentence_repetition'
|
||||
/** Number of trailing characters dropped when the repetition guard fired. */
|
||||
truncatedChars?: number
|
||||
}
|
||||
|
||||
export interface MessageMetadata {
|
||||
|
||||
@ -251,6 +251,7 @@
|
||||
:phase-info="phaseInfo"
|
||||
:running-tool-name="currentRunningToolName"
|
||||
:has-queued="hasQueued"
|
||||
:lifecycle-stage="lifecycleStage"
|
||||
/>
|
||||
|
||||
<!-- 使用组件化的 ChatInput -->
|
||||
@ -608,6 +609,7 @@ const {
|
||||
hasQueued,
|
||||
queueSize,
|
||||
heartbeat,
|
||||
lifecycleStage,
|
||||
sendMessage: sendChatMessage,
|
||||
stopGeneration: stopChatGeneration,
|
||||
cancelQueued,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user