package vip.mate.agent;
import com.alibaba.cloud.ai.graph.NodeOutput;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.util.List;
import java.util.Map;
/**
* Graph 事件发布工具
*
* 所有方法都是 static,不做状态管理。
* 节点内部收集 List<GraphEvent>,最终写入 PENDING_EVENTS。
* StateGraph*Agent 从 NodeOutput 中读取这些事件。
*
* @author MateClaw Team
*/
public final class GraphEventPublisher {
private GraphEventPublisher() {}
// ===== 事件类型常量 =====
public static final String EVENT_PHASE = "phase";
public static final String EVENT_TOOL_START = "tool_call_started";
public static final String EVENT_TOOL_COMPLETE = "tool_call_completed";
public static final String EVENT_PLAN_CREATED = "plan_created";
public static final String EVENT_STEP_STARTED = "plan_step_started";
public static final String EVENT_STEP_COMPLETED = "plan_step_completed";
public static final String EVENT_TOOL_APPROVAL_REQUESTED = "tool_approval_requested";
/** RFC-06 D-6: lightweight performance summary emitted per-phase. */
public static final String EVENT_PERF_SUMMARY = "perf_summary";
/**
* RFC-052: a tool with returnDirect=true completed; its full result is
* carried in the payload and is intended to be rendered as part of the
* assistant message (renderAs=assistant_message), bypassing the LLM.
*/
public static final String EVENT_TOOL_DIRECT_RESULT = "tool_direct_result";
/**
* Terminal {@link vip.mate.agent.graph.state.FinishReason} for the turn,
* emitted at FinalAnswerNode so channel-side accumulators can persist it
* into message metadata. Downstream filters (e.g. memory promotion gate)
* branch on this structured value instead of doing brittle text matching
* on the assistant content.
*/
public static final String EVENT_FINISH_REASON = "finish_reason";
/**
* User-facing recovery affordances offered after a turn ends in a
* non-transient error. Carries the error type + message + a
* data-driven list of actions ({@code retry}, {@code regenerate},
* {@code report}) so the frontend can render the right buttons
* without hard-coding which categories deserve which actions.
*
*
Sibling to {@link #EVENT_FINISH_REASON} (which only carries the
* machine-readable reason). The two are kept separate so legacy
* consumers of {@code finish_reason} don't have to learn a new
* payload shape — and so a future graph branch (e.g. evidence-
* insufficient → "rerun with the listed files attached") can emit
* feedback affordances without abusing the finish_reason channel.
*/
public static final String EVENT_FEEDBACK = "feedback_event";
/**
* Multimodal sidecar routing decision for the current turn. Emitted once
* per turn before the graph starts streaming; the channel-side accumulator
* stores it under {@code metadata.routing} so the chat UI can show which
* sidecar (if any) was invoked. Underscore-prefixed name keeps it out of
* IM channel rebroadcast (see {@code ChannelMessageRouter}).
*/
public static final String EVENT_ROUTING_DECISION = "_routing_decision";
/**
* 事件记录
*/
public record GraphEvent(String type, Map data, long timestamp) {}
// ===== 静态工厂方法 =====
public static GraphEvent phase(String phase, Map extra) {
long ts = System.currentTimeMillis();
Map data = new java.util.HashMap<>(extra);
data.put("phase", phase);
data.put("timestamp", ts);
return new GraphEvent(EVENT_PHASE, Map.copyOf(data), ts);
}
public static GraphEvent toolStart(String toolName, String arguments) {
return toolStart(null, toolName, arguments);
}
/**
* Emit a tool_call_started event with the LLM-provided tool_call.id so the
* frontend can match start/complete pairs precisely. Without the id, the
* UI uses toolName + status="running" + findLast() to pair completes back
* to the original card; when the LLM fires multiple calls of the same tool
* (e.g. several execute_shell_command in a row) the matching collapses to
* "the most recent running" and earlier cards get stranded with a
* permanent spinner. Pass the id whenever it's available; null is OK for
* legacy callers.
*/
public static GraphEvent toolStart(String toolCallId, String toolName, String arguments) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_START, Map.of(
"toolCallId", toolCallId != null ? toolCallId : "",
"toolName", toolName,
"arguments", arguments != null ? arguments : "",
"timestamp", ts
), ts);
}
public static GraphEvent toolComplete(String toolName, String result, boolean success) {
return toolComplete(null, toolName, result, success);
}
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 ? result : "",
"success", success,
"timestamp", ts
), ts);
}
public static GraphEvent planCreated(Long planId, List steps) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_PLAN_CREATED, Map.of(
"planId", planId,
"steps", steps,
"timestamp", ts
), ts);
}
public static GraphEvent stepStarted(int index, String title) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_STEP_STARTED, Map.of(
"index", index,
"title", title != null ? title : "",
"timestamp", ts
), ts);
}
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 ? result : "",
"timestamp", ts
), ts);
}
public static GraphEvent toolApprovalRequested(String pendingId, String toolName,
String arguments, String reason) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_TOOL_APPROVAL_REQUESTED, Map.of(
"pendingId", pendingId,
"toolName", toolName != null ? toolName : "",
"arguments", arguments != null ? arguments : "",
"reason", reason != null ? reason : "",
"timestamp", ts
), ts);
}
/**
* 增强版审批事件(包含 findings、severity、summary)
*/
public static GraphEvent toolApprovalRequested(String pendingId, String toolName,
String arguments, String reason,
String summary, String maxSeverity,
List