diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 235c25e0..618eab3f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -3,12 +3,15 @@ package vip.mate.agent; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.agent.event.AgentLifecycleEvent; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; @@ -43,6 +46,11 @@ public class AgentService { private final MemoryLifecycleMediator lifecycleMediator; private final MemoryProperties memoryProperties; + /** Field-injected publisher for agent_lifecycle trigger events; the + * trigger module's bridge listens and forwards into ingest. */ + @Autowired(required = false) + private ApplicationEventPublisher events; + /** 运行时 Agent 实例缓存(agentId -> BaseAgent) */ private final Map agentInstances = new ConcurrentHashMap<>(); @@ -76,18 +84,51 @@ public class AgentService { agent.setAgentType("react"); } agentMapper.insert(agent); + publishLifecycle(agent, "spawned"); return agent; } public AgentEntity updateAgent(AgentEntity agent) { + // Detect enabled-flag flip so the lifecycle event reflects the + // intent rather than every metadata edit. Reading the prior row + // is cheap and gives us a clean diff source. + AgentEntity prior = agentMapper.selectById(agent.getId()); agentMapper.updateById(agent); agentInstances.remove(agent.getId()); + if (prior != null && prior.getEnabled() != null + && !prior.getEnabled().equals(agent.getEnabled())) { + publishLifecycle(agent, + Boolean.TRUE.equals(agent.getEnabled()) ? "enabled" : "disabled"); + } return agent; } public void deleteAgent(Long id) { + AgentEntity prior = agentMapper.selectById(id); agentMapper.deleteById(id); agentInstances.remove(id); + if (prior != null) publishLifecycle(prior, "terminated"); + } + + /** + * Best-effort publish of an {@link AgentLifecycleEvent}. A publish + * failure must never roll back the agent CRUD that just succeeded — + * the agent_lifecycle trigger surface is observability, not the + * canonical record. + */ + private void publishLifecycle(AgentEntity agent, String phase) { + if (events == null || agent == null) return; + try { + events.publishEvent(new AgentLifecycleEvent( + agent.getWorkspaceId() == null ? 0L : agent.getWorkspaceId(), + agent.getId() == null ? 0L : agent.getId(), + agent.getName(), + phase, + System.currentTimeMillis())); + } catch (Exception e) { + log.warn("[AgentService] lifecycle publish failed for agent {} ({}): {}", + agent.getId(), phase, e.getMessage()); + } } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/event/AgentLifecycleEvent.java b/mateclaw-server/src/main/java/vip/mate/agent/event/AgentLifecycleEvent.java new file mode 100644 index 00000000..b7fecd88 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/event/AgentLifecycleEvent.java @@ -0,0 +1,25 @@ +package vip.mate.agent.event; + +/** + * Spring application event fired when an agent's lifecycle state changes. + * The trigger module subscribes via {@code @EventListener} and forwards + * the payload through {@code TriggerEventIngestService} so triggers of + * pattern type {@code agent_lifecycle} can fan out to workflows. + * + *

{@code phase} matches the matcher's vocabulary: {@code spawned} for + * a fresh create, {@code enabled} / {@code disabled} for a flag flip, + * {@code terminated} for a delete. {@code crashed} is reserved for v1 + * once the agent runtime grows a structured error hook. + * + *

The dedup key downstream is {@code phase + ":" + agentId + ":" + + * timestamp}; that's stable across retries of the same operation but + * lets the same agent flip enabled/disabled repeatedly without the + * trigger pipeline collapsing the events. + */ +public record AgentLifecycleEvent( + long workspaceId, + long agentId, + String agentName, + String phase, + long timestamp +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/AgentLifecycleEventBridge.java b/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/AgentLifecycleEventBridge.java new file mode 100644 index 00000000..becbad48 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/trigger/dispatch/AgentLifecycleEventBridge.java @@ -0,0 +1,55 @@ +package vip.mate.trigger.dispatch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import vip.mate.agent.event.AgentLifecycleEvent; +import vip.mate.trigger.ingest.TriggerEventEnvelope; +import vip.mate.trigger.ingest.TriggerEventIngestService; + +import java.util.HashMap; +import java.util.Map; + +/** + * Forwards {@link AgentLifecycleEvent} into the trigger pipeline as + * {@code agent_lifecycle} envelopes. Lives in the trigger module so the + * agent runtime stays free of trigger / ingest dependencies, matching + * the workflow_completion + channel_message bridge pattern. + * + *

The dedup key composes phase + agentId + timestamp so the same + * agent flipping enabled / disabled repeatedly stays observable, but + * an at-least-once retry of the same exact lifecycle event collapses. + * Failures inside ingest are logged and swallowed. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentLifecycleEventBridge { + + private final TriggerEventIngestService ingestService; + + @EventListener + public void onLifecycle(AgentLifecycleEvent event) { + if (event == null) return; + try { + Map data = new HashMap<>(); + // The matcher reads `agentId` and `phase` out of the envelope + // data; the field names mirror the matcher's vocabulary so + // pattern_json can narrow precisely. + data.put("agentId", event.agentId()); + if (event.agentName() != null) data.put("agentName", event.agentName()); + data.put("phase", event.phase()); + data.put("timestamp", event.timestamp()); + ingestService.ingest(new TriggerEventEnvelope( + event.workspaceId(), + "agent_lifecycle", + event.phase() + ":" + event.agentId() + ":" + event.timestamp(), + "system", + data)); + } catch (Exception e) { + log.warn("[AgentLifecycleBridge] forwarding agent {} phase={} failed: {}", + event.agentId(), event.phase(), e.getMessage()); + } + } +}