mateclaw/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRegistry.java
2026-05-05 20:09:58 +08:00

174 lines
6.2 KiB
Java

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