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 cd92cf83..3b4c886f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -25,6 +25,7 @@ import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; import java.util.List; +import java.time.Duration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; @@ -237,6 +238,26 @@ public class AgentService { } } + /** + * Sync chat that also captures token usage and runtime model attribution + * from the agent graph's {@code _usage_final} event. Equivalent to + * subscribing to {@link #chatStructuredStream} and joining all content + * deltas — produces the same assistant text as {@link #chat} but exposes + * the usage figures so callers can persist them on the assistant message. + * + *

Prefer this entry over {@link #chat} for any path that writes the + * reply to {@code mate_message} (sync HTTP endpoint, voice WebSocket, + * cron task, post-approval replay); the plain {@link #chat} stays as the + * thin wrapper for fire-and-forget invocations where usage is not needed. + */ + public ChatResult chatWithUsage(Long agentId, String message, String conversationId) { + return chatWithUsage(agentId, message, conversationId, ChatOrigin.EMPTY); + } + + public ChatResult chatWithUsage(Long agentId, String message, String conversationId, ChatOrigin origin) { + return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin)); + } + public Flux chatStream(Long agentId, String message, String conversationId) { return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY); } @@ -362,6 +383,42 @@ public class AgentService { } } + /** + * Replay-after-approval that also captures token usage and runtime model + * attribution. Mirrors {@link #chatWithUsage} for the + * approval-resumption path used by {@code ChannelMessageRouter}. + */ + public ChatResult chatWithReplayWithUsage(Long agentId, String userMessage, String conversationId, + String toolCallPayload, ChatOrigin origin) { + return collectChatResult(chatWithReplayStream(agentId, userMessage, conversationId, + toolCallPayload, "", origin != null ? origin : ChatOrigin.EMPTY)); + } + + /** + * Subscribe to a structured stream and collapse it into a single + * {@link ChatResult}: append all content deltas, capture the trailing + * {@code _usage_final} event for token and model attribution. + */ + private ChatResult collectChatResult(Flux stream) { + StringBuilder content = new StringBuilder(); + final int[] usage = {0, 0}; + final String[] modelInfo = {null, null}; + stream.doOnNext(delta -> { + if (delta.isEvent() && "_usage_final".equals(delta.eventType())) { + Map data = delta.eventData(); + usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); + usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue(); + Object model = data.get("runtimeModelName"); + Object provider = data.get("runtimeProviderId"); + if (model != null) modelInfo[0] = model.toString(); + if (provider != null) modelInfo[1] = provider.toString(); + } else if (delta.content() != null) { + content.append(delta.content()); + } + }).blockLast(Duration.ofMinutes(10)); + return new ChatResult(content.toString(), usage[0], usage[1], modelInfo[0], modelInfo[1]); + } + /** * 带工具重放的流式调用(Web 端审批通过后使用,通过 SSE 推送结果) */ @@ -623,4 +680,23 @@ public class AgentService { return thinking != null ? thinking.length() : 0; } } + + // ==================== ChatResult ==================== + + /** + * Sync chat result carrying the assistant reply alongside the usage + * attribution that the streaming path exposes via the {@code _usage_final} + * event. Use this when callers need to persist {@code promptTokens} / + * {@code completionTokens} / {@code runtimeModel} / {@code runtimeProvider} + * on the assistant message row but cannot subscribe to the structured + * stream directly (cron tasks, sync HTTP endpoints, voice WebSocket, + * post-approval replays). + */ + public record ChatResult(String content, int promptTokens, int completionTokens, + String runtimeModel, String runtimeProvider) { + + public static ChatResult contentOnly(String content) { + return new ChatResult(content != null ? content : "", 0, 0, null, null); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index f3bbbeef..4a9c5bc6 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -1017,8 +1017,9 @@ public class ChannelMessageRouter { replayOrigin = chatOriginFactory.from( channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null); } - String reply = agentService.chatWithReplay( + AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage( agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin); + String reply = replayResult.content(); // Persist the replay result. If the LLM 400'd during replay, // the error reply must also get status='error' — otherwise the @@ -1026,7 +1027,9 @@ public class ChannelMessageRouter { // into the prompt and re-trigger the same failure. boolean isError = errorClassifier.isErrorReply(reply); conversationService.saveMessage(conversationId, "assistant", reply, null, - isError ? "error" : "completed"); + isError ? "error" : "completed", + replayResult.promptTokens(), replayResult.completionTokens(), + replayResult.runtimeModel(), replayResult.runtimeProvider()); // 发送回复 adapter.renderAndSend(replyTarget, reply); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index f6d453b8..270bbf00 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1035,8 +1035,11 @@ public class ChatController { conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts()); String promptText = buildPromptText(request.getMessage(), request.getContentParts()); - String response = agentService.chat(agentId, promptText, request.getConversationId()); - conversationService.saveMessage(request.getConversationId(), "assistant", response); + AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId()); + String response = result.content(); + conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", + result.promptTokens(), result.completionTokens(), + result.runtimeModel(), result.runtimeProvider()); completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web"); return R.ok(response); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java index 45bd703c..89cea694 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java @@ -145,13 +145,17 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler { conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of()); // 5. Agent 对话(同步) - String reply = agentService.chat(talkSession.agentId, transcript, talkSession.conversationId); + AgentService.ChatResult chatResult = agentService.chatWithUsage( + talkSession.agentId, transcript, talkSession.conversationId); + String reply = chatResult.content(); if (reply == null || reply.isBlank()) { reply = "Sorry, I couldn't generate a response."; } - // 6. 保存助手回复 - conversationService.saveMessage(talkSession.conversationId, "assistant", reply, List.of()); + // 6. 保存助手回复(携带 token usage + runtime model 归属) + conversationService.saveMessage(talkSession.conversationId, "assistant", reply, List.of(), + "completed", chatResult.promptTokens(), chatResult.completionTokens(), + chatResult.runtimeModel(), chatResult.runtimeProvider()); // Publish conversation-completed event so memory extraction runs for voice turns too. completionPublisher.publish(talkSession.agentId, talkSession.conversationId, diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java index fcb01f50..cfc7f123 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java @@ -9,6 +9,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import vip.mate.agent.AgentService; import vip.mate.cron.delivery.CronJobCompletedEvent; import vip.mate.cron.model.CronJobEntity; import vip.mate.dashboard.model.CronJobRunEntity; @@ -178,6 +179,20 @@ public class CronJobLifecycleService { public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run, String userMessage, AssistantMessage result, String conversationId, boolean silent) { + finishRunAndPublish(job, run, userMessage, result, conversationId, silent, null); + } + + /** + * @param chatResult optional usage attribution from the LLM path; pass + * {@code null} for non-LLM paths (e.g. reminder + * direct-push) so the assistant row is persisted with + * zero token counts and null runtime model attribution. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run, + String userMessage, AssistantMessage result, + String conversationId, boolean silent, + AgentService.ChatResult chatResult) { String convId = conversationId != null ? conversationId : run.getConversationId(); String text = result != null && result.getText() != null ? result.getText() : ""; @@ -197,7 +212,13 @@ public class CronJobLifecycleService { return; } - conversationService.saveMessage(convId, "assistant", text); + if (chatResult != null) { + conversationService.saveMessage(convId, "assistant", text, null, "completed", + chatResult.promptTokens(), chatResult.completionTokens(), + chatResult.runtimeModel(), chatResult.runtimeProvider()); + } else { + conversationService.saveMessage(convId, "assistant", text); + } // Memory pipeline (existing behavior preserved — was inline in the // old executeJob; now lives behind the same publisher used by the diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java index 9b3f453d..d8273750 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java @@ -138,10 +138,12 @@ public class CronJobRunner { // No-tx segment — long LLM call. RFC §5.2 hard rule: must not hold // any DB connection during this call. + AgentService.ChatResult chatResult; AssistantMessage result; try { ChatOrigin origin = originFactory.from(job, conversationId); - result = runAgent(job, userMessage, origin, conversationId); + chatResult = runAgent(job, userMessage, origin, conversationId); + result = new AssistantMessage(chatResult.content()); } catch (Exception e) { log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e); try { @@ -156,12 +158,12 @@ public class CronJobRunner { // Explicit no-op: the agent answered with the silent sentinel, // meaning there is nothing to deliver or report for this run. - boolean silent = result != null && result.getText() != null + boolean silent = result.getText() != null && CRON_SILENT_MARKER.equals(result.getText().trim()); // T2 — short tx try { - lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId, silent); + lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId, silent, chatResult); } catch (Exception e) { log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e); try { @@ -261,13 +263,14 @@ public class CronJobRunner { * Runs the agent with the scheduled-job {@link ChatOrigin} and the * execution-context prompt assembled by {@link #buildCronPrompt}. */ - private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin, - String conversationId) { + private AgentService.ChatResult runAgent(CronJobEntity job, String userMessage, ChatOrigin origin, + String conversationId) { String prompt = buildCronPrompt(userMessage, origin); - String text = "agent".equals(job.getTaskType()) - ? agentService.execute(job.getAgentId(), prompt, conversationId, origin) - : agentService.chat(job.getAgentId(), prompt, conversationId, origin); - return new AssistantMessage(text != null ? text : ""); + // execute() and chat() both ultimately route through the agent's + // StateGraph; chatWithUsage captures token + runtime model attribution + // for either path. Plan-Execute agents stream via the same + // chatStructuredStream the helper consumes. + return agentService.chatWithUsage(job.getAgentId(), prompt, conversationId, origin); } /**