feat(chat-stream): streaming UX overhaul + multi-agent stability layer

This commit is contained in:
matevip 2026-05-03 17:15:02 +08:00
parent d1099f1524
commit 66f09a968a
24 changed files with 2261 additions and 46 deletions

View File

@ -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.

View File

@ -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);
}
}

View File

@ -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\"}";
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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
*/

View File

@ -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;
}
/**

View File

@ -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));

View File

@ -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 次工具调用返回相同结果,已强制终止循环");

View File

@ -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;

View File

@ -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(),

View File

@ -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);

View File

@ -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": "&lt;json&gt;" }, ...]
* }
* </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": "&lt;refKey&gt;",
* "seq": &lt;0..N&gt;,
* "final": &lt;true|false&gt;,
* "delta": "&lt;text&gt;"
* }
* </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;
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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>

View File

@ -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
})

View File

@ -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,

View File

@ -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

View File

@ -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?',

View File

@ -248,6 +248,15 @@ export default {
streamSlowSummarizing: '正在整理较长的工具结果。因为信息较多,这一步可能需要更久。',
streamSlowReasoning: '正在做较复杂的分析和取舍,不是卡住。',
streamSlowGeneral: '处理还在继续,当前主要耗时在内部分析,不代表连接中断。',
// 首 token 前的生命周期阶段
streamConnecting: '正在连接服务器...',
streamStarted: '连接已建立,正在准备上下文...',
streamContextPrepared: '上下文已就绪,正在调用 LLM...',
streamLlmRequestSent: '等待模型首个响应...',
streamElapsedSuffix: '(已等 {sec} 秒)',
// 按轮次分组渲染
iterationEmpty: '第 {index} 轮被中断(无输出)',
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact',
// 审批栏
approvalAllow: '允许',
approvalExecute: '执行?',

View File

@ -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 {

View File

@ -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,