mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
feat(cron): channel delivery via ChatOrigin + Spring AI ToolContext
Replaces the prior ThreadLocal context plumbing with explicit Spring AI
ToolContext threading carried by an immutable ChatOrigin value object,
so a cron created from inside WeChat (or any IM channel) delivers its
results back to the originating channel.
Architecture
- ChatOrigin / ChannelTarget value objects + per-entry-point factories
(ChannelChatOriginFactory in vip.mate.channel, CronChatOriginFactory
in vip.mate.cron — symmetric, no cyclic deps).
- LocaleAwareToolCallback now forwards call(String, ToolContext) and
getToolMetadata so the decorator chain cannot silently drop the origin.
- AgentService 6-method overhaul + ChatOriginHolder bridge into
StateGraph buildInitialState which writes CHAT_ORIGIN; ActionNode +
StepExecutionNode forward it to ToolExecutionExecutor.
- ToolExecutionExecutor builds ToolContext per call; 8/8 tools migrated
(CronJobTool, WorkspacePathGuard, Video/Image/Browser/ReadFile/Music,
DelegateAgentTool with parent-origin inheritance).
- CronJobRunner + CronJobLifecycleService 3-segment REQUIRES_NEW model
(T1 startRun / no-tx runAgent / T2 finishRunAndPublish); ArchUnit
pins CronJobRunner as @Transactional-free.
- CronResultDelivery Strategy + AbstractCronResultDelivery Template
with SQL CAS idempotency on mate_cron_job_run.delivery_status —
replaces the prior process-local Caffeine TTL, cluster-safe.
- CronJobCompletedEvent + @Async @TransactionalEventListener(AFTER_COMMIT);
cronDeliveryExecutor (core=2, max=4, queue=1000, AbortPolicy + audit).
- CronRunStaleCleanup @Scheduled(5min) sweeps PENDING-15min and
status='running'-30min in one query each.
- CronJobRunner.wrapWithDeliveryGuard prepends a system note for
channel-bound crons to suppress hallucinated 'install CLI to send
WeChat' suggestions.
- ApprovalWorkflowService Memento: persist ChatOrigin snapshot on
create, restore on replay so cross-restart approvals keep channel
binding; ChannelMessageRouter + ChatController web-replay both prefer
the Memento and fall back to fresh-build.
- ChannelManager.sendToChannel 4-arg DeliveryOptions overload;
ChannelAdapter#proactiveSend default 4-arg pass-through; Slack
overrides for thread_ts and Telegram overrides for message_thread_id.
- CronJobs UI: read-only 'last delivery' badge driven by
CronJobMapper.selectListWithDeliveryStatus subquery.
Schema migrations V57/V58/V59 (V56 was already taken by an unrelated
provider migration — Flyway processes versions in order regardless of
gaps):
- V57: mate_cron_job_run delivery_status / target / error + composite
index (delivery_status, started_at) covering the cleanup sweep.
- V58: mate_cron_job channel_id (indexed) + delivery_config TEXT (JSON
via MyBatis Plus JacksonTypeHandler).
- V59: mate_tool_approval chat_origin TEXT (Memento).
All idempotent in both H2 (IF NOT EXISTS) and MySQL (INFORMATION_SCHEMA
guard + PREPARE).
ArchUnit guards (test scope, archunit-junit5 1.3.0):
- every concrete vip.mate.* ToolCallback must override
call(String, ToolContext) — pins the decorator-forward fix.
- CronJobRunner must NOT carry @Transactional on the class or any
method — pins the 3-segment lifecycle rule.
Tests: 32 new unit tests + 21 regression tests in touched areas, all
53 green:
- ChatOriginTest (6) — value-object invariants + JSON round-trip.
- LocaleAwareToolCallbackToolContextTest (2) — decorator forward.
- DeliveryConfigTest (4) — Jackson round-trip + forward-compat.
- ToolCallbackToolContextForwardArchTest (2) — both ArchUnit guards.
- CronJobRunnerDeliveryGuardTest (3) — channel-cron prefix injection.
- AbstractCronResultDeliveryTest (4) — claim CAS + concurrent CAS.
- ChannelCronResultDeliveryTest (6) — supports / doDeliver / errors.
- ApprovalReplayContinuityTest (5) — Memento round-trip + corrupt
payload fallback + unknown-field tolerance.
Refs: #25, #16
This commit is contained in:
parent
f57b31f379
commit
4011050ceb
@ -367,6 +367,20 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ===== ArchUnit (RFC-063r §2.3 / §5.2 architecture invariants) =====
|
||||||
|
test-scope only — guards:
|
||||||
|
- every ToolCallback implementation overrides call(String, ToolContext)
|
||||||
|
so decorators (LocaleAwareToolCallback) cannot silently drop ChatOrigin
|
||||||
|
- CronJobRunner (introduced in PR-3) must not carry @Transactional
|
||||||
|
(would silently fail under self-invocation; see RFC §5.2)
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.tngtech.archunit</groupId>
|
||||||
|
<artifactId>archunit-junit5</artifactId>
|
||||||
|
<version>1.3.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import org.springframework.context.event.EventListener;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ChatOriginHolder;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
@ -98,31 +100,67 @@ public class AgentService {
|
|||||||
// ==================== 运行时入口 ====================
|
// ==================== 运行时入口 ====================
|
||||||
|
|
||||||
public String chat(Long agentId, String message, String conversationId) {
|
public String chat(Long agentId, String message, String conversationId) {
|
||||||
|
return chat(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: preferred entry — accepts the originating
|
||||||
|
* {@link ChatOrigin} so channel binding and workspace context propagate
|
||||||
|
* down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}.
|
||||||
|
*/
|
||||||
|
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgent(agentId);
|
||||||
return withLifecycleSync(agentId, message, conversationId,
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
(msg, convId) -> agent.chat(msg, convId));
|
try {
|
||||||
|
return withLifecycleSync(agentId, message, conversationId,
|
||||||
|
(msg, convId) -> agent.chat(msg, convId));
|
||||||
|
} finally {
|
||||||
|
ChatOriginHolder.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
||||||
|
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgent(agentId);
|
||||||
return withLifecycleFlux(agentId, message, conversationId,
|
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||||
(msg, convId) -> agent.chatStream(msg, convId),
|
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||||
chunk -> chunk);
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
|
return Flux.defer(() -> {
|
||||||
|
ChatOriginHolder.set(captured);
|
||||||
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
|
(msg, convId) -> agent.chatStream(msg, convId),
|
||||||
|
chunk -> chunk);
|
||||||
|
}).doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
|
||||||
return chatStructuredStream(agentId, message, conversationId, "", null);
|
return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
String requesterId) {
|
String requesterId) {
|
||||||
return chatStructuredStream(agentId, message, conversationId, requesterId, null);
|
return chatStructuredStream(agentId, message, conversationId, requesterId, null, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
|
String requesterId, ChatOrigin origin) {
|
||||||
|
return chatStructuredStream(agentId, message, conversationId, requesterId, null, origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
String requesterId, String thinkingLevel) {
|
String requesterId, String thinkingLevel) {
|
||||||
|
return chatStructuredStream(agentId, message, conversationId, requesterId, thinkingLevel,
|
||||||
|
ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||||
|
String requesterId, String thinkingLevel,
|
||||||
|
ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
memoryRecallTracker.trackRecalls(agentId, message);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgent(agentId);
|
||||||
|
|
||||||
@ -139,27 +177,45 @@ public class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
if (agent instanceof StructuredStreamCapable capable) {
|
if (agent instanceof StructuredStreamCapable capable) {
|
||||||
return withLifecycleFlux(agentId, message, conversationId,
|
return Flux.defer(() -> {
|
||||||
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
ChatOriginHolder.set(captured);
|
||||||
requesterId != null ? requesterId : "")
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
(msg, convId) -> capable.chatStructuredStream(msg, convId,
|
||||||
StreamDelta::content);
|
requesterId != null ? requesterId : "")
|
||||||
|
.doFinally(signal -> ThinkingLevelHolder.clear()),
|
||||||
|
StreamDelta::content);
|
||||||
|
})
|
||||||
|
.doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
// 降级:不支持结构化流的 Agent,包装为纯内容流
|
||||||
ThinkingLevelHolder.clear();
|
ThinkingLevelHolder.clear();
|
||||||
return withLifecycleFlux(agentId, message, conversationId,
|
return Flux.defer(() -> {
|
||||||
(msg, convId) -> agent.chatStream(msg, convId)
|
ChatOriginHolder.set(captured);
|
||||||
.map(chunk -> new StreamDelta(chunk, null)),
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
StreamDelta::content);
|
(msg, convId) -> agent.chatStream(msg, convId)
|
||||||
|
.map(chunk -> new StreamDelta(chunk, null)),
|
||||||
|
StreamDelta::content);
|
||||||
|
})
|
||||||
|
.doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
public String execute(Long agentId, String goal, String conversationId) {
|
public String execute(Long agentId, String goal, String conversationId) {
|
||||||
|
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgent(agentId);
|
||||||
return withLifecycleSync(agentId, goal, conversationId,
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
(msg, convId) -> agent.execute(msg, convId));
|
try {
|
||||||
|
return withLifecycleSync(agentId, goal, conversationId,
|
||||||
|
(msg, convId) -> agent.execute(msg, convId));
|
||||||
|
} finally {
|
||||||
|
ChatOriginHolder.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -173,10 +229,20 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload) {
|
String toolCallPayload) {
|
||||||
|
return chatWithReplay(agentId, userMessage, conversationId, toolCallPayload, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||||
|
String toolCallPayload, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgent(agentId);
|
||||||
return withLifecycleSync(agentId, userMessage, conversationId,
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
|
try {
|
||||||
|
return withLifecycleSync(agentId, userMessage, conversationId,
|
||||||
|
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
|
||||||
|
} finally {
|
||||||
|
ChatOriginHolder.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -184,17 +250,29 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload) {
|
String toolCallPayload) {
|
||||||
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "");
|
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "", ChatOrigin.EMPTY);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload, String requesterId) {
|
String toolCallPayload, String requesterId) {
|
||||||
|
return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, requesterId,
|
||||||
|
ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
|
String toolCallPayload, String requesterId,
|
||||||
|
ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||||
BaseAgent agent = getOrBuildAgent(agentId);
|
BaseAgent agent = getOrBuildAgent(agentId);
|
||||||
return withLifecycleFlux(agentId, userMessage, conversationId,
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
|
return Flux.defer(() -> {
|
||||||
requesterId != null ? requesterId : ""),
|
ChatOriginHolder.set(captured);
|
||||||
StreamDelta::content);
|
return withLifecycleFlux(agentId, userMessage, conversationId,
|
||||||
|
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
|
||||||
|
requesterId != null ? requesterId : ""),
|
||||||
|
StreamDelta::content);
|
||||||
|
})
|
||||||
|
.doFinally(signal -> ChatOriginHolder.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentState getAgentState(Long agentId) {
|
public AgentState getAgentState(Long agentId) {
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel-bound target identity used when an agent's response must be delivered
|
||||||
|
* back to a specific external channel (cron, IM relay, etc.).
|
||||||
|
*
|
||||||
|
* <p>Kept as a sub-VO of {@link ChatOrigin} so future channel-related fields do
|
||||||
|
* not pollute the top-level origin record.
|
||||||
|
*
|
||||||
|
* <p>Field evolution rule: only-add, do-not-rename, deprecate-for-90-days before
|
||||||
|
* physical removal — see {@link ChatOrigin}'s class doc.
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ChannelTarget(
|
||||||
|
@Nullable String targetId,
|
||||||
|
@Nullable String threadId,
|
||||||
|
@Nullable String accountId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable value object that travels alongside an agent invocation describing
|
||||||
|
* <em>where the request came from</em> — channel, conversation, requester,
|
||||||
|
* workspace, and optional delivery target.
|
||||||
|
*
|
||||||
|
* <p>Replaces ad-hoc ThreadLocal threading (RFC-063 v1) with explicit Spring AI
|
||||||
|
* {@link ToolContext} carriage (RFC-063r §2.1). The wither-style API enables
|
||||||
|
* the agent runtime to enrich the origin (agentId, workspace) without mutation.
|
||||||
|
*
|
||||||
|
* <h2>Field evolution rule</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Only add — never delete; deprecate at least 90 days (covers approval TTL)
|
||||||
|
* before physical removal.</li>
|
||||||
|
* <li>Never rename — add a new field plus deprecate-old-field, double-write
|
||||||
|
* during the migration window.</li>
|
||||||
|
* <li>{@link JsonIgnoreProperties#ignoreUnknown()} guards forward/backward
|
||||||
|
* compatibility when older approval rows are deserialized after upgrades.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ChatOrigin(
|
||||||
|
@Nullable Long agentId,
|
||||||
|
@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable Long channelId,
|
||||||
|
@Nullable ChannelTarget channelTarget
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
|
||||||
|
public static final String CTX_KEY = "mateclaw.chatOrigin";
|
||||||
|
|
||||||
|
/** Sentinel used by AgentService default overloads where no origin is supplied. */
|
||||||
|
public static final ChatOrigin EMPTY =
|
||||||
|
new ChatOrigin(null, null, "", null, null, null, null);
|
||||||
|
|
||||||
|
// ---------------- Factories per entry point ----------------
|
||||||
|
|
||||||
|
public static ChatOrigin web(@Nullable String conversationId,
|
||||||
|
@Nullable String requesterId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath) {
|
||||||
|
return new ChatOrigin(null, conversationId,
|
||||||
|
requesterId != null ? requesterId : "",
|
||||||
|
workspaceId, workspaceBasePath, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChatOrigin cron(@Nullable String conversationId,
|
||||||
|
@Nullable Long workspaceId,
|
||||||
|
@Nullable String workspaceBasePath,
|
||||||
|
@Nullable Long channelId,
|
||||||
|
@Nullable ChannelTarget target) {
|
||||||
|
return new ChatOrigin(null, conversationId, "system",
|
||||||
|
workspaceId, workspaceBasePath, channelId, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Wither-style updates ----------------
|
||||||
|
|
||||||
|
public ChatOrigin withAgent(@Nullable Long newAgentId) {
|
||||||
|
return new ChatOrigin(newAgentId, conversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
|
@Nullable String newWorkspaceBasePath) {
|
||||||
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatOrigin withConversationId(@Nullable String newConversationId) {
|
||||||
|
return new ChatOrigin(agentId, newConversationId, requesterId,
|
||||||
|
workspaceId, workspaceBasePath, channelId, channelTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Spring AI ToolContext interop ----------------
|
||||||
|
|
||||||
|
/** Wrap this origin into a Spring AI {@link ToolContext} the runtime can pass to tools. */
|
||||||
|
public ToolContext toToolContext() {
|
||||||
|
return new ToolContext(Map.of(CTX_KEY, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a {@link ChatOrigin} stored under {@link #CTX_KEY} in the given
|
||||||
|
* {@link ToolContext}. Returns {@link #EMPTY} when {@code ctx} is null, has
|
||||||
|
* no entry, or the value is not a ChatOrigin (defensive — keeps single-tool
|
||||||
|
* callers safe even if wiring is partial).
|
||||||
|
*/
|
||||||
|
public static ChatOrigin from(@Nullable ToolContext ctx) {
|
||||||
|
if (ctx == null) return EMPTY;
|
||||||
|
Object v = ctx.getContext().get(CTX_KEY);
|
||||||
|
return v instanceof ChatOrigin co ? co : EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-scoped {@link ChatOrigin} bridge between {@code AgentService}'s
|
||||||
|
* public entry points and the StateGraph's {@code buildInitialState}.
|
||||||
|
*
|
||||||
|
* <p>RFC-063r §2.5 carries the origin end-to-end via Spring AI {@code ToolContext}
|
||||||
|
* once it lands in graph state. This holder is the small bridge that gets the
|
||||||
|
* origin from the AgentService method invocation into the graph's initial
|
||||||
|
* state map — the holder lifecycle is bounded by the AgentService method
|
||||||
|
* call (set on entry, cleared in {@code finally}). Once written into the
|
||||||
|
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
|
||||||
|
* the rest of the runtime reads via the typed accessor — no further ThreadLocal
|
||||||
|
* access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}.
|
||||||
|
*/
|
||||||
|
public final class ChatOriginHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<ChatOrigin> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private ChatOriginHolder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the origin for the current AgentService invocation. */
|
||||||
|
public static void set(ChatOrigin origin) {
|
||||||
|
HOLDER.set(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the origin set for the current invocation, or {@link ChatOrigin#EMPTY}
|
||||||
|
* when no entry path has supplied one (legacy callers).
|
||||||
|
*/
|
||||||
|
public static ChatOrigin get() {
|
||||||
|
ChatOrigin v = HOLDER.get();
|
||||||
|
return v != null ? v : ChatOrigin.EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -410,6 +410,19 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||||
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||||
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
|
||||||
|
// RFC-063r §2.5: enrich the originating ChatOrigin with this agent's id
|
||||||
|
// and workspace, then write it into graph state so ActionNode +
|
||||||
|
// StepExecutionNode can forward it to ToolExecutionExecutor → ToolContext.
|
||||||
|
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||||
|
Long parsedAgentIdForOrigin = null;
|
||||||
|
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||||
|
if (parsedAgentIdForOrigin != null) {
|
||||||
|
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||||
|
}
|
||||||
|
origin = origin.withConversationId(conversationId)
|
||||||
|
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||||
|
inputs.put(CHAT_ORIGIN, origin);
|
||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
@ -202,6 +204,8 @@ public class ToolExecutionExecutor {
|
|||||||
private volatile String currentRequesterId;
|
private volatile String currentRequesterId;
|
||||||
/** 当前工作区活动目录(为空不限制),传递给 ToolExecutionContext */
|
/** 当前工作区活动目录(为空不限制),传递给 ToolExecutionContext */
|
||||||
private volatile String currentWorkspaceBasePath;
|
private volatile String currentWorkspaceBasePath;
|
||||||
|
/** RFC-063r §2.5: 当前执行的 ChatOrigin,构建 ToolContext 时透传给工具 */
|
||||||
|
private volatile ChatOrigin currentChatOrigin = ChatOrigin.EMPTY;
|
||||||
|
|
||||||
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
String conversationId, String agentId,
|
String conversationId, String agentId,
|
||||||
@ -213,8 +217,30 @@ public class ToolExecutionExecutor {
|
|||||||
String conversationId, String agentId,
|
String conversationId, String agentId,
|
||||||
boolean isReplay, String requesterId,
|
boolean isReplay, String requesterId,
|
||||||
String workspaceBasePath) {
|
String workspaceBasePath) {
|
||||||
|
return execute(toolCalls, conversationId, agentId, isReplay, requesterId,
|
||||||
|
workspaceBasePath, ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: preferred overload — accepts a {@link ChatOrigin} that the
|
||||||
|
* top-level agent has enriched with agentId/workspace/channel context.
|
||||||
|
* Builds a Spring AI {@link ToolContext} per tool invocation so
|
||||||
|
* {@code @Tool} methods can read the origin via
|
||||||
|
* {@code ChatOrigin.from(toolContext)}.
|
||||||
|
*
|
||||||
|
* <p>During the PR-1 transition the legacy {@link ToolExecutionContext}
|
||||||
|
* ThreadLocal is also populated, so existing tools that read from it keep
|
||||||
|
* working unchanged. After all 8 callsites migrate, the ThreadLocal can be
|
||||||
|
* removed.
|
||||||
|
*/
|
||||||
|
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
|
String conversationId, String agentId,
|
||||||
|
boolean isReplay, String requesterId,
|
||||||
|
String workspaceBasePath,
|
||||||
|
ChatOrigin origin) {
|
||||||
this.currentRequesterId = requesterId;
|
this.currentRequesterId = requesterId;
|
||||||
this.currentWorkspaceBasePath = workspaceBasePath;
|
this.currentWorkspaceBasePath = workspaceBasePath;
|
||||||
|
this.currentChatOrigin = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
|
||||||
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
|
||||||
// RFC-052: accumulate full-text outputs from returnDirect tools so the
|
// RFC-052: accumulate full-text outputs from returnDirect tools so the
|
||||||
@ -309,7 +335,7 @@ public class ToolExecutionExecutor {
|
|||||||
// 4. 分类: concurrencySafe
|
// 4. 分类: concurrencySafe
|
||||||
boolean safe = isConcurrencySafe(toolName);
|
boolean safe = isConcurrencySafe(toolName);
|
||||||
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
||||||
conversationId, currentRequesterId, currentWorkspaceBasePath));
|
conversationId, currentRequesterId, currentWorkspaceBasePath, currentChatOrigin));
|
||||||
// 占位,Phase 2 填充
|
// 占位,Phase 2 填充
|
||||||
allResponses.add(null);
|
allResponses.add(null);
|
||||||
}
|
}
|
||||||
@ -382,7 +408,13 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
log.info("[ToolExecutor] Executing pre-approved tool: {}", toolName);
|
||||||
String result = callback.call(callArguments);
|
// RFC-063r §2.5: forward ToolContext so the pre-approved tool can
|
||||||
|
// still observe the originating ChatOrigin (channel/workspace).
|
||||||
|
ChatOrigin replayOrigin = currentChatOrigin != null ? currentChatOrigin : ChatOrigin.EMPTY;
|
||||||
|
replayOrigin = replayOrigin
|
||||||
|
.withConversationId(conversationId)
|
||||||
|
.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
||||||
|
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
|
|
||||||
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
||||||
@ -565,11 +597,19 @@ public class ToolExecutionExecutor {
|
|||||||
toolName, pc.arguments != null && pc.arguments.length() > 200
|
toolName, pc.arguments != null && pc.arguments.length() > 200
|
||||||
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
|
? pc.arguments.substring(0, 200) + "..." : pc.arguments);
|
||||||
|
|
||||||
// 注入工具执行上下文(供 VideoGenerateTool 等获取 conversationId / username / workspaceBasePath)
|
// RFC-063r §2.5 / PR-1 transition window: populate BOTH the explicit
|
||||||
|
// Spring AI ToolContext (preferred — read via ChatOrigin.from(ctx))
|
||||||
|
// AND the legacy ToolExecutionContext ThreadLocal so tools that have
|
||||||
|
// not yet migrated to ToolContext keep working unchanged.
|
||||||
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
|
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
|
||||||
String result;
|
String result;
|
||||||
try {
|
try {
|
||||||
result = pc.callback.call(pc.arguments);
|
ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
|
||||||
|
runtimeOrigin = runtimeOrigin
|
||||||
|
.withConversationId(pc.conversationId)
|
||||||
|
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||||
|
ToolContext toolContext = runtimeOrigin.toToolContext();
|
||||||
|
result = pc.callback.call(pc.arguments, toolContext);
|
||||||
} finally {
|
} finally {
|
||||||
ToolExecutionContext.clear();
|
ToolExecutionContext.clear();
|
||||||
}
|
}
|
||||||
@ -781,7 +821,8 @@ public class ToolExecutionExecutor {
|
|||||||
int resultIndex,
|
int resultIndex,
|
||||||
String conversationId,
|
String conversationId,
|
||||||
String requesterId,
|
String requesterId,
|
||||||
String workspaceBasePath
|
String workspaceBasePath,
|
||||||
|
ChatOrigin origin
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private record ApprovalBarrier(String pendingId, String toolName) {}
|
private record ApprovalBarrier(String pendingId, String toolName) {}
|
||||||
|
|||||||
@ -65,9 +65,13 @@ public class ActionNode implements NodeAction {
|
|||||||
// 获取工作区活动目录
|
// 获取工作区活动目录
|
||||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||||
|
|
||||||
|
// RFC-063r §2.5: read the originating ChatOrigin from graph state and
|
||||||
|
// forward it into the executor — tools see it via Spring AI ToolContext.
|
||||||
|
vip.mate.agent.context.ChatOrigin origin = accessor.chatOrigin();
|
||||||
|
|
||||||
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
// 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行)
|
||||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||||
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath);
|
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
|
||||||
|
|
||||||
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
|
||||||
.responses(result.responses())
|
.responses(result.responses())
|
||||||
|
|||||||
@ -285,6 +285,19 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
|
||||||
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
|
||||||
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
|
||||||
|
// RFC-063r §2.5: same as ReAct path — enrich and store the ChatOrigin
|
||||||
|
// so StepExecutionNode (and any sub-graphs spawned via DelegateAgentTool)
|
||||||
|
// can read it back from state.
|
||||||
|
vip.mate.agent.context.ChatOrigin origin = vip.mate.agent.context.ChatOriginHolder.get();
|
||||||
|
Long parsedAgentIdForOrigin = null;
|
||||||
|
try { parsedAgentIdForOrigin = agentId != null ? Long.valueOf(agentId) : null; } catch (Exception ignored) {}
|
||||||
|
if (parsedAgentIdForOrigin != null) {
|
||||||
|
origin = origin.withAgent(parsedAgentIdForOrigin);
|
||||||
|
}
|
||||||
|
origin = origin.withConversationId(conversationId)
|
||||||
|
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||||
|
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
|
||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,12 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
|
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
|
||||||
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
|
||||||
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
String workspaceBasePath = state.value(MateClawStateKeys.WORKSPACE_BASE_PATH, "");
|
||||||
|
// RFC-063r §2.5: read parent ChatOrigin from graph state so tools in
|
||||||
|
// this step (and any DelegateAgentTool sub-graphs) inherit channel /
|
||||||
|
// workspace / requester context.
|
||||||
|
vip.mate.agent.context.ChatOrigin chatOrigin =
|
||||||
|
state.<vip.mate.agent.context.ChatOrigin>value(MateClawStateKeys.CHAT_ORIGIN)
|
||||||
|
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
|
||||||
|
|
||||||
if (stepIndex >= steps.size()) {
|
if (stepIndex >= steps.size()) {
|
||||||
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
|
||||||
@ -196,7 +202,7 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
} else {
|
} else {
|
||||||
// 非预批准工具走正常执行器
|
// 非预批准工具走正常执行器
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath);
|
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||||
toolResponses.addAll(execResult.responses());
|
toolResponses.addAll(execResult.responses());
|
||||||
events.addAll(execResult.events());
|
events.addAll(execResult.events());
|
||||||
if (execResult.hasDirectOutputs()) {
|
if (execResult.hasDirectOutputs()) {
|
||||||
@ -212,7 +218,7 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
} else {
|
} else {
|
||||||
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
// 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier)
|
||||||
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
|
||||||
allToolCalls, conversationId, agentId, false, "", workspaceBasePath);
|
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
|
||||||
toolResponses.addAll(execResult.responses());
|
toolResponses.addAll(execResult.responses());
|
||||||
events.addAll(execResult.events());
|
events.addAll(execResult.events());
|
||||||
if (execResult.hasDirectOutputs()) {
|
if (execResult.hasDirectOutputs()) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
|
|||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@ -212,6 +213,17 @@ public final class MateClawStateAccessor {
|
|||||||
return state.value(FORCED_TOOL_CALL, "");
|
return state.value(FORCED_TOOL_CALL, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== RFC-063r: ChatOrigin =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: the {@link ChatOrigin} written into graph state by the
|
||||||
|
* top-level agent. Returns {@link ChatOrigin#EMPTY} when the entry path
|
||||||
|
* did not supply one (e.g., legacy callers using the bridge overloads).
|
||||||
|
*/
|
||||||
|
public ChatOrigin chatOrigin() {
|
||||||
|
return state.<ChatOrigin>value(CHAT_ORIGIN).orElse(ChatOrigin.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Token Usage =====
|
// ===== Token Usage =====
|
||||||
|
|
||||||
public int promptTokens() {
|
public int promptTokens() {
|
||||||
@ -398,6 +410,11 @@ public final class MateClawStateAccessor {
|
|||||||
return put(FORCED_TOOL_CALL, json);
|
return put(FORCED_TOOL_CALL, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- RFC-063r: ChatOrigin ----
|
||||||
|
public OutputBuilder chatOrigin(ChatOrigin origin) {
|
||||||
|
return put(CHAT_ORIGIN, origin);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Token Usage ----
|
// ---- Token Usage ----
|
||||||
|
|
||||||
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */
|
||||||
|
|||||||
@ -155,4 +155,16 @@ public final class MateClawStateKeys {
|
|||||||
* tool batch, used by FinalAnswerNode to assemble the final answer.
|
* tool batch, used by FinalAnswerNode to assemble the final answer.
|
||||||
*/
|
*/
|
||||||
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
|
public static final String DIRECT_TOOL_OUTPUTS = "direct_tool_outputs";
|
||||||
|
|
||||||
|
// ===== RFC-063r: ChatOrigin propagation through the StateGraph =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: top-level agent writes the {@code ChatOrigin} value object
|
||||||
|
* into graph state once at {@code buildInitialState}; nodes (especially
|
||||||
|
* {@code StepExecutionNode} in the Plan-Execute sub-graph) read it
|
||||||
|
* read-only when invoking {@link vip.mate.agent.graph.executor.ToolExecutionExecutor}
|
||||||
|
* so child graphs and delegated agents inherit the originating channel /
|
||||||
|
* workspace context.
|
||||||
|
*/
|
||||||
|
public static final String CHAT_ORIGIN = "chat_origin";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.context.ChatOriginHolder;
|
||||||
import vip.mate.approval.model.ToolApprovalEntity;
|
import vip.mate.approval.model.ToolApprovalEntity;
|
||||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||||
@ -137,6 +139,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
snapshot.setFindingsJson(entity.getFindingsJson());
|
snapshot.setFindingsJson(entity.getFindingsJson());
|
||||||
snapshot.setMaxSeverity(entity.getMaxSeverity());
|
snapshot.setMaxSeverity(entity.getMaxSeverity());
|
||||||
snapshot.setSummary(entity.getSummary());
|
snapshot.setSummary(entity.getSummary());
|
||||||
|
snapshot.setChatOrigin(entity.getChatOrigin());
|
||||||
|
|
||||||
approvalService.registerRecovered(snapshot);
|
approvalService.registerRecovered(snapshot);
|
||||||
recovered++;
|
recovered++;
|
||||||
@ -200,6 +203,14 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
conversationId, userId, toolName, toolArguments, reason,
|
conversationId, userId, toolName, toolArguments, reason,
|
||||||
toolCallPayload, siblingToolCalls, agentId);
|
toolCallPayload, siblingToolCalls, agentId);
|
||||||
|
|
||||||
|
// RFC-063r §2.12: capture the originating ChatOrigin from the holder.
|
||||||
|
// The holder was set by AgentService.{chat,chatStream,...} for the
|
||||||
|
// duration of the agent invocation that produced this approval — so
|
||||||
|
// it is non-null for IM / web triggered tool calls. Snapshot is
|
||||||
|
// serialized once here and persisted on the DB row so cross-restart
|
||||||
|
// replays keep the channel binding.
|
||||||
|
String chatOriginJson = serializeChatOrigin(ChatOriginHolder.get());
|
||||||
|
|
||||||
// 2. 增强内存记录
|
// 2. 增强内存记录
|
||||||
approvalService.getPending(pendingId).ifPresent(pending -> {
|
approvalService.getPending(pendingId).ifPresent(pending -> {
|
||||||
if (evaluation != null) {
|
if (evaluation != null) {
|
||||||
@ -207,11 +218,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
|
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
|
||||||
pending.setSummary(evaluation.summary());
|
pending.setSummary(evaluation.summary());
|
||||||
}
|
}
|
||||||
|
pending.setChatOrigin(chatOriginJson);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. DB 层
|
// 3. DB 层
|
||||||
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
|
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
|
||||||
toolCallPayload, siblingToolCalls, agentId, evaluation);
|
toolCallPayload, siblingToolCalls, agentId, evaluation, chatOriginJson);
|
||||||
|
|
||||||
return pendingId;
|
return pendingId;
|
||||||
}
|
}
|
||||||
@ -517,7 +529,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
private void persistToDb(String pendingId, String conversationId, String userId,
|
private void persistToDb(String pendingId, String conversationId, String userId,
|
||||||
String toolName, String toolArguments,
|
String toolName, String toolArguments,
|
||||||
String toolCallPayload, String siblingToolCalls, String agentId,
|
String toolCallPayload, String siblingToolCalls, String agentId,
|
||||||
GuardEvaluation evaluation) {
|
GuardEvaluation evaluation, String chatOriginJson) {
|
||||||
try {
|
try {
|
||||||
ToolApprovalEntity entity = new ToolApprovalEntity();
|
ToolApprovalEntity entity = new ToolApprovalEntity();
|
||||||
entity.setPendingId(pendingId);
|
entity.setPendingId(pendingId);
|
||||||
@ -531,6 +543,9 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
entity.setStatus("PENDING");
|
entity.setStatus("PENDING");
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
|
entity.setExpireAt(LocalDateTime.now().plusMinutes(30));
|
||||||
|
// RFC-063r §2.12: persist Memento snapshot. Null when the entry
|
||||||
|
// path didn't supply an origin — replay falls back to ChatOrigin.EMPTY.
|
||||||
|
entity.setChatOrigin(chatOriginJson);
|
||||||
|
|
||||||
if (evaluation != null) {
|
if (evaluation != null) {
|
||||||
entity.setFindingsJson(serializeFindings(evaluation.findings()));
|
entity.setFindingsJson(serializeFindings(evaluation.findings()));
|
||||||
@ -548,6 +563,44 @@ public class ApprovalWorkflowService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: serialize a {@link ChatOrigin} for persistence on
|
||||||
|
* {@code mate_tool_approval.chat_origin}. Returns null for
|
||||||
|
* {@code ChatOrigin.EMPTY} so legacy approvals that never captured an
|
||||||
|
* origin do not store a meaningless empty record.
|
||||||
|
*/
|
||||||
|
private String serializeChatOrigin(ChatOrigin origin) {
|
||||||
|
if (origin == null || origin == ChatOrigin.EMPTY) return null;
|
||||||
|
if (origin.agentId() == null && origin.channelId() == null
|
||||||
|
&& origin.conversationId() == null && origin.workspaceId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(origin);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("[ApprovalWorkflow] Failed to serialize ChatOrigin: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: deserialize a persisted Memento back into a
|
||||||
|
* {@link ChatOrigin}. Returns {@link ChatOrigin#EMPTY} when the column
|
||||||
|
* is null or the payload is corrupt — the caller treats that as
|
||||||
|
* "no channel binding" and replay proceeds with a web-style flow.
|
||||||
|
*/
|
||||||
|
public ChatOrigin restoreChatOrigin(String json) {
|
||||||
|
if (json == null || json.isBlank()) return ChatOrigin.EMPTY;
|
||||||
|
try {
|
||||||
|
ChatOrigin restored = objectMapper.readValue(json, ChatOrigin.class);
|
||||||
|
return restored != null ? restored : ChatOrigin.EMPTY;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ApprovalWorkflow] Failed to restore ChatOrigin: {} (payload-len={})",
|
||||||
|
e.getMessage(), json.length());
|
||||||
|
return ChatOrigin.EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
|
private void updateDbStatus(String pendingId, String status, String resolvedBy) {
|
||||||
try {
|
try {
|
||||||
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()
|
||||||
|
|||||||
@ -59,6 +59,16 @@ public class PendingApproval {
|
|||||||
/** 风险摘要 */
|
/** 风险摘要 */
|
||||||
private String summary;
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: serialized {@code ChatOrigin} snapshot captured when
|
||||||
|
* this approval was created. Lets cross-process / cross-restart replays
|
||||||
|
* (the user approves hours later from a different node) restore the
|
||||||
|
* original channel binding so the replayed tool call still delivers
|
||||||
|
* back to the correct channel. Persisted into
|
||||||
|
* {@code mate_tool_approval.chat_origin}.
|
||||||
|
*/
|
||||||
|
private String chatOrigin;
|
||||||
|
|
||||||
public PendingApproval(String pendingId, String conversationId, String userId,
|
public PendingApproval(String pendingId, String conversationId, String userId,
|
||||||
String toolName, String toolArguments, String reason) {
|
String toolName, String toolArguments, String reason) {
|
||||||
this.pendingId = pendingId;
|
this.pendingId = pendingId;
|
||||||
@ -110,6 +120,7 @@ public class PendingApproval {
|
|||||||
public String getFindingsJson() { return findingsJson; }
|
public String getFindingsJson() { return findingsJson; }
|
||||||
public String getMaxSeverity() { return maxSeverity; }
|
public String getMaxSeverity() { return maxSeverity; }
|
||||||
public String getSummary() { return summary; }
|
public String getSummary() { return summary; }
|
||||||
|
public String getChatOrigin() { return chatOrigin; }
|
||||||
|
|
||||||
// === Setters ===
|
// === Setters ===
|
||||||
|
|
||||||
@ -125,4 +136,5 @@ public class PendingApproval {
|
|||||||
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
|
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
|
||||||
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
|
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
|
||||||
public void setSummary(String summary) { this.summary = summary; }
|
public void setSummary(String summary) { this.summary = summary; }
|
||||||
|
public void setChatOrigin(String chatOrigin) { this.chatOrigin = chatOrigin; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,16 @@ public class ToolApprovalEntity {
|
|||||||
private LocalDateTime resolvedAt;
|
private LocalDateTime resolvedAt;
|
||||||
private LocalDateTime expireAt;
|
private LocalDateTime expireAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.12: serialized {@link vip.mate.agent.context.ChatOrigin}
|
||||||
|
* snapshot captured when this approval was created. The Memento lets
|
||||||
|
* ChannelMessageRouter.replayApprovedToolCall (and the web ApprovalController
|
||||||
|
* replay path) restore the originating channel/workspace context after
|
||||||
|
* a process restart, so a tool approved hours later still binds back to
|
||||||
|
* the original channel.
|
||||||
|
*/
|
||||||
|
private String chatOrigin;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,19 @@ public interface ChannelAdapter {
|
|||||||
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
|
throw new UnsupportedOperationException(getChannelType() + " does not support proactive send");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: extended overload that accepts a
|
||||||
|
* {@link DeliveryOptions} Parameter Object carrying optional hints
|
||||||
|
* (thread id, multi-bot account id, future ext fields).
|
||||||
|
*
|
||||||
|
* <p>Default implementation delegates to {@link #proactiveSend(String, String)},
|
||||||
|
* dropping hints — concrete adapters (Slack, Telegram) override this
|
||||||
|
* variant to read {@code threadId} and route into the threading API.
|
||||||
|
*/
|
||||||
|
default void proactiveSend(String targetId, String content, DeliveryOptions options) {
|
||||||
|
proactiveSend(targetId, content);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前渠道是否支持主动推送
|
* 当前渠道是否支持主动推送
|
||||||
*
|
*
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.context.ChannelTarget;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.2: factory that translates an inbound channel message into a
|
||||||
|
* {@link ChatOrigin}. Lives in {@code vip.mate.channel} (not in
|
||||||
|
* {@code vip.mate.agent.context}) so that the dependency direction stays
|
||||||
|
* {@code channel → agent} and never the reverse.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ChannelChatOriginFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a {@link ChatOrigin} for a channel-originated message.
|
||||||
|
*
|
||||||
|
* @param channel channel entity (non-null) — provides id + workspaceId
|
||||||
|
* @param message inbound message (non-null) — provides senderId + reply target
|
||||||
|
* @param conversationId resolved conversation id (channel-scoped)
|
||||||
|
* @param workspaceBasePath workspace activity directory; null = unrestricted
|
||||||
|
*/
|
||||||
|
public ChatOrigin from(ChannelEntity channel,
|
||||||
|
ChannelMessage message,
|
||||||
|
String conversationId,
|
||||||
|
String workspaceBasePath) {
|
||||||
|
ChannelTarget target = new ChannelTarget(
|
||||||
|
resolveTargetId(message),
|
||||||
|
/* threadId */ null, // adapters fill via ChannelMessage extension fields when available
|
||||||
|
/* accountId */ null);
|
||||||
|
return new ChatOrigin(
|
||||||
|
/* agentId */ null,
|
||||||
|
/* conversationId */ conversationId,
|
||||||
|
/* requesterId */ message.getSenderId(),
|
||||||
|
/* workspaceId */ channel.getWorkspaceId(),
|
||||||
|
/* workspaceBasePath */ workspaceBasePath,
|
||||||
|
/* channelId */ channel.getId(),
|
||||||
|
/* channelTarget */ target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the IM target id used for proactive sends — prefer chatId
|
||||||
|
* (group/room) over senderId so that cron deliveries land in the same
|
||||||
|
* conversation the user originally messaged from.
|
||||||
|
*/
|
||||||
|
private String resolveTargetId(ChannelMessage message) {
|
||||||
|
if (message.getReplyToken() != null && !message.getReplyToken().isBlank()) {
|
||||||
|
return message.getReplyToken();
|
||||||
|
}
|
||||||
|
return message.getChatId() != null ? message.getChatId() : message.getSenderId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -330,13 +330,23 @@ public class ChannelManager {
|
|||||||
* @throws IllegalStateException 渠道未启动或不支持主动推送
|
* @throws IllegalStateException 渠道未启动或不支持主动推送
|
||||||
*/
|
*/
|
||||||
public void sendToChannel(Long channelId, String targetId, String content) {
|
public void sendToChannel(Long channelId, String targetId, String content) {
|
||||||
|
sendToChannel(channelId, targetId, content, DeliveryOptions.DEFAULTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: preferred overload — accepts a {@link DeliveryOptions}
|
||||||
|
* Parameter Object so cron delivery (and future callers) can pass
|
||||||
|
* Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
|
||||||
|
* {@code accountId}, etc. without growing a 5-arg signature.
|
||||||
|
*/
|
||||||
|
public void sendToChannel(Long channelId, String targetId, String content, DeliveryOptions options) {
|
||||||
ChannelAdapter adapter = getAdapter(channelId)
|
ChannelAdapter adapter = getAdapter(channelId)
|
||||||
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
|
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
|
||||||
if (!adapter.supportsProactiveSend()) {
|
if (!adapter.supportsProactiveSend()) {
|
||||||
throw new UnsupportedOperationException(
|
throw new UnsupportedOperationException(
|
||||||
"Channel " + adapter.getDisplayName() + " (" + adapter.getChannelType() + ") does not support proactive send");
|
"Channel " + adapter.getDisplayName() + " (" + adapter.getChannelType() + ") does not support proactive send");
|
||||||
}
|
}
|
||||||
adapter.proactiveSend(targetId, content);
|
adapter.proactiveSend(targetId, content, options != null ? options : DeliveryOptions.DEFAULTS);
|
||||||
log.info("Proactive message sent via channel {} to {}: {}chars",
|
log.info("Proactive message sent via channel {} to {}: {}chars",
|
||||||
adapter.getDisplayName(), targetId, content.length());
|
adapter.getDisplayName(), targetId, content.length());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
import vip.mate.approval.ResolveOutcome;
|
import vip.mate.approval.ResolveOutcome;
|
||||||
import vip.mate.approval.PendingApproval;
|
import vip.mate.approval.PendingApproval;
|
||||||
@ -54,6 +55,7 @@ public class ChannelMessageRouter {
|
|||||||
private final TtsService ttsService;
|
private final TtsService ttsService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
|
private final ChannelChatOriginFactory chatOriginFactory;
|
||||||
|
|
||||||
/** 队列条目:封装消息及其路由上下文 */
|
/** 队列条目:封装消息及其路由上下文 */
|
||||||
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||||
@ -98,7 +100,8 @@ public class ChannelMessageRouter {
|
|||||||
ConversationCompletionPublisher completionPublisher,
|
ConversationCompletionPublisher completionPublisher,
|
||||||
TtsService ttsService,
|
TtsService ttsService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
ChatStreamTracker streamTracker) {
|
ChatStreamTracker streamTracker,
|
||||||
|
ChannelChatOriginFactory chatOriginFactory) {
|
||||||
this.agentService = agentService;
|
this.agentService = agentService;
|
||||||
this.conversationService = conversationService;
|
this.conversationService = conversationService;
|
||||||
this.channelService = channelService;
|
this.channelService = channelService;
|
||||||
@ -109,6 +112,7 @@ public class ChannelMessageRouter {
|
|||||||
this.ttsService = ttsService;
|
this.ttsService = ttsService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
|
this.chatOriginFactory = chatOriginFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 防抖辅助类 ====================
|
// ==================== 防抖辅助类 ====================
|
||||||
@ -440,11 +444,17 @@ public class ChannelMessageRouter {
|
|||||||
Long savedAssistantId = null;
|
Long savedAssistantId = null;
|
||||||
try {
|
try {
|
||||||
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
|
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
|
||||||
|
// RFC-063r §2.5: build the ChatOrigin once per channel-message
|
||||||
|
// so cron jobs created during this conversation inherit the
|
||||||
|
// channel binding (Issue #25 root path).
|
||||||
|
ChatOrigin chatOrigin = chatOriginFactory.from(
|
||||||
|
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||||
|
|
||||||
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
||||||
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
|
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
|
||||||
} else {
|
} else {
|
||||||
// 同步路径:直接获取完整回复
|
// 同步路径:直接获取完整回复
|
||||||
String reply = agentService.chat(agentId, promptText, conversationId);
|
String reply = agentService.chat(agentId, promptText, conversationId, chatOrigin);
|
||||||
|
|
||||||
// 检查 chat 过程中是否产生了审批 pending
|
// 检查 chat 过程中是否产生了审批 pending
|
||||||
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
||||||
@ -521,14 +531,14 @@ public class ChannelMessageRouter {
|
|||||||
*/
|
*/
|
||||||
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
|
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
|
||||||
String conversationId, Long agentId, String promptText,
|
String conversationId, Long agentId, String promptText,
|
||||||
ChannelEntity channelEntity) {
|
ChannelEntity channelEntity, ChatOrigin chatOrigin) {
|
||||||
String channelType = streamingAdapter.getChannelType();
|
String channelType = streamingAdapter.getChannelType();
|
||||||
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
|
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: 产生事件流
|
// Step 1: 产生事件流(RFC-063r §2.5: forward ChatOrigin so tools see channelId)
|
||||||
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
|
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
|
||||||
agentId, promptText, conversationId, message.getSenderId());
|
agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
|
||||||
|
|
||||||
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
|
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
|
||||||
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
|
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
|
||||||
@ -591,8 +601,17 @@ public class ChannelMessageRouter {
|
|||||||
String replayPrompt = "继续执行已批准的工具调用。";
|
String replayPrompt = "继续执行已批准的工具调用。";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// RFC-063r §2.12: prefer the persisted Memento (covers
|
||||||
|
// cross-restart approval where the channel session changed) and
|
||||||
|
// only fall back to rebuilding from the current inbound message
|
||||||
|
// when no snapshot was captured (legacy rows from before this PR).
|
||||||
|
ChatOrigin replayOrigin = approvalService.restoreChatOrigin(consumed.getChatOrigin());
|
||||||
|
if (replayOrigin == ChatOrigin.EMPTY) {
|
||||||
|
replayOrigin = chatOriginFactory.from(
|
||||||
|
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
|
||||||
|
}
|
||||||
String reply = agentService.chatWithReplay(
|
String reply = agentService.chatWithReplay(
|
||||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload());
|
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
||||||
|
|
||||||
// 保存 replay 结果(这是正常结果,入库)
|
// 保存 replay 结果(这是正常结果,入库)
|
||||||
conversationService.saveMessage(conversationId, "assistant", reply);
|
conversationService.saveMessage(conversationId, "assistant", reply);
|
||||||
@ -644,7 +663,11 @@ public class ChannelMessageRouter {
|
|||||||
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
|
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
|
||||||
|
|
||||||
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
|
||||||
return agentService.chatStream(agentId, promptText, conversationId);
|
// RFC-063r §2.5: forward ChatOrigin so tools created during this
|
||||||
|
// streaming conversation inherit channel binding.
|
||||||
|
ChatOrigin origin = chatOriginFactory.from(
|
||||||
|
channelEntity, message, conversationId, /* workspaceBasePath */ null);
|
||||||
|
return agentService.chatStream(agentId, promptText, conversationId, origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 优雅关闭 ====================
|
// ==================== 优雅关闭 ====================
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: Parameter Object that bundles optional delivery hints
|
||||||
|
* (Slack {@code thread_ts}, Telegram {@code message_thread_id}, multi-bot
|
||||||
|
* {@code accountId}, etc.) so {@link ChannelManager#sendToChannel} doesn't
|
||||||
|
* grow a 5-arg overload.
|
||||||
|
*
|
||||||
|
* <p>{@link #DEFAULTS} is the canonical "no hints" instance — adapters that
|
||||||
|
* don't override the 4-arg {@code proactiveSend} keep their pre-RFC behavior.
|
||||||
|
*/
|
||||||
|
public record DeliveryOptions(
|
||||||
|
@Nullable String threadId,
|
||||||
|
@Nullable String accountId,
|
||||||
|
Map<String, Object> ext
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static final DeliveryOptions DEFAULTS = new DeliveryOptions(null, null, Map.of());
|
||||||
|
|
||||||
|
public DeliveryOptions {
|
||||||
|
// Defensive: never expose a null map — the receiver should be able to
|
||||||
|
// call .get(...) without a null check.
|
||||||
|
if (ext == null) ext = Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -238,6 +238,38 @@ public class SlackChannelAdapter extends AbstractChannelAdapter {
|
|||||||
sendMessage(targetId, content);
|
sendMessage(targetId, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: thread-aware proactive send. Reads
|
||||||
|
* {@link vip.mate.channel.DeliveryOptions#threadId()} (the Slack
|
||||||
|
* {@code thread_ts}) and posts into that thread when supplied; falls
|
||||||
|
* back to the legacy in-channel post when null.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void proactiveSend(String targetId, String content,
|
||||||
|
vip.mate.channel.DeliveryOptions options) {
|
||||||
|
if (options == null || options.threadId() == null || options.threadId().isBlank()) {
|
||||||
|
sendMessage(targetId, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String botToken = getConfigString("bot_token");
|
||||||
|
if (botToken == null || content == null || content.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String slackContent = convertToSlackMarkdown(content);
|
||||||
|
final String threadTs = options.threadId();
|
||||||
|
ChatPostMessageResponse response = slack.methods(botToken).chatPostMessage(req ->
|
||||||
|
req.channel(targetId).text(slackContent).threadTs(threadTs));
|
||||||
|
if (!response.isOk()) {
|
||||||
|
log.warn("[slack] Failed to send threaded proactive message (thread_ts={}): {}",
|
||||||
|
threadTs, response.getError());
|
||||||
|
}
|
||||||
|
} catch (IOException | SlackApiException e) {
|
||||||
|
log.error("[slack] Error sending threaded proactive message to {}: {}",
|
||||||
|
targetId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Webhook 回调处理(备用模式,Socket Mode 优先)
|
* Webhook 回调处理(备用模式,Socket Mode 优先)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -715,6 +715,80 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
|
|||||||
sendMessage(targetId, content);
|
sendMessage(targetId, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.10: forum-thread-aware proactive send. When
|
||||||
|
* {@link vip.mate.channel.DeliveryOptions#threadId()} is set (Telegram
|
||||||
|
* forum {@code message_thread_id}), include it in the {@code sendMessage}
|
||||||
|
* call so the cron result lands in the correct thread of a forum group.
|
||||||
|
* Falls back to the legacy chat-level send when null.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void proactiveSend(String targetId, String content,
|
||||||
|
vip.mate.channel.DeliveryOptions options) {
|
||||||
|
if (options == null || options.threadId() == null || options.threadId().isBlank()) {
|
||||||
|
sendMessage(targetId, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (httpClient == null || botToken == null) {
|
||||||
|
log.warn("[telegram] Channel not started, cannot send proactive message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Integer threadId;
|
||||||
|
try {
|
||||||
|
threadId = Integer.valueOf(options.threadId());
|
||||||
|
} catch (NumberFormatException nfe) {
|
||||||
|
log.warn("[telegram] Invalid message_thread_id '{}'; sending to main chat", options.threadId());
|
||||||
|
sendMessage(targetId, content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Try Markdown first, fall back to plain on parse error — same
|
||||||
|
// contract as sendMessage but with message_thread_id added.
|
||||||
|
if (!sendThreadedText(targetId, threadId, content, "Markdown")) {
|
||||||
|
sendThreadedText(targetId, threadId, content, null);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[telegram] proactiveSend(threadId={}) failed: {}", threadId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private boolean sendThreadedText(String targetId, Integer threadId, String content, String parseMode) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||||
|
body.put("chat_id", targetId);
|
||||||
|
body.put("message_thread_id", threadId);
|
||||||
|
body.put("text", content);
|
||||||
|
if (parseMode != null) {
|
||||||
|
body.put("parse_mode", parseMode);
|
||||||
|
}
|
||||||
|
String jsonBody = objectMapper.writeValueAsString(body);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(apiBaseUrl + "/sendMessage"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (response.statusCode() == 400 && parseMode != null) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> errResult = objectMapper.readValue(response.body(), Map.class);
|
||||||
|
String desc = String.valueOf(errResult.getOrDefault("description", ""));
|
||||||
|
if (desc.contains("can't parse")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
log.warn("[telegram] Threaded send failed: status={}, body={}", response.statusCode(), response.body());
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[telegram] Threaded send error: {}", e.getMessage(), e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getChannelType() {
|
public String getChannelType() {
|
||||||
return CHANNEL_TYPE;
|
return CHANNEL_TYPE;
|
||||||
|
|||||||
@ -283,8 +283,18 @@ public class ChatController {
|
|||||||
String replayPrompt = "继续执行已批准的工具调用。";
|
String replayPrompt = "继续执行已批准的工具调用。";
|
||||||
|
|
||||||
streamTracker.incrementFlux(conversationId);
|
streamTracker.incrementFlux(conversationId);
|
||||||
|
// RFC-063r §2.12: prefer the persisted Memento snapshot
|
||||||
|
// (covers cross-restart approval where the original channel
|
||||||
|
// is gone) and fall back to a fresh web-origin
|
||||||
|
// ChatOrigin when none was captured.
|
||||||
|
vip.mate.agent.context.ChatOrigin replayOrigin =
|
||||||
|
approvalService.restoreChatOrigin(finalConsumed.getChatOrigin());
|
||||||
|
if (replayOrigin == vip.mate.agent.context.ChatOrigin.EMPTY) {
|
||||||
|
replayOrigin = vip.mate.agent.context.ChatOrigin.web(
|
||||||
|
conversationId, username, workspaceId, null);
|
||||||
|
}
|
||||||
Disposable disposable = agentService.chatWithReplayStream(
|
Disposable disposable = agentService.chatWithReplayStream(
|
||||||
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username)
|
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
if (approvalEmitterDone.get()) return;
|
if (approvalEmitterDone.get()) return;
|
||||||
try {
|
try {
|
||||||
@ -460,7 +470,12 @@ public class ChatController {
|
|||||||
));
|
));
|
||||||
|
|
||||||
streamTracker.incrementFlux(conversationId);
|
streamTracker.incrementFlux(conversationId);
|
||||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel())
|
// RFC-063r §2.5: web entry — null channelId / no ChannelTarget;
|
||||||
|
// tools that need a workspace path read it from the agent (origin
|
||||||
|
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||||
|
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||||
|
vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
|
||||||
|
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
if (emitterDone.get()) return;
|
if (emitterDone.get()) return;
|
||||||
try {
|
try {
|
||||||
@ -1061,7 +1076,12 @@ public class ChatController {
|
|||||||
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
|
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
|
||||||
|
|
||||||
streamTracker.incrementFlux(conversationId);
|
streamTracker.incrementFlux(conversationId);
|
||||||
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId)
|
// RFC-063r §2.5: queued messages land in the same conversation; carry
|
||||||
|
// a web-origin ChatOrigin so any cron job created during the queued
|
||||||
|
// turn keeps a consistent (null-channel) binding.
|
||||||
|
vip.mate.agent.context.ChatOrigin queuedOrigin =
|
||||||
|
vip.mate.agent.context.ChatOrigin.web(conversationId, requesterId, null, null);
|
||||||
|
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, requesterId, null, queuedOrigin)
|
||||||
.doOnNext(delta -> {
|
.doOnNext(delta -> {
|
||||||
if (emitterDone.get()) return;
|
if (emitterDone.get()) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
package vip.mate.cron;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.context.ChannelTarget;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
import vip.mate.cron.model.DeliveryConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.2: factory that builds a {@link ChatOrigin} for a cron-triggered
|
||||||
|
* agent invocation. Lives in {@code vip.mate.cron} so the dependency arrow
|
||||||
|
* points {@code cron → agent} only — symmetric with
|
||||||
|
* {@code ChannelChatOriginFactory} in {@code vip.mate.channel}.
|
||||||
|
*
|
||||||
|
* <p>workspaceId is reverse-resolved from {@code agent.workspaceId} (with the
|
||||||
|
* legacy {@code 1L} fallback to keep behavior identical to
|
||||||
|
* {@code CronJobService.executeJob}). workspaceBasePath is intentionally not
|
||||||
|
* persisted — the value is derived at run time from the agent's workspace
|
||||||
|
* configuration, matching the previous {@code ToolExecutionContext.workspaceBasePath()}
|
||||||
|
* semantics.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CronChatOriginFactory {
|
||||||
|
|
||||||
|
private final AgentMapper agentMapper;
|
||||||
|
|
||||||
|
public ChatOrigin from(CronJobEntity job, String conversationId) {
|
||||||
|
AgentEntity agent = job.getAgentId() != null ? agentMapper.selectById(job.getAgentId()) : null;
|
||||||
|
Long workspaceId = agent != null && agent.getWorkspaceId() != null ? agent.getWorkspaceId() : 1L;
|
||||||
|
|
||||||
|
DeliveryConfig dc = job.getDeliveryConfig();
|
||||||
|
ChannelTarget target = dc != null ? dc.toChannelTarget() : null;
|
||||||
|
|
||||||
|
return ChatOrigin.cron(conversationId, workspaceId, /* workspaceBasePath */ null,
|
||||||
|
job.getChannelId(), target);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package vip.mate.cron.delivery;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import vip.mate.channel.ChannelMessageRenderer;
|
||||||
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
|
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.6.1: Template-Method base for {@link CronResultDelivery}.
|
||||||
|
*
|
||||||
|
* <p>Owns the cross-strategy invariants:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Idempotency</b> — atomic SQL CAS on
|
||||||
|
* {@code mate_cron_job_run.delivery_status} via {@link #claimRun} so
|
||||||
|
* only one listener instance proceeds per run row even across cluster
|
||||||
|
* deployments (replaces RFC-063 v1's process-local Caffeine cache).</li>
|
||||||
|
* <li><b>State-machine bookkeeping</b> — {@link #markDelivered} on success,
|
||||||
|
* {@link #markNotDelivered} on failure (truncated message via Hutool
|
||||||
|
* {@code StrUtil.maxLength} per RFC §2.6.1).</li>
|
||||||
|
* <li><b>Render hook</b> — {@link #renderForChannel} delegates to the
|
||||||
|
* project's existing {@link ChannelMessageRenderer} so per-channel
|
||||||
|
* markdown / Block Kit rendering stays consistent with the inline reply
|
||||||
|
* path.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Concrete subclasses implement only
|
||||||
|
* {@link #doDeliver(CronJobEntity, AssistantMessage, CronJobRunEntity)}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class AbstractCronResultDelivery implements CronResultDelivery {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default platform message-length cap used when the bound channel type
|
||||||
|
* is unknown — matches Telegram's 4096 ceiling, the most permissive
|
||||||
|
* among the small-cap platforms in {@link ChannelMessageRenderer#PLATFORM_LIMITS}.
|
||||||
|
* RFC-063r §2.11 will refine this in PR-4 by passing per-channel limits
|
||||||
|
* through {@code DeliveryOptions}.
|
||||||
|
*/
|
||||||
|
private static final int DEFAULT_RENDER_MAX_LEN = 4096;
|
||||||
|
|
||||||
|
private final CronJobRunMapper runMapper;
|
||||||
|
|
||||||
|
protected AbstractCronResultDelivery(CronJobRunMapper runMapper) {
|
||||||
|
this.runMapper = runMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final DeliveryOutcome deliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run) {
|
||||||
|
if (!claimRun(run)) {
|
||||||
|
return DeliveryOutcome.skipped("already-claimed-by-other-instance");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DeliveryOutcome outcome = doDeliver(job, result, run);
|
||||||
|
markDelivered(run, outcome);
|
||||||
|
return outcome;
|
||||||
|
} catch (Exception e) {
|
||||||
|
markNotDelivered(run, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strategy's actual delivery work — invoked after CAS success. */
|
||||||
|
protected abstract DeliveryOutcome doDeliver(
|
||||||
|
CronJobEntity job, AssistantMessage result, CronJobRunEntity run);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.11: filter thinking + tool-call markers and join the
|
||||||
|
* platform-truncated segments into a single string. Concrete strategies
|
||||||
|
* call this before handing the result to the channel-specific send call.
|
||||||
|
*
|
||||||
|
* <p>Null-safe — null/empty {@link AssistantMessage} returns "" so
|
||||||
|
* adapters never NPE.
|
||||||
|
*/
|
||||||
|
protected String renderForChannel(AssistantMessage msg, Long channelId) {
|
||||||
|
if (msg == null) return "";
|
||||||
|
String text = msg.getText() != null ? msg.getText() : "";
|
||||||
|
if (text.isEmpty()) return "";
|
||||||
|
try {
|
||||||
|
List<String> segments = ChannelMessageRenderer.renderForChannel(
|
||||||
|
text, /* filterThinking */ true, /* filterToolMessages */ true,
|
||||||
|
/* messageFormat */ null, DEFAULT_RENDER_MAX_LEN);
|
||||||
|
return segments.isEmpty() ? "" : String.join("\n\n", segments);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[CronDelivery] renderForChannel failed (channelId={}); falling back to raw text: {}",
|
||||||
|
channelId, e.getMessage());
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SQL state-machine helpers ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic SQL CAS: transition delivery_status from {@code NONE} or
|
||||||
|
* {@code PENDING} → {@code PENDING}. Returns true iff this instance won
|
||||||
|
* the race. NONE-eligibility lets fresh runs claim without a separate
|
||||||
|
* "first-time" branch; PENDING-eligibility covers the rare same-instance
|
||||||
|
* retry inside the listener (cluster paths can't normally hit this).
|
||||||
|
*/
|
||||||
|
private boolean claimRun(CronJobRunEntity run) {
|
||||||
|
return runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
.eq(CronJobRunEntity::getId, run.getId())
|
||||||
|
.in(CronJobRunEntity::getDeliveryStatus, "NONE", "PENDING", null)
|
||||||
|
.set(CronJobRunEntity::getDeliveryStatus, "PENDING")) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markDelivered(CronJobRunEntity run, DeliveryOutcome o) {
|
||||||
|
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
.eq(CronJobRunEntity::getId, run.getId())
|
||||||
|
.set(CronJobRunEntity::getDeliveryStatus, "DELIVERED")
|
||||||
|
.set(CronJobRunEntity::getDeliveryTarget, o.target()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markNotDelivered(CronJobRunEntity run, Exception e) {
|
||||||
|
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
.eq(CronJobRunEntity::getId, run.getId())
|
||||||
|
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
|
||||||
|
.set(CronJobRunEntity::getDeliveryError, StrUtil.maxLength(e.getMessage(), 500)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package vip.mate.cron.delivery;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.channel.ChannelManager;
|
||||||
|
import vip.mate.channel.DeliveryOptions;
|
||||||
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
import vip.mate.cron.model.DeliveryConfig;
|
||||||
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
|
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.6: deliver a cron job's assistant result back to its
|
||||||
|
* originating IM channel via {@link ChannelManager#sendToChannel}.
|
||||||
|
*
|
||||||
|
* <p>{@link #supports} returns true only when both {@code channelId} and a
|
||||||
|
* non-null {@code deliveryConfig.targetId()} are present — web-origin jobs
|
||||||
|
* (no channelId) and partial bindings fall through and the run stays in
|
||||||
|
* {@code delivery_status='NONE'}, matching the always-best-effort policy in
|
||||||
|
* RFC §2.7.3.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(10)
|
||||||
|
public class ChannelCronResultDelivery extends AbstractCronResultDelivery {
|
||||||
|
|
||||||
|
private final ChannelManager channelManager;
|
||||||
|
|
||||||
|
public ChannelCronResultDelivery(CronJobRunMapper runMapper,
|
||||||
|
ChannelManager channelManager) {
|
||||||
|
super(runMapper);
|
||||||
|
this.channelManager = channelManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(CronJobEntity job) {
|
||||||
|
if (job == null || job.getChannelId() == null) return false;
|
||||||
|
DeliveryConfig dc = job.getDeliveryConfig();
|
||||||
|
return dc != null && dc.targetId() != null && !dc.targetId().isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected DeliveryOutcome doDeliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run) {
|
||||||
|
DeliveryConfig dc = job.getDeliveryConfig();
|
||||||
|
String rendered = renderForChannel(result, job.getChannelId());
|
||||||
|
// RFC-063r §2.10: forward thread / account hints via DeliveryOptions.
|
||||||
|
// Adapters that don't override the 4-arg proactiveSend default ignore
|
||||||
|
// the hints — preserves pre-RFC behavior for non-threading platforms.
|
||||||
|
DeliveryOptions options = new DeliveryOptions(dc.threadId(), dc.accountId(), Map.of());
|
||||||
|
channelManager.sendToChannel(job.getChannelId(), dc.targetId(), rendered, options);
|
||||||
|
return DeliveryOutcome.delivered(dc.targetId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
package vip.mate.cron.delivery;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
import vip.mate.audit.service.AuditEventService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.7.3: Domain-Event listener that resolves the right
|
||||||
|
* {@link CronResultDelivery} strategy and runs it asynchronously after the
|
||||||
|
* cron run's T2 transaction commits.
|
||||||
|
*
|
||||||
|
* <p>Why {@code AFTER_COMMIT} + {@code @Async}:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code AFTER_COMMIT} guarantees the listener can re-read the run
|
||||||
|
* row from a fresh DB connection — eliminates the read-your-writes
|
||||||
|
* trap from the same-tx delivery path.</li>
|
||||||
|
* <li>{@code @Async("cronDeliveryExecutor")} unbinds delivery from the
|
||||||
|
* Spring event-dispatcher thread so a slow IM API never stalls other
|
||||||
|
* listeners. The pool uses {@code AbortPolicy} + audit so an overflow
|
||||||
|
* surfaces immediately rather than degrading silently.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CronDeliveryListener {
|
||||||
|
|
||||||
|
/** Spring auto-collects every {@link CronResultDelivery} bean ordered by {@code @Order}. */
|
||||||
|
private final List<CronResultDelivery> deliveries;
|
||||||
|
private final AuditEventService audit;
|
||||||
|
|
||||||
|
@Async("cronDeliveryExecutor")
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||||
|
public void onCompleted(CronJobCompletedEvent ev) {
|
||||||
|
Optional<CronResultDelivery> strategy = deliveries.stream()
|
||||||
|
.filter(d -> d.supports(ev.job()))
|
||||||
|
.findFirst();
|
||||||
|
if (strategy.isEmpty()) {
|
||||||
|
// RFC §2.6 / §2.8.1: web-origin runs (or any job without a
|
||||||
|
// matching strategy) leave delivery_status=NONE and exit silently.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
strategy.get().deliver(ev.job(), ev.result(), ev.run());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// RFC §2.7.3: always-best-effort — failures audit but never flip
|
||||||
|
// the run's main status. Stale-pending cleanup tightens the
|
||||||
|
// delivery_status state machine if the row gets stuck.
|
||||||
|
log.warn("[CronDelivery] Delivery failed for job {}: {}",
|
||||||
|
ev.job() != null ? ev.job().getId() : null, e.getMessage());
|
||||||
|
try {
|
||||||
|
audit.record("DELIVERY_FAILED", "CRON_JOB",
|
||||||
|
ev.job() != null ? String.valueOf(ev.job().getId()) : "unknown",
|
||||||
|
e.getMessage(), null);
|
||||||
|
} catch (Exception auditError) {
|
||||||
|
log.warn("[CronDelivery] Audit recording failed: {}", auditError.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener hook for unit tests bypassing the {@code @TransactionalEventListener}
|
||||||
|
* proxy — direct {@code ApplicationEventPublisher.publishEvent} delivery.
|
||||||
|
* Production paths always go through the AFTER_COMMIT bridge.
|
||||||
|
*/
|
||||||
|
@EventListener
|
||||||
|
public void onCompletedRaw(CronJobCompletedEvent ev) {
|
||||||
|
// No-op: the @TransactionalEventListener above is the production path
|
||||||
|
// (it only fires when a transaction is active and after it commits).
|
||||||
|
// This raw @EventListener exists so unit tests using fallbackExecution
|
||||||
|
// do not double-fire and so the listener bean stays scannable in
|
||||||
|
// contexts without a tx manager. Intentionally empty.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.7.3: dedicated executor for cron delivery. Defined here so
|
||||||
|
* the listener and its pool live in the same module without an extra
|
||||||
|
* {@code @Configuration} class.
|
||||||
|
*/
|
||||||
|
@org.springframework.context.annotation.Bean(name = "cronDeliveryExecutor")
|
||||||
|
public ThreadPoolTaskExecutor cronDeliveryExecutor() {
|
||||||
|
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
|
||||||
|
ex.setCorePoolSize(2);
|
||||||
|
ex.setMaxPoolSize(4);
|
||||||
|
ex.setQueueCapacity(1000);
|
||||||
|
ex.setThreadNamePrefix("cron-delivery-");
|
||||||
|
ex.setRejectedExecutionHandler((r, executor) -> {
|
||||||
|
// RFC §2.7.3: AbortPolicy + audit (NOT CallerRunsPolicy) — the
|
||||||
|
// caller is the Spring event-dispatcher thread; blocking it would
|
||||||
|
// stall every other listener.
|
||||||
|
log.error("[CronDelivery] queue overflow ({}); dropping task — review pool sizing",
|
||||||
|
executor.getQueue().size());
|
||||||
|
try {
|
||||||
|
audit.record("DELIVERY_QUEUE_OVERFLOW", "CRON_DELIVERY", "system",
|
||||||
|
"active=" + executor.getActiveCount() + ",queue=" + executor.getQueue().size(),
|
||||||
|
null);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Audit must never block the rejection path itself.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ex.initialize();
|
||||||
|
return ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package vip.mate.cron.delivery;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.7.3: domain event fired by
|
||||||
|
* {@code CronJobLifecycleService.finishRunAndPublish} after T2 commits.
|
||||||
|
* Listeners run with {@code @TransactionalEventListener(AFTER_COMMIT)}, so
|
||||||
|
* the run row is guaranteed visible from a fresh DB connection.
|
||||||
|
*/
|
||||||
|
public record CronJobCompletedEvent(
|
||||||
|
CronJobEntity job,
|
||||||
|
AssistantMessage result,
|
||||||
|
CronJobRunEntity run) {
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package vip.mate.cron.delivery;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.6: pluggable strategy for delivering a cron job's assistant
|
||||||
|
* result back to its originating context (channel, future webhook, future
|
||||||
|
* SSE bridge, etc.).
|
||||||
|
*
|
||||||
|
* <p>{@link AbstractCronResultDelivery} provides the SQL-CAS idempotency +
|
||||||
|
* state-machine update; concrete strategies only implement
|
||||||
|
* {@link AbstractCronResultDelivery#doDeliver}.
|
||||||
|
*/
|
||||||
|
public interface CronResultDelivery {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this strategy applies to the given job. The first matching
|
||||||
|
* strategy (per {@code @Order}) wins; web-origin jobs match nothing,
|
||||||
|
* leaving {@code delivery_status='NONE'}.
|
||||||
|
*/
|
||||||
|
boolean supports(CronJobEntity job);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the delivery, internally claiming the run row via SQL CAS and
|
||||||
|
* updating {@code delivery_status} on success / failure. Implementations
|
||||||
|
* should override
|
||||||
|
* {@link AbstractCronResultDelivery#doDeliver(CronJobEntity, AssistantMessage, CronJobRunEntity)}
|
||||||
|
* rather than this method directly.
|
||||||
|
*/
|
||||||
|
DeliveryOutcome deliver(CronJobEntity job, AssistantMessage result, CronJobRunEntity run);
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package vip.mate.cron.delivery;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
|
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.8.2: periodic sweep that recovers two stuck states with one
|
||||||
|
* scheduled job.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code delivery_status=PENDING} older than 15 min → mark
|
||||||
|
* {@code NOT_DELIVERED} with {@code stale-pending-timeout} reason.
|
||||||
|
* Covers listener crashes / OOMs / forced kills after a successful
|
||||||
|
* {@code claimRun()} but before {@code markDelivered}.</li>
|
||||||
|
* <li>{@code status='running'} older than 30 min → mark {@code failed}
|
||||||
|
* with {@code stale-running-timeout}. Covers
|
||||||
|
* {@code CronJobLifecycleService.markRunFailed()} itself failing under
|
||||||
|
* DB jitter (the LLM call already terminated by then).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Single sweep, two predicates, one DB roundtrip per state. Spring
|
||||||
|
* {@code @Scheduled} is already enabled at the application bootstrap.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CronRunStaleCleanup {
|
||||||
|
|
||||||
|
private final CronJobRunMapper runMapper;
|
||||||
|
|
||||||
|
private static final Duration DELIVERY_STALE = Duration.ofMinutes(15);
|
||||||
|
private static final Duration RUN_STALE = Duration.ofMinutes(30);
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 5 * 60 * 1000L, initialDelay = 60 * 1000L)
|
||||||
|
public void sweep() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
int stalePending = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
.eq(CronJobRunEntity::getDeliveryStatus, "PENDING")
|
||||||
|
.lt(CronJobRunEntity::getStartedAt, now.minus(DELIVERY_STALE))
|
||||||
|
.set(CronJobRunEntity::getDeliveryStatus, "NOT_DELIVERED")
|
||||||
|
.set(CronJobRunEntity::getDeliveryError, "stale-pending-timeout"));
|
||||||
|
|
||||||
|
int staleRunning = runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
.eq(CronJobRunEntity::getStatus, "running")
|
||||||
|
.lt(CronJobRunEntity::getStartedAt, now.minus(RUN_STALE))
|
||||||
|
.set(CronJobRunEntity::getStatus, "failed")
|
||||||
|
.set(CronJobRunEntity::getFinishedAt, now)
|
||||||
|
.set(CronJobRunEntity::getErrorMessage, "stale-running-timeout"));
|
||||||
|
|
||||||
|
if (stalePending > 0 || staleRunning > 0) {
|
||||||
|
log.warn("[CronCleanup] swept stalePending={} staleRunning={}", stalePending, staleRunning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package vip.mate.cron.delivery;
|
||||||
|
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.6.0: outcome of a single cron-result delivery attempt.
|
||||||
|
*
|
||||||
|
* <p>Two states only — {@code DELIVERED} and {@code SKIPPED}. There is no
|
||||||
|
* {@code FAILED} state; failures throw from
|
||||||
|
* {@link AbstractCronResultDelivery#doDeliver}, get marked
|
||||||
|
* {@code NOT_DELIVERED} on the run row by the template method, and surface
|
||||||
|
* to the listener as exceptions for audit. Single-purpose return semantics.
|
||||||
|
*/
|
||||||
|
public record DeliveryOutcome(Status status,
|
||||||
|
@Nullable String target,
|
||||||
|
@Nullable String reason) {
|
||||||
|
|
||||||
|
public enum Status { DELIVERED, SKIPPED }
|
||||||
|
|
||||||
|
public static DeliveryOutcome delivered(String target) {
|
||||||
|
return new DeliveryOutcome(Status.DELIVERED, target, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DeliveryOutcome skipped(String reason) {
|
||||||
|
return new DeliveryOutcome(Status.SKIPPED, null, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,22 @@ public class CronJobDTO {
|
|||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
/** RFC-063r §2.9: originating channel binding (null = web-origin cron). */
|
||||||
|
private Long channelId;
|
||||||
|
|
||||||
|
/** RFC-063r §2.9: delivery target detail (targetId / threadId / accountId). */
|
||||||
|
private DeliveryConfig deliveryConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.14: read-model field surfaced by CronJobMapper#selectListWithDeliveryStatus
|
||||||
|
* (PR-3). One of NONE / PENDING / DELIVERED / NOT_DELIVERED, taken from
|
||||||
|
* the most-recent run row. Out-only — never accepted on create/update.
|
||||||
|
*/
|
||||||
|
private String lastDeliveryStatus;
|
||||||
|
|
||||||
|
/** RFC-063r §2.14: out-only error detail for the most-recent delivery attempt. */
|
||||||
|
private String lastDeliveryError;
|
||||||
|
|
||||||
public static CronJobDTO from(CronJobEntity entity) {
|
public static CronJobDTO from(CronJobEntity entity) {
|
||||||
CronJobDTO dto = new CronJobDTO();
|
CronJobDTO dto = new CronJobDTO();
|
||||||
dto.setId(entity.getId());
|
dto.setId(entity.getId());
|
||||||
@ -43,6 +59,15 @@ public class CronJobDTO {
|
|||||||
dto.setLastRunTime(entity.getLastRunTime());
|
dto.setLastRunTime(entity.getLastRunTime());
|
||||||
dto.setCreateTime(entity.getCreateTime());
|
dto.setCreateTime(entity.getCreateTime());
|
||||||
dto.setUpdateTime(entity.getUpdateTime());
|
dto.setUpdateTime(entity.getUpdateTime());
|
||||||
|
dto.setChannelId(entity.getChannelId());
|
||||||
|
dto.setDeliveryConfig(entity.getDeliveryConfig());
|
||||||
|
// RFC-063r §2.14: surface the latest-run delivery snapshot when the
|
||||||
|
// entity was loaded via selectListWithDeliveryStatus / selectByIdWithDeliveryStatus.
|
||||||
|
// Default "NONE" when no run has ever been recorded so the UI can
|
||||||
|
// render a neutral badge instead of a blank cell.
|
||||||
|
dto.setLastDeliveryStatus(entity.getLastDeliveryStatus() != null
|
||||||
|
? entity.getLastDeliveryStatus() : "NONE");
|
||||||
|
dto.setLastDeliveryError(entity.getLastDeliveryError());
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +88,8 @@ public class CronJobDTO {
|
|||||||
entity.setTriggerMessage(this.triggerMessage);
|
entity.setTriggerMessage(this.triggerMessage);
|
||||||
entity.setRequestBody(this.requestBody);
|
entity.setRequestBody(this.requestBody);
|
||||||
entity.setEnabled(this.enabled);
|
entity.setEnabled(this.enabled);
|
||||||
|
entity.setChannelId(this.channelId);
|
||||||
|
entity.setDeliveryConfig(this.deliveryConfig);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package vip.mate.cron.model;
|
package vip.mate.cron.model;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.*;
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@ -11,7 +12,7 @@ import java.time.LocalDateTime;
|
|||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@TableName("mate_cron_job")
|
@TableName(value = "mate_cron_job", autoResultMap = true)
|
||||||
public class CronJobEntity {
|
public class CronJobEntity {
|
||||||
|
|
||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
@ -49,6 +50,21 @@ public class CronJobEntity {
|
|||||||
/** 上次执行时间 */
|
/** 上次执行时间 */
|
||||||
private LocalDateTime lastRunTime;
|
private LocalDateTime lastRunTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.9: originating channel binding. Null when this job was
|
||||||
|
* created from the web (no proactive delivery target). The single
|
||||||
|
* indexed column lets ops query "all jobs delivering to channel X".
|
||||||
|
*/
|
||||||
|
private Long channelId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.9: delivery target detail (targetId / threadId / accountId)
|
||||||
|
* persisted as JSON via MyBatis Plus JacksonTypeHandler so future fields
|
||||||
|
* don't require schema migrations.
|
||||||
|
*/
|
||||||
|
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||||
|
private DeliveryConfig deliveryConfig;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
@ -56,4 +72,16 @@ public class CronJobEntity {
|
|||||||
private LocalDateTime updateTime;
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.14: read-model field — populated by
|
||||||
|
* {@code CronJobMapper.selectListWithDeliveryStatus()} via a subquery
|
||||||
|
* against {@code mate_cron_job_run}. Not part of the writable schema.
|
||||||
|
*/
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String lastDeliveryStatus;
|
||||||
|
|
||||||
|
/** RFC-063r §2.14: matching error column for the most-recent run. */
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String lastDeliveryError;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
package vip.mate.cron.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import vip.mate.agent.context.ChannelTarget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.9: per-cron-job delivery configuration. Persisted as a JSON
|
||||||
|
* column on {@code mate_cron_job.delivery_config} via MyBatis Plus
|
||||||
|
* {@code JacksonTypeHandler}. Mirrors {@link ChannelTarget} but lives in the
|
||||||
|
* cron module so {@code CronJobEntity} doesn't need to depend on the channel
|
||||||
|
* value object directly.
|
||||||
|
*
|
||||||
|
* <p>{@link JsonIgnoreProperties#ignoreUnknown()} keeps deserialization
|
||||||
|
* forward-compatible when older rows are read after a column-add upgrade.
|
||||||
|
*/
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record DeliveryConfig(
|
||||||
|
@Nullable String targetId,
|
||||||
|
@Nullable String threadId,
|
||||||
|
@Nullable String accountId
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Convert from the {@link ChannelTarget} carried on a {@code ChatOrigin}. */
|
||||||
|
public static DeliveryConfig from(@Nullable ChannelTarget t) {
|
||||||
|
if (t == null) return null;
|
||||||
|
return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert back to a {@link ChannelTarget} for ChatOrigin reconstruction. */
|
||||||
|
public ChannelTarget toChannelTarget() {
|
||||||
|
return new ChannelTarget(targetId, threadId, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,8 +2,12 @@ package vip.mate.cron.repository;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
import vip.mate.cron.model.CronJobEntity;
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定时任务 Mapper
|
* 定时任务 Mapper
|
||||||
*
|
*
|
||||||
@ -11,4 +15,47 @@ import vip.mate.cron.model.CronJobEntity;
|
|||||||
*/
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
public interface CronJobMapper extends BaseMapper<CronJobEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.14: list cron jobs together with their most-recent
|
||||||
|
* delivery status / error (subquery against {@code mate_cron_job_run}).
|
||||||
|
*
|
||||||
|
* <p>Both H2 and MySQL accept {@code LIMIT 1} inside a correlated
|
||||||
|
* subquery, so the same SQL is portable across the two profiles. Index
|
||||||
|
* coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created
|
||||||
|
* by V1 baseline migration) makes the subquery cheap.
|
||||||
|
*
|
||||||
|
* <p>Filters out logically-deleted rows and orders by create_time DESC
|
||||||
|
* to mirror the existing {@code list()} ordering.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT j.*,
|
||||||
|
(SELECT r.delivery_status FROM mate_cron_job_run r
|
||||||
|
WHERE r.cron_job_id = j.id
|
||||||
|
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_status,
|
||||||
|
(SELECT r.delivery_error FROM mate_cron_job_run r
|
||||||
|
WHERE r.cron_job_id = j.id
|
||||||
|
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||||
|
FROM mate_cron_job j
|
||||||
|
WHERE j.deleted = 0
|
||||||
|
ORDER BY j.create_time DESC
|
||||||
|
""")
|
||||||
|
List<CronJobEntity> selectListWithDeliveryStatus();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.14: per-job variant for the detail page. Same subquery
|
||||||
|
* pattern, restricted to a single id.
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
SELECT j.*,
|
||||||
|
(SELECT r.delivery_status FROM mate_cron_job_run r
|
||||||
|
WHERE r.cron_job_id = j.id
|
||||||
|
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_status,
|
||||||
|
(SELECT r.delivery_error FROM mate_cron_job_run r
|
||||||
|
WHERE r.cron_job_id = j.id
|
||||||
|
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
|
||||||
|
FROM mate_cron_job j
|
||||||
|
WHERE j.id = #{id} AND j.deleted = 0
|
||||||
|
""")
|
||||||
|
CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,128 @@
|
|||||||
|
package vip.mate.cron.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
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.cron.delivery.CronJobCompletedEvent;
|
||||||
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
|
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||||
|
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||||
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.7.2: three-segment transactional support for {@link CronJobRunner}.
|
||||||
|
*
|
||||||
|
* <p>Each method runs in its own short {@code REQUIRES_NEW} transaction so
|
||||||
|
* the long LLM call between T1 and T2 never holds a DB connection.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code T1} — {@link #startRun}: insert run row + persist user message.</li>
|
||||||
|
* <li>{@code T-fail} — {@link #markRunFailed}: terminal state when the LLM
|
||||||
|
* call throws.</li>
|
||||||
|
* <li>{@code T2} — {@link #finishRunAndPublish}: persist assistant
|
||||||
|
* message + dispatch the conversation-completed event (memory pipeline)
|
||||||
|
* + the cron-job-completed event (delivery pipeline).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Lives in a separate {@code @Service} bean so cross-bean invocation from
|
||||||
|
* {@link CronJobRunner} routes through the Spring AOP proxy (RFC §5.2 hard
|
||||||
|
* rule). The lifecycle service is deliberately the only place
|
||||||
|
* {@code @Transactional} appears in the cron-execution path.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CronJobLifecycleService {
|
||||||
|
|
||||||
|
private final CronJobRunMapper runMapper;
|
||||||
|
private final ConversationService conversationService;
|
||||||
|
private final ConversationCompletionPublisher completionPublisher;
|
||||||
|
private final ApplicationEventPublisher events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T1 — short transaction: persist a run row in {@code running} state,
|
||||||
|
* persist the user message that triggered the run, and commit. Returns
|
||||||
|
* the persisted entity so callers can observe its assigned id without a
|
||||||
|
* second SELECT.
|
||||||
|
*
|
||||||
|
* @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType) {
|
||||||
|
CronJobRunEntity run = new CronJobRunEntity();
|
||||||
|
run.setCronJobId(job.getId());
|
||||||
|
run.setConversationId("cron:" + job.getId());
|
||||||
|
run.setStatus("running");
|
||||||
|
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
|
||||||
|
run.setStartedAt(LocalDateTime.now());
|
||||||
|
run.setDeliveryStatus("NONE");
|
||||||
|
runMapper.insert(run);
|
||||||
|
|
||||||
|
// Persist the user message before the LLM call so history reads
|
||||||
|
// see a coherent (user → assistant) ordering even if the agent
|
||||||
|
// throws mid-run.
|
||||||
|
if (userMessage != null && !userMessage.isBlank()) {
|
||||||
|
conversationService.saveMessage(run.getConversationId(), "user", userMessage);
|
||||||
|
}
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T-fail — short transaction: flag the run row as failed when the agent
|
||||||
|
* throws. Always-best-effort policy: delivery_status stays NONE; nothing
|
||||||
|
* is published.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void markRunFailed(CronJobRunEntity run, Throwable error) {
|
||||||
|
String message = error != null && error.getMessage() != null ? error.getMessage() : "unknown error";
|
||||||
|
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
.eq(CronJobRunEntity::getId, run.getId())
|
||||||
|
.set(CronJobRunEntity::getStatus, "failed")
|
||||||
|
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now())
|
||||||
|
.set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T2 — short transaction: persist the assistant reply, mark the run
|
||||||
|
* succeeded, then publish the two domain events. The
|
||||||
|
* {@code @TransactionalEventListener(AFTER_COMMIT)} listeners only run
|
||||||
|
* once this method's tx commits, so cross-connection reads in the
|
||||||
|
* delivery / memory pipelines always see the final state.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
|
||||||
|
String userMessage, AssistantMessage result) {
|
||||||
|
String convId = "cron:" + job.getId();
|
||||||
|
String text = result != null && result.getText() != null ? result.getText() : "";
|
||||||
|
|
||||||
|
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
.eq(CronJobRunEntity::getId, run.getId())
|
||||||
|
.set(CronJobRunEntity::getStatus, "succeeded")
|
||||||
|
.set(CronJobRunEntity::getFinishedAt, LocalDateTime.now()));
|
||||||
|
|
||||||
|
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
|
||||||
|
// web / channel paths so cron is no longer special-cased).
|
||||||
|
try {
|
||||||
|
completionPublisher.publish(job.getAgentId(), convId, userMessage, text, "cron");
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Memory failures must not break delivery — log + carry on.
|
||||||
|
log.warn("[CronLifecycle] completionPublisher failed for job {}: {}", job.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery pipeline (RFC-063r §2.7.3) — fired here so listeners only
|
||||||
|
// run after T2 commits.
|
||||||
|
events.publishEvent(new CronJobCompletedEvent(job, result != null ? result : new AssistantMessage(""), run));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
package vip.mate.cron.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.cron.CronChatOriginFactory;
|
||||||
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.7.1: scheduler-facing orchestrator that decomposes one cron
|
||||||
|
* tick into the three transactional segments owned by
|
||||||
|
* {@link CronJobLifecycleService}, with the long-running LLM call sitting
|
||||||
|
* <em>outside</em> any transaction.
|
||||||
|
*
|
||||||
|
* <p><b>This class must NOT be annotated {@code @Transactional}</b> — see
|
||||||
|
* RFC-063r §5.2:
|
||||||
|
* <ul>
|
||||||
|
* <li>The class is the entry point invoked by the
|
||||||
|
* {@code ThreadPoolTaskScheduler}'s lambda; the LLM HTTP call inside
|
||||||
|
* {@link #runAgent} is seconds-to-minutes long and must not hold a DB
|
||||||
|
* connection.</li>
|
||||||
|
* <li>Self-invocation in the legacy {@code CronJobService} silently
|
||||||
|
* skipped {@code @Transactional}; that pattern is forbidden here.</li>
|
||||||
|
* <li>An {@code ArchUnit} test (see PR-3) pins this rule so a future
|
||||||
|
* regression fails CI.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The three transactional segments live on
|
||||||
|
* {@link CronJobLifecycleService} (a separate bean), so cross-bean calls
|
||||||
|
* route through Spring AOP and {@code REQUIRES_NEW} works as advertised.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CronJobRunner {
|
||||||
|
|
||||||
|
private final CronJobLifecycleService lifecycle;
|
||||||
|
private final AgentService agentService;
|
||||||
|
private final CronChatOriginFactory originFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduler-facing entry. Runs three logical segments:
|
||||||
|
* <ol>
|
||||||
|
* <li>T1 — {@link CronJobLifecycleService#startRun} commits run row +
|
||||||
|
* user message.</li>
|
||||||
|
* <li>Untransacted — {@link #runAgent} performs the LLM call.</li>
|
||||||
|
* <li>T2 — {@link CronJobLifecycleService#finishRunAndPublish} commits
|
||||||
|
* assistant message and publishes the completion / delivery
|
||||||
|
* events. {@code AFTER_COMMIT} listeners fire from a fresh DB
|
||||||
|
* connection, so {@link CronJobRunEntity}'s persisted state is
|
||||||
|
* always visible by the time delivery resolves a strategy.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public void executeJob(CronJobEntity job) {
|
||||||
|
executeJob(job, /* triggerType */ "scheduled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variant with explicit trigger type — used by {@code runNow} (manual). */
|
||||||
|
public void executeJob(CronJobEntity job, String triggerType) {
|
||||||
|
if (job == null) {
|
||||||
|
log.warn("[CronRunner] executeJob called with null job — ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String userMessage = "agent".equals(job.getTaskType())
|
||||||
|
? job.getRequestBody()
|
||||||
|
: job.getTriggerMessage();
|
||||||
|
|
||||||
|
// T1 — short tx
|
||||||
|
CronJobRunEntity run;
|
||||||
|
try {
|
||||||
|
run = lifecycle.startRun(job, userMessage, triggerType);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-tx segment — long LLM call. RFC §5.2 hard rule: must not hold
|
||||||
|
// any DB connection during this call.
|
||||||
|
AssistantMessage result;
|
||||||
|
try {
|
||||||
|
ChatOrigin origin = originFactory.from(job, "cron:" + job.getId());
|
||||||
|
result = runAgent(job, userMessage, origin);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||||
|
try {
|
||||||
|
lifecycle.markRunFailed(run, e);
|
||||||
|
} catch (Exception markErr) {
|
||||||
|
// CronRunStaleCleanup will sweep status='running' rows older than 30 min.
|
||||||
|
log.warn("[CronRunner] markRunFailed itself failed for run {}: {} (stale-cleanup will recover)",
|
||||||
|
run.getId(), markErr.getMessage());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// T2 — short tx
|
||||||
|
try {
|
||||||
|
lifecycle.finishRunAndPublish(job, run, userMessage, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||||
|
try {
|
||||||
|
lifecycle.markRunFailed(run, e);
|
||||||
|
} catch (Exception markErr) {
|
||||||
|
log.warn("[CronRunner] markRunFailed after T2 failure also failed for run {}: {}",
|
||||||
|
run.getId(), markErr.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the agent with the cron-derived {@link ChatOrigin} and the
|
||||||
|
* RFC-063r §2.13 system-prompt guard prepended when the cron is bound to
|
||||||
|
* a channel — fixes the Issue #25 LLM hallucination ("install
|
||||||
|
* mateclaw cli to send to wechat") by telling the model that delivery is
|
||||||
|
* framework-handled.
|
||||||
|
*/
|
||||||
|
private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin) {
|
||||||
|
String guarded = wrapWithDeliveryGuard(userMessage, origin);
|
||||||
|
String text = "agent".equals(job.getTaskType())
|
||||||
|
? agentService.execute(job.getAgentId(), guarded, "cron:" + job.getId(), origin)
|
||||||
|
: agentService.chat(job.getAgentId(), guarded, "cron:" + job.getId(), origin);
|
||||||
|
return new AssistantMessage(text != null ? text : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.13: when the cron is bound to a channel, prepend an
|
||||||
|
* explicit system note telling the LLM that delivery is handled by the
|
||||||
|
* framework. Without this, the model invents tools ("call CLI to send
|
||||||
|
* to wechat") and surfaces "command not found" style errors to users
|
||||||
|
* (Issue #25 second symptom).
|
||||||
|
*
|
||||||
|
* <p>Web-origin crons (no channelId) bypass the wrapper so the
|
||||||
|
* pre-RFC behavior is preserved.
|
||||||
|
*/
|
||||||
|
static String wrapWithDeliveryGuard(String userMessage, ChatOrigin origin) {
|
||||||
|
String body = userMessage != null ? userMessage : "";
|
||||||
|
if (origin == null || origin.channelId() == null) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
return """
|
||||||
|
[系统说明]
|
||||||
|
本次执行由定时任务触发,结果将由系统自动投递回原渠道,
|
||||||
|
你只需直接给出最终回复内容,不要尝试调用 CLI / shell /
|
||||||
|
"发送到微信"等工具——这些操作由框架完成。
|
||||||
|
|
||||||
|
[用户原始消息]
|
||||||
|
""" + body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,15 +12,12 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
|||||||
import org.springframework.scheduling.support.CronExpression;
|
import org.springframework.scheduling.support.CronExpression;
|
||||||
import org.springframework.scheduling.support.CronTrigger;
|
import org.springframework.scheduling.support.CronTrigger;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.agent.AgentService;
|
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
import vip.mate.cron.model.CronJobDTO;
|
import vip.mate.cron.model.CronJobDTO;
|
||||||
import vip.mate.cron.model.CronJobEntity;
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
import vip.mate.cron.repository.CronJobMapper;
|
import vip.mate.cron.repository.CronJobMapper;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
@ -45,17 +42,23 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
|
|
||||||
private final CronJobMapper cronJobMapper;
|
private final CronJobMapper cronJobMapper;
|
||||||
private final AgentMapper agentMapper;
|
private final AgentMapper agentMapper;
|
||||||
private final AgentService agentService;
|
/**
|
||||||
private final ConversationService conversationService;
|
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
|
||||||
private final ConversationCompletionPublisher completionPublisher;
|
* (separate bean) so the three-segment transactional model in
|
||||||
|
* {@link CronJobLifecycleService} works via Spring AOP — no more
|
||||||
|
* self-invocation footgun.
|
||||||
|
*
|
||||||
|
* <p>{@code agentService}, {@code conversationService}, and
|
||||||
|
* {@code completionPublisher} now live on {@link CronJobLifecycleService}
|
||||||
|
* and {@link CronJobRunner} so this service shrinks to CRUD + scheduler
|
||||||
|
* registration only.
|
||||||
|
*/
|
||||||
|
private final CronJobRunner cronJobRunner;
|
||||||
|
|
||||||
private final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
private final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||||
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
|
||||||
private final ReentrantLock schedulerLock = new ReentrantLock();
|
private final ReentrantLock schedulerLock = new ReentrantLock();
|
||||||
|
|
||||||
/** 定时任务触发时使用的系统用户标识 */
|
|
||||||
private static final String SYSTEM_USER = "system";
|
|
||||||
|
|
||||||
// ==================== 初始化与销毁 ====================
|
// ==================== 初始化与销毁 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -88,9 +91,10 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
// ==================== CRUD ====================
|
// ==================== CRUD ====================
|
||||||
|
|
||||||
public List<CronJobDTO> list() {
|
public List<CronJobDTO> list() {
|
||||||
List<CronJobEntity> entities = cronJobMapper.selectList(
|
// RFC-063r §2.14: use the variant that aggregates the most-recent
|
||||||
new LambdaQueryWrapper<CronJobEntity>()
|
// delivery_status from mate_cron_job_run so the list page can
|
||||||
.orderByDesc(CronJobEntity::getCreateTime));
|
// render the "最近投递" badge without a per-row N+1 query.
|
||||||
|
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus();
|
||||||
|
|
||||||
// 批量加载 Agent 名称
|
// 批量加载 Agent 名称
|
||||||
List<Long> agentIds = entities.stream()
|
List<Long> agentIds = entities.stream()
|
||||||
@ -107,7 +111,9 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CronJobDTO getById(Long id) {
|
public CronJobDTO getById(Long id) {
|
||||||
CronJobEntity entity = cronJobMapper.selectById(id);
|
// RFC-063r §2.14: detail page shows lastDeliveryStatus too — same
|
||||||
|
// subquery shape, restricted to one id.
|
||||||
|
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
@ -219,8 +225,19 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
|
||||||
}
|
}
|
||||||
// 异步执行,不阻塞请求线程
|
// RFC-063r §2.7.1: delegate to CronJobRunner via Spring proxy so
|
||||||
scheduler.submit(() -> executeJob(entity));
|
// the three-segment REQUIRES_NEW transactions on
|
||||||
|
// CronJobLifecycleService work as advertised. "manual" trigger type
|
||||||
|
// distinguishes this from scheduler-driven runs in mate_cron_job_run.
|
||||||
|
scheduler.submit(() -> {
|
||||||
|
try {
|
||||||
|
cronJobRunner.executeJob(entity, "manual");
|
||||||
|
} finally {
|
||||||
|
// RFC-063r §2.7.1: bookkeep regardless of run outcome so a
|
||||||
|
// single bad run does not wedge all future ticks.
|
||||||
|
updateRunTimes(entity.getId(), entity.getCronExpression(), entity.getTimezone());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 调度器管理 ====================
|
// ==================== 调度器管理 ====================
|
||||||
@ -232,7 +249,16 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
String springCron = toSpringCron(job.getCronExpression());
|
String springCron = toSpringCron(job.getCronExpression());
|
||||||
ZoneId zoneId = ZoneId.of(job.getTimezone());
|
ZoneId zoneId = ZoneId.of(job.getTimezone());
|
||||||
CronTrigger trigger = new CronTrigger(springCron, zoneId);
|
CronTrigger trigger = new CronTrigger(springCron, zoneId);
|
||||||
ScheduledFuture<?> future = scheduler.schedule(() -> executeJob(job), trigger);
|
// RFC-063r §2.7.1: delegate to CronJobRunner — see runNow above.
|
||||||
|
// Bookkeeping (lastRunTime / nextRunTime) is wrapped in a finally
|
||||||
|
// so a failed run still advances the schedule.
|
||||||
|
ScheduledFuture<?> future = scheduler.schedule(() -> {
|
||||||
|
try {
|
||||||
|
cronJobRunner.executeJob(job, "scheduled");
|
||||||
|
} finally {
|
||||||
|
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
|
||||||
|
}
|
||||||
|
}, trigger);
|
||||||
scheduledTasks.put(job.getId(), future);
|
scheduledTasks.put(job.getId(), future);
|
||||||
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
|
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
|
||||||
job.getCronExpression(), job.getTimezone());
|
job.getCronExpression(), job.getTimezone());
|
||||||
@ -249,46 +275,18 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 任务执行 ====================
|
// ==================== 任务执行 ====================
|
||||||
|
//
|
||||||
private void executeJob(CronJobEntity job) {
|
// RFC-063r §2.7.1: the executeJob body moved to CronJobRunner so the
|
||||||
String conversationId = "cron:" + job.getId();
|
// three-segment transactional model in CronJobLifecycleService runs
|
||||||
try {
|
// through a Spring AOP proxy. CronJobService now only owns CRUD +
|
||||||
log.info("[CronJob] Executing job {} ({}), type={}", job.getId(), job.getName(), job.getTaskType());
|
// scheduler registration, and the lastRunTime / nextRunTime bookkeeping
|
||||||
|
// hook below — which deliberately runs *after* the runner returns so a
|
||||||
// 确保会话存在(使用 SYSTEM_USER 作为定时触发的所有者标识,workspace 从 agent 获取)
|
// failed run still advances the next-run pointer (otherwise a single
|
||||||
AgentEntity cronAgent = agentMapper.selectById(job.getAgentId());
|
// bad run wedges all future ticks).
|
||||||
Long cronWorkspaceId = cronAgent != null ? cronAgent.getWorkspaceId() : 1L;
|
//
|
||||||
conversationService.getOrCreateConversation(conversationId, job.getAgentId(), SYSTEM_USER, cronWorkspaceId);
|
// Both register() and runNow() now delegate to cronJobRunner.executeJob;
|
||||||
|
// see those methods above. The wrap below ensures next-run rolls forward
|
||||||
String userMessage;
|
// regardless of run outcome.
|
||||||
String result;
|
|
||||||
if ("agent".equals(job.getTaskType())) {
|
|
||||||
userMessage = job.getRequestBody();
|
|
||||||
// 保存 user 消息
|
|
||||||
conversationService.saveMessage(conversationId, "user", userMessage);
|
|
||||||
result = agentService.execute(job.getAgentId(), userMessage, conversationId);
|
|
||||||
} else {
|
|
||||||
userMessage = job.getTriggerMessage();
|
|
||||||
// 保存 user 消息
|
|
||||||
conversationService.saveMessage(conversationId, "user", userMessage);
|
|
||||||
result = agentService.chat(job.getAgentId(), userMessage, conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存 assistant 消息
|
|
||||||
conversationService.saveMessage(conversationId, "assistant", result);
|
|
||||||
|
|
||||||
// 发布对话完成事件
|
|
||||||
completionPublisher.publish(job.getAgentId(), conversationId, userMessage, result, "cron");
|
|
||||||
|
|
||||||
// 合并更新 lastRunTime + nextRunTime,单次 DB 写入
|
|
||||||
updateRunTimes(job.getId(), job.getCronExpression(), job.getTimezone());
|
|
||||||
|
|
||||||
log.info("[CronJob] Job {} executed successfully, result length={}", job.getId(),
|
|
||||||
result != null ? result.length() : 0);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[CronJob] Job {} execution failed: {}", job.getId(), e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并更新 lastRunTime 和 nextRunTime,单次 DB 写入替代原来的 4 次 selectById + updateById
|
* 合并更新 lastRunTime 和 nextRunTime,单次 DB 写入替代原来的 4 次 selectById + updateById
|
||||||
|
|||||||
@ -19,6 +19,24 @@ public class CronJobRunEntity {
|
|||||||
private LocalDateTime finishedAt;
|
private LocalDateTime finishedAt;
|
||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
private Integer tokenUsage;
|
private Integer tokenUsage;
|
||||||
|
|
||||||
|
// ===== RFC-063r §2.9: delivery state machine =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivery state machine — see RFC-063r §2.8.1:
|
||||||
|
* {@code NONE} (web-origin) | {@code PENDING} (claimed) |
|
||||||
|
* {@code DELIVERED} | {@code NOT_DELIVERED} (failure or stale-cleanup).
|
||||||
|
* Orthogonal to {@link #status} (run main state); always-best-effort
|
||||||
|
* policy means delivery failure never flips the main state to failed.
|
||||||
|
*/
|
||||||
|
private String deliveryStatus;
|
||||||
|
|
||||||
|
/** Resolved delivery target (IM userId / chat_id / Feishu webhook URL). */
|
||||||
|
private String deliveryTarget;
|
||||||
|
|
||||||
|
/** Delivery error reason — Hutool-truncated to 500 chars max. */
|
||||||
|
private String deliveryError;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,23 @@
|
|||||||
package vip.mate.i18n;
|
package vip.mate.i18n;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 国际化工具回调装饰器
|
* I18n decorator around a {@link ToolCallback} — overrides
|
||||||
* <p>
|
* {@link #getToolDefinition()} so the LLM sees the localized description.
|
||||||
* 包装原始 ToolCallback,覆写 {@link #getToolDefinition()} 返回本地化描述。
|
|
||||||
* 其他方法(call、name 等)全部委托给原始回调。
|
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* <p>RFC-063r §2.3 (P0): every {@code call(String, ToolContext)} invocation
|
||||||
|
* must be forwarded to the underlying delegate so {@link ToolContext} (carrying
|
||||||
|
* {@code ChatOrigin}) reaches downstream {@code @Tool} methods. The previous
|
||||||
|
* implementation only overrode {@code call(String)}, which silently dropped the
|
||||||
|
* ToolContext via the framework default.
|
||||||
|
*
|
||||||
|
* <p>{@link #getToolMetadata()} is forwarded so {@code returnDirect=true}
|
||||||
|
* tools keep their direct-return semantics under this decorator.
|
||||||
*/
|
*/
|
||||||
public class LocaleAwareToolCallback implements ToolCallback {
|
public class LocaleAwareToolCallback implements ToolCallback {
|
||||||
|
|
||||||
@ -31,8 +39,18 @@ public class LocaleAwareToolCallback implements ToolCallback {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolMetadata getToolMetadata() {
|
||||||
|
return delegate.getToolMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String call(String toolInput) {
|
public String call(String toolInput) {
|
||||||
return delegate.call(toolInput);
|
return delegate.call(toolInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String call(String toolInput, @Nullable ToolContext toolContext) {
|
||||||
|
return delegate.call(toolInput, toolContext);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,10 @@ import com.microsoft.playwright.PlaywrightException;
|
|||||||
import com.microsoft.playwright.options.LoadState;
|
import com.microsoft.playwright.options.LoadState;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.tool.browser.BrowserDiagnosticsService;
|
import vip.mate.tool.browser.BrowserDiagnosticsService;
|
||||||
import vip.mate.tool.browser.BrowserLauncher;
|
import vip.mate.tool.browser.BrowserLauncher;
|
||||||
@ -61,6 +63,15 @@ public class BrowserUseTool {
|
|||||||
private final Object playwrightLock = new Object();
|
private final Object playwrightLock = new Object();
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, BrowserSession> sessions = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, BrowserSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5 transition: ToolContext for the current invocation, set
|
||||||
|
* at the @Tool entry point and read by {@link #broadcastBrowserEvent}.
|
||||||
|
* Tool calls are serialized per ToolExecutionExecutor instance so this
|
||||||
|
* volatile field is safe; the field is read-only inside the action
|
||||||
|
* handlers.
|
||||||
|
*/
|
||||||
|
private volatile ToolContext currentToolContext;
|
||||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
Thread t = new Thread(r, "browser-idle-watchdog");
|
Thread t = new Thread(r, "browser-idle-watchdog");
|
||||||
t.setDaemon(true);
|
t.setDaemon(true);
|
||||||
@ -97,8 +108,15 @@ public class BrowserUseTool {
|
|||||||
@ToolParam(description = "JavaScript code to execute (for action=eval)", required = false) String code,
|
@ToolParam(description = "JavaScript code to execute (for action=eval)", required = false) String code,
|
||||||
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
|
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
|
||||||
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
|
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
|
||||||
@ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort
|
@ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort,
|
||||||
|
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
|
||||||
|
@Nullable ToolContext ctx
|
||||||
) {
|
) {
|
||||||
|
// The conversationId resolution lives in broadcastBrowserEvent below;
|
||||||
|
// capture the ctx into a field so the helper can read it without
|
||||||
|
// passing it down every action handler. Race-free because tool calls
|
||||||
|
// are serialized per executor.
|
||||||
|
this.currentToolContext = ctx;
|
||||||
if (action == null || action.isBlank()) {
|
if (action == null || action.isBlank()) {
|
||||||
return error("action is required");
|
return error("action is required");
|
||||||
}
|
}
|
||||||
@ -163,7 +181,7 @@ public class BrowserUseTool {
|
|||||||
*/
|
*/
|
||||||
private void broadcastBrowserEvent(String action, boolean success, String url, String title,
|
private void broadcastBrowserEvent(String action, boolean success, String url, String title,
|
||||||
String screenshot, long durationMs) {
|
String screenshot, long durationMs) {
|
||||||
String conversationId = ToolExecutionContext.conversationId();
|
String conversationId = ToolExecutionContext.conversationId(currentToolContext);
|
||||||
if (conversationId == null || streamTracker == null) {
|
if (conversationId == null || streamTracker == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import cn.hutool.json.JSONObject;
|
|||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.cron.model.CronJobDTO;
|
import vip.mate.cron.model.CronJobDTO;
|
||||||
import vip.mate.cron.service.CronJobService;
|
import vip.mate.cron.service.CronJobService;
|
||||||
|
|
||||||
@ -38,12 +41,21 @@ public class CronJobTool {
|
|||||||
@ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name,
|
@ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name,
|
||||||
@ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression,
|
@ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression,
|
||||||
@ToolParam(description = "Message to send when the task triggers, e.g. 'Search for the latest AI news and summarize'") String triggerMessage,
|
@ToolParam(description = "Message to send when the task triggers, e.g. 'Search for the latest AI news and summarize'") String triggerMessage,
|
||||||
@ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone) {
|
@ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone,
|
||||||
|
// RFC-063r §2.4: ToolContext is *not* exposed to the LLM —
|
||||||
|
// JsonSchemaGenerator skips it (Spring AI 1.1 framework convention)
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Resolve current agent ID from conversation context
|
// RFC-063r §2.5: prefer the explicit ChatOrigin (channelId / channelTarget
|
||||||
String conversationId = ToolExecutionContext.conversationId();
|
// / agentId all live there). Fall back to the legacy ToolExecutionContext
|
||||||
Long agentId = resolveAgentId(conversationId);
|
// ThreadLocal during the PR-1 transition window so callers that have not
|
||||||
|
// yet migrated keep working.
|
||||||
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
|
String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
|
||||||
|
? origin.conversationId()
|
||||||
|
: ToolExecutionContext.conversationId();
|
||||||
|
Long agentId = origin.agentId() != null ? origin.agentId() : resolveAgentId(conversationId);
|
||||||
|
|
||||||
CronJobDTO dto = new CronJobDTO();
|
CronJobDTO dto = new CronJobDTO();
|
||||||
dto.setName(name);
|
dto.setName(name);
|
||||||
@ -54,6 +66,12 @@ public class CronJobTool {
|
|||||||
dto.setTaskType("text");
|
dto.setTaskType("text");
|
||||||
dto.setEnabled(true);
|
dto.setEnabled(true);
|
||||||
|
|
||||||
|
// RFC-063r §2.4 / PR-2: when the originating context carries a
|
||||||
|
// channelId, the cron job inherits the binding so its results can
|
||||||
|
// be delivered back to the same channel. Fields are wired via
|
||||||
|
// reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
|
||||||
|
propagateChannelBinding(dto, origin);
|
||||||
|
|
||||||
CronJobDTO created = cronJobService.create(dto);
|
CronJobDTO created = cronJobService.create(dto);
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
@ -166,4 +184,17 @@ public class CronJobTool {
|
|||||||
result.set("error", message);
|
result.set("error", message);
|
||||||
return JSONUtil.toJsonPrettyStr(result);
|
return JSONUtil.toJsonPrettyStr(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.4: propagate the originating channel binding into the cron
|
||||||
|
* job DTO so PR-3's delivery dispatcher can route results back to the
|
||||||
|
* originating channel.
|
||||||
|
*/
|
||||||
|
private void propagateChannelBinding(CronJobDTO dto, ChatOrigin origin) {
|
||||||
|
if (origin == null || origin.channelId() == null) return;
|
||||||
|
dto.setChannelId(origin.channelId());
|
||||||
|
if (origin.channelTarget() != null) {
|
||||||
|
dto.setDeliveryConfig(vip.mate.cron.model.DeliveryConfig.from(origin.channelTarget()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.agent.model.AgentEntity;
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.agent.repository.AgentMapper;
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
@ -76,7 +79,12 @@ public class DelegateAgentTool {
|
|||||||
For multiple parallel tasks, use delegateParallel instead.""")
|
For multiple parallel tasks, use delegateParallel instead.""")
|
||||||
public String delegateToAgent(
|
public String delegateToAgent(
|
||||||
@ToolParam(description = "Target Agent name (exact match)") String agentName,
|
@ToolParam(description = "Target Agent name (exact match)") String agentName,
|
||||||
@ToolParam(description = "Task description with complete context information") String task) {
|
@ToolParam(description = "Task description with complete context information") String task,
|
||||||
|
// RFC-063r §2.5 改动点 5: parent ChatOrigin (channel binding /
|
||||||
|
// workspace) propagates into the delegated child so a sub-agent
|
||||||
|
// creating a cron job still binds back to the originating channel.
|
||||||
|
// Hidden from the LLM by JsonSchemaGenerator.
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
if (agentName == null || agentName.isBlank()) {
|
if (agentName == null || agentName.isBlank()) {
|
||||||
return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint();
|
return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint();
|
||||||
@ -111,8 +119,11 @@ public class DelegateAgentTool {
|
|||||||
}
|
}
|
||||||
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
|
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
|
||||||
|
|
||||||
// Execute child agent
|
// Execute child agent — RFC-063r §2.5 改动点 5: inherit the parent
|
||||||
ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId);
|
// 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, task, parentConversationId, childConversationId, parentOrigin);
|
||||||
|
|
||||||
// Cleanup relay, then broadcast final result
|
// Cleanup relay, then broadcast final result
|
||||||
if (stopRelay != null) stopRelay.run();
|
if (stopRelay != null) stopRelay.run();
|
||||||
@ -133,7 +144,9 @@ public class DelegateAgentTool {
|
|||||||
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
|
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
|
||||||
public String delegateParallel(
|
public String delegateParallel(
|
||||||
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
|
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
|
||||||
String tasksJson) {
|
String tasksJson,
|
||||||
|
// RFC-063r §2.5 改动点 5: hidden from LLM, used to inherit ChatOrigin into children.
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
// 1. Parse task list
|
// 1. Parse task list
|
||||||
List<Map<String, String>> tasks;
|
List<Map<String, String>> tasks;
|
||||||
@ -206,9 +219,14 @@ public class DelegateAgentTool {
|
|||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
Map<Integer, CompletableFuture<ChildResult>> futures = new LinkedHashMap<>();
|
Map<Integer, CompletableFuture<ChildResult>> futures = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
// RFC-063r §2.5 改动点 5: capture parent origin once on this thread,
|
||||||
|
// then hand it to each child future — the worker virtual threads
|
||||||
|
// can't re-read the ToolContext (no parameter scope), so we close
|
||||||
|
// over the captured origin.
|
||||||
|
ChatOrigin parentOriginParallel = ChatOrigin.from(ctx);
|
||||||
for (PreparedChild p : prepared) {
|
for (PreparedChild p : prepared) {
|
||||||
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
|
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
|
||||||
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId),
|
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, parentOriginParallel),
|
||||||
DELEGATION_EXECUTOR);
|
DELEGATION_EXECUTOR);
|
||||||
|
|
||||||
// Broadcast per-child completion as soon as each child finishes
|
// Broadcast per-child completion as soon as each child finishes
|
||||||
@ -385,11 +403,18 @@ public class DelegateAgentTool {
|
|||||||
* truncated length, making "blank_success" detection unreliable.
|
* truncated length, making "blank_success" detection unreliable.
|
||||||
*/
|
*/
|
||||||
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
||||||
String parentConversationId, String childConversationId) {
|
String parentConversationId, String childConversationId,
|
||||||
|
ChatOrigin parentOrigin) {
|
||||||
DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS);
|
DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS);
|
||||||
try {
|
try {
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
String rawResult = agentService.chat(target.getId(), task, childConversationId);
|
// RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId
|
||||||
|
// so child reads correct identity from ToolContext while keeping
|
||||||
|
// channelId / channelTarget / workspace context intact.
|
||||||
|
ChatOrigin childOrigin = (parentOrigin != null ? parentOrigin : ChatOrigin.EMPTY)
|
||||||
|
.withAgent(target.getId())
|
||||||
|
.withConversationId(childConversationId);
|
||||||
|
String rawResult = agentService.chat(target.getId(), task, childConversationId, childOrigin);
|
||||||
long durationMs = System.currentTimeMillis() - startTime;
|
long durationMs = System.currentTimeMillis() - startTime;
|
||||||
// Measure lengths before truncation so ChildResult carries accurate metadata.
|
// Measure lengths before truncation so ChildResult carries accurate metadata.
|
||||||
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
|
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
|
||||||
|
|||||||
@ -2,8 +2,10 @@ package vip.mate.tool.builtin;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.system.model.SystemSettingsDTO;
|
import vip.mate.system.model.SystemSettingsDTO;
|
||||||
import vip.mate.system.service.SystemSettingService;
|
import vip.mate.system.service.SystemSettingService;
|
||||||
@ -39,14 +41,16 @@ public class ImageGenerateTool {
|
|||||||
@ToolParam(description = "Aspect ratio: 1:1 / 16:9 / 9:16, default 1:1", required = false) String aspectRatio,
|
@ToolParam(description = "Aspect ratio: 1:1 / 16:9 / 9:16, default 1:1", required = false) String aspectRatio,
|
||||||
@ToolParam(description = "Generation count (1-4), default 1", required = false) Integer count,
|
@ToolParam(description = "Generation count (1-4), default 1", required = false) Integer count,
|
||||||
@ToolParam(description = "Model name (optional)", required = false) String model,
|
@ToolParam(description = "Model name (optional)", required = false) String model,
|
||||||
@ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId
|
@ToolParam(description = "Task ID to check status (for status action)", required = false) String taskId,
|
||||||
|
// RFC-063r §2.5: ToolContext is hidden from the LLM by JsonSchemaGenerator.
|
||||||
|
@Nullable ToolContext ctx
|
||||||
) {
|
) {
|
||||||
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
|
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
|
||||||
|
|
||||||
return switch (normalizedAction) {
|
return switch (normalizedAction) {
|
||||||
case "list" -> handleListAction();
|
case "list" -> handleListAction();
|
||||||
case "status" -> handleStatusAction(taskId);
|
case "status" -> handleStatusAction(taskId, ctx);
|
||||||
default -> handleGenerateAction(prompt, size, aspectRatio, count, model);
|
default -> handleGenerateAction(prompt, size, aspectRatio, count, model, ctx);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,8 +88,8 @@ public class ImageGenerateTool {
|
|||||||
|
|
||||||
// ==================== action=status ====================
|
// ==================== action=status ====================
|
||||||
|
|
||||||
private String handleStatusAction(String taskId) {
|
private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
|
||||||
String conversationId = ToolExecutionContext.conversationId();
|
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||||
|
|
||||||
if (taskId != null && !taskId.isBlank()) {
|
if (taskId != null && !taskId.isBlank()) {
|
||||||
AsyncTaskInfo info = imageGenerationService.checkTaskStatus(taskId);
|
AsyncTaskInfo info = imageGenerationService.checkTaskStatus(taskId);
|
||||||
@ -117,9 +121,9 @@ public class ImageGenerateTool {
|
|||||||
// ==================== action=generate ====================
|
// ==================== action=generate ====================
|
||||||
|
|
||||||
private String handleGenerateAction(String prompt, String size, String aspectRatio,
|
private String handleGenerateAction(String prompt, String size, String aspectRatio,
|
||||||
Integer count, String model) {
|
Integer count, String model, @Nullable ToolContext ctx) {
|
||||||
String conversationId = ToolExecutionContext.conversationId();
|
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||||
String username = ToolExecutionContext.username();
|
String username = ToolExecutionContext.username(ctx);
|
||||||
|
|
||||||
if (conversationId == null || conversationId.isBlank()) {
|
if (conversationId == null || conversationId.isBlank()) {
|
||||||
return "错误:无法获取当前会话信息,请重试";
|
return "错误:无法获取当前会话信息,请重试";
|
||||||
|
|||||||
@ -3,8 +3,10 @@ package vip.mate.tool.builtin;
|
|||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
@ -57,7 +59,9 @@ public class ReadFileTool {
|
|||||||
public String read_file(
|
public String read_file(
|
||||||
@ToolParam(description = "Absolute or relative file path") String filePath,
|
@ToolParam(description = "Absolute or relative file path") String filePath,
|
||||||
@ToolParam(description = "Start line number (1-based, inclusive). Omit to start from line 1", required = false) Integer startLine,
|
@ToolParam(description = "Start line number (1-based, inclusive). Omit to start from line 1", required = false) Integer startLine,
|
||||||
@ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine) {
|
@ToolParam(description = "End line number (1-based, inclusive). Omit to read to EOF or truncation limit", required = false) Integer endLine,
|
||||||
|
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("filePath", filePath);
|
result.set("filePath", filePath);
|
||||||
@ -65,7 +69,9 @@ public class ReadFileTool {
|
|||||||
try {
|
try {
|
||||||
Path path;
|
Path path;
|
||||||
try {
|
try {
|
||||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath);
|
// RFC-063r §2.5: forward ToolContext so workspace boundary
|
||||||
|
// honors ChatOrigin.workspaceBasePath when available.
|
||||||
|
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
// Sandbox rejected the literal path. The LLM may have hallucinated
|
// Sandbox rejected the literal path. The LLM may have hallucinated
|
||||||
// a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that
|
// a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that
|
||||||
|
|||||||
@ -1,11 +1,21 @@
|
|||||||
package vip.mate.tool.builtin;
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具执行上下文 — 通过 ThreadLocal 向 @Tool 方法传递执行环境信息
|
* 工具执行上下文 — 通过 ThreadLocal 向 @Tool 方法传递执行环境信息
|
||||||
* <p>
|
* <p>
|
||||||
* 在 ToolExecutionExecutor.executeSingleTool() 中 set,在 finally 中 clear。
|
* 在 ToolExecutionExecutor.executeSingleTool() 中 set,在 finally 中 clear。
|
||||||
* 视频生成等需要知道 conversationId 的工具从此处获取。
|
* 视频生成等需要知道 conversationId 的工具从此处获取。
|
||||||
*
|
*
|
||||||
|
* <p>RFC-063r §2.5 兼容期:执行器会同时填充本 ThreadLocal 和 Spring AI 的
|
||||||
|
* {@link ToolContext}(携带 {@link ChatOrigin})。优先读 ToolContext 的工具
|
||||||
|
* 调用 {@link #conversationId(ToolContext)} / {@link #username(ToolContext)}
|
||||||
|
* / {@link #workspaceBasePath(ToolContext)} 等三参重载即可——传入 ctx 不为
|
||||||
|
* null 时优先返回 origin 的字段,否则回退到 ThreadLocal。
|
||||||
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
public final class ToolExecutionContext {
|
public final class ToolExecutionContext {
|
||||||
@ -47,4 +57,34 @@ public final class ToolExecutionContext {
|
|||||||
USERNAME.remove();
|
USERNAME.remove();
|
||||||
WORKSPACE_BASE_PATH.remove();
|
WORKSPACE_BASE_PATH.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== RFC-063r §2.5: ToolContext-aware accessors =====
|
||||||
|
//
|
||||||
|
// Preferred over the parameter-less variants: read from the explicit
|
||||||
|
// Spring AI ToolContext (carries ChatOrigin) when available, otherwise
|
||||||
|
// fall back to the legacy ThreadLocal so legacy paths keep working.
|
||||||
|
|
||||||
|
public static String conversationId(@Nullable ToolContext ctx) {
|
||||||
|
if (ctx != null) {
|
||||||
|
String v = ChatOrigin.from(ctx).conversationId();
|
||||||
|
if (v != null && !v.isEmpty()) return v;
|
||||||
|
}
|
||||||
|
return CONVERSATION_ID.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String username(@Nullable ToolContext ctx) {
|
||||||
|
if (ctx != null) {
|
||||||
|
String v = ChatOrigin.from(ctx).requesterId();
|
||||||
|
if (v != null && !v.isEmpty()) return v;
|
||||||
|
}
|
||||||
|
return USERNAME.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String workspaceBasePath(@Nullable ToolContext ctx) {
|
||||||
|
if (ctx != null) {
|
||||||
|
String v = ChatOrigin.from(ctx).workspaceBasePath();
|
||||||
|
if (v != null && !v.isBlank()) return v;
|
||||||
|
}
|
||||||
|
return WORKSPACE_BASE_PATH.get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,10 @@ package vip.mate.tool.builtin;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.system.model.SystemSettingsDTO;
|
import vip.mate.system.model.SystemSettingsDTO;
|
||||||
import vip.mate.system.service.SystemSettingService;
|
import vip.mate.system.service.SystemSettingService;
|
||||||
@ -45,15 +47,18 @@ public class VideoGenerateTool {
|
|||||||
@ToolParam(description = "视频时长(秒),如 5 或 10,默认 5", required = false) Integer duration,
|
@ToolParam(description = "视频时长(秒),如 5 或 10,默认 5", required = false) Integer duration,
|
||||||
@ToolParam(description = "参考图片 URL(图生视频模式)", required = false) String imageUrl,
|
@ToolParam(description = "参考图片 URL(图生视频模式)", required = false) String imageUrl,
|
||||||
@ToolParam(description = "指定模型名称(可选)", required = false) String model,
|
@ToolParam(description = "指定模型名称(可选)", required = false) String model,
|
||||||
@ToolParam(description = "查询指定任务 ID 的状态(status 模式时使用)", required = false) String taskId
|
@ToolParam(description = "查询指定任务 ID 的状态(status 模式时使用)", required = false) String taskId,
|
||||||
|
// RFC-063r §2.5: ToolContext is auto-injected by Spring AI MethodToolCallback
|
||||||
|
// and explicitly skipped by JsonSchemaGenerator — never visible to the LLM.
|
||||||
|
@Nullable ToolContext ctx
|
||||||
) {
|
) {
|
||||||
// 路由 action
|
// 路由 action
|
||||||
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
|
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
|
||||||
|
|
||||||
return switch (normalizedAction) {
|
return switch (normalizedAction) {
|
||||||
case "list" -> handleListAction();
|
case "list" -> handleListAction();
|
||||||
case "status" -> handleStatusAction(taskId);
|
case "status" -> handleStatusAction(taskId, ctx);
|
||||||
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model);
|
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model, ctx);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,8 +97,8 @@ public class VideoGenerateTool {
|
|||||||
|
|
||||||
// ==================== action=status ====================
|
// ==================== action=status ====================
|
||||||
|
|
||||||
private String handleStatusAction(String taskId) {
|
private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
|
||||||
String conversationId = ToolExecutionContext.conversationId();
|
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||||
|
|
||||||
// 指定 taskId 查询
|
// 指定 taskId 查询
|
||||||
if (taskId != null && !taskId.isBlank()) {
|
if (taskId != null && !taskId.isBlank()) {
|
||||||
@ -124,9 +129,9 @@ public class VideoGenerateTool {
|
|||||||
// ==================== action=generate ====================
|
// ==================== action=generate ====================
|
||||||
|
|
||||||
private String handleGenerateAction(String prompt, String aspectRatio, Integer duration,
|
private String handleGenerateAction(String prompt, String aspectRatio, Integer duration,
|
||||||
String imageUrl, String model) {
|
String imageUrl, String model, @Nullable ToolContext ctx) {
|
||||||
String conversationId = ToolExecutionContext.conversationId();
|
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||||
String username = ToolExecutionContext.username();
|
String username = ToolExecutionContext.username(ctx);
|
||||||
|
|
||||||
if (conversationId == null || conversationId.isBlank()) {
|
if (conversationId == null || conversationId.isBlank()) {
|
||||||
return "错误:无法获取当前会话信息,请重试";
|
return "错误:无法获取当前会话信息,请重试";
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package vip.mate.tool.guard;
|
package vip.mate.tool.guard;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -33,9 +36,19 @@ public final class WorkspacePathGuard {
|
|||||||
* @throws IllegalArgumentException 路径不在允许范围内
|
* @throws IllegalArgumentException 路径不在允许范围内
|
||||||
*/
|
*/
|
||||||
public static Path validatePath(String rawPath) {
|
public static Path validatePath(String rawPath) {
|
||||||
|
return validatePath(rawPath, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: ToolContext-aware overload. Reads the workspace base path
|
||||||
|
* from the explicit {@link ChatOrigin} when present; falls back to the
|
||||||
|
* legacy {@link ToolExecutionContext} ThreadLocal during the PR-1
|
||||||
|
* transition window.
|
||||||
|
*/
|
||||||
|
public static Path validatePath(String rawPath, @Nullable ToolContext ctx) {
|
||||||
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
|
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
|
||||||
|
|
||||||
String basePath = ToolExecutionContext.workspaceBasePath();
|
String basePath = resolveBasePath(ctx);
|
||||||
if (basePath == null || basePath.isBlank()) {
|
if (basePath == null || basePath.isBlank()) {
|
||||||
return normalized; // 未配置活动目录,不限制
|
return normalized; // 未配置活动目录,不限制
|
||||||
}
|
}
|
||||||
@ -72,10 +85,35 @@ public final class WorkspacePathGuard {
|
|||||||
* @return 活动目录 Path,未配置时返回 null
|
* @return 活动目录 Path,未配置时返回 null
|
||||||
*/
|
*/
|
||||||
public static Path getWorkingDirectory() {
|
public static Path getWorkingDirectory() {
|
||||||
String basePath = ToolExecutionContext.workspaceBasePath();
|
return getWorkingDirectory(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-063r §2.5: ToolContext-aware variant — prefer the explicit
|
||||||
|
* {@link ChatOrigin} workspaceBasePath when available.
|
||||||
|
*/
|
||||||
|
public static Path getWorkingDirectory(@Nullable ToolContext ctx) {
|
||||||
|
String basePath = resolveBasePath(ctx);
|
||||||
if (basePath == null || basePath.isBlank()) {
|
if (basePath == null || basePath.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Paths.get(basePath).toAbsolutePath().normalize();
|
return Paths.get(basePath).toAbsolutePath().normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active workspace base path. Order of preference:
|
||||||
|
* <ol>
|
||||||
|
* <li>ChatOrigin from ToolContext (RFC-063r §2.5)</li>
|
||||||
|
* <li>Legacy {@link ToolExecutionContext} ThreadLocal (PR-1 transition)</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
private static String resolveBasePath(@Nullable ToolContext ctx) {
|
||||||
|
if (ctx != null) {
|
||||||
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
|
if (origin.workspaceBasePath() != null && !origin.workspaceBasePath().isBlank()) {
|
||||||
|
return origin.workspaceBasePath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ToolExecutionContext.workspaceBasePath();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,10 @@ package vip.mate.tool.music;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
import org.springframework.ai.tool.annotation.ToolParam;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
|
||||||
@ -23,9 +25,11 @@ public class MusicGenerateTool {
|
|||||||
public String music_generate(
|
public String music_generate(
|
||||||
@ToolParam(description = "音乐风格/场景描述,如:'轻快的钢琴爵士乐'、'史诗电影配乐'、'欢快的流行歌曲'") String prompt,
|
@ToolParam(description = "音乐风格/场景描述,如:'轻快的钢琴爵士乐'、'史诗电影配乐'、'欢快的流行歌曲'") String prompt,
|
||||||
@ToolParam(description = "歌词文本(可选,不填则由 AI 生成或生成纯音乐)") String lyrics,
|
@ToolParam(description = "歌词文本(可选,不填则由 AI 生成或生成纯音乐)") String lyrics,
|
||||||
@ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental) {
|
@ToolParam(description = "是否生成纯音乐(无人声),默认 false") Boolean instrumental,
|
||||||
|
// RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator.
|
||||||
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
String conversationId = ToolExecutionContext.conversationId();
|
String conversationId = ToolExecutionContext.conversationId(ctx);
|
||||||
if (conversationId == null) {
|
if (conversationId == null) {
|
||||||
return "无法获取会话 ID";
|
return "无法获取会话 ID";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
-- RFC-063r §2.9: cron run delivery state machine.
|
||||||
|
--
|
||||||
|
-- delivery_status drives the SQL-CAS that prevents duplicate proactive sends
|
||||||
|
-- (replaces RFC-063 v1's Caffeine 30-min TTL idempotency). 4-state machine:
|
||||||
|
-- NONE — no delivery strategy (web-origin cron)
|
||||||
|
-- PENDING — claimed by AbstractCronResultDelivery#claimRun
|
||||||
|
-- DELIVERED — markDelivered after successful proactiveSend
|
||||||
|
-- NOT_DELIVERED — markNotDelivered (or stale-cleanup timeout)
|
||||||
|
--
|
||||||
|
-- delivery_target VARCHAR(512) covers both IM userId (≤64) and Feishu
|
||||||
|
-- sessionWebhook URLs (~200-256). Index (delivery_status, started_at) is
|
||||||
|
-- the exact predicate of CronRunStaleCleanup.sweep().
|
||||||
|
|
||||||
|
ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_status VARCHAR(16) DEFAULT 'NONE' NOT NULL;
|
||||||
|
ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_target VARCHAR(512);
|
||||||
|
ALTER TABLE mate_cron_job_run ADD COLUMN IF NOT EXISTS delivery_error VARCHAR(500);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cron_run_pending_started ON mate_cron_job_run(delivery_status, started_at);
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
-- RFC-063r §2.9: bind a cron job to its originating channel + delivery target.
|
||||||
|
--
|
||||||
|
-- channel_id is the only field that needs an index (operations team queries
|
||||||
|
-- "all jobs delivering to channel X"). delivery_config is a JSON column —
|
||||||
|
-- targetId / threadId / accountId are persisted as a structured value via
|
||||||
|
-- MyBatis Plus JacksonTypeHandler so future delivery-target fields don't
|
||||||
|
-- require schema changes.
|
||||||
|
|
||||||
|
ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS channel_id BIGINT;
|
||||||
|
ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS delivery_config TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cron_channel ON mate_cron_job(channel_id);
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
-- RFC-063r §2.12: persist the originating ChatOrigin as a JSON snapshot on the
|
||||||
|
-- approval row. Used by ChannelMessageRouter.replayApprovedToolCall + web
|
||||||
|
-- ApprovalController so cross-process / cross-restart approval replays still
|
||||||
|
-- preserve the channel binding (Memento pattern).
|
||||||
|
|
||||||
|
ALTER TABLE mate_tool_approval ADD COLUMN IF NOT EXISTS chat_origin TEXT;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- RFC-063r §2.9: cron run delivery state machine (MySQL dialect).
|
||||||
|
-- See V57 H2 file for state-machine + design reasoning.
|
||||||
|
--
|
||||||
|
-- MySQL has no `ADD COLUMN IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` —
|
||||||
|
-- guard each statement via INFORMATION_SCHEMA + dynamic SQL (project pattern
|
||||||
|
-- previously used in V4/V19/V44).
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_status');
|
||||||
|
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_status VARCHAR(16) NOT NULL DEFAULT ''NONE''', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_target');
|
||||||
|
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_target VARCHAR(512)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND COLUMN_NAME = 'delivery_error');
|
||||||
|
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job_run ADD COLUMN delivery_error VARCHAR(500)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job_run' AND INDEX_NAME = 'idx_cron_run_pending_started');
|
||||||
|
SET @s := IF(@c = 0, 'CREATE INDEX idx_cron_run_pending_started ON mate_cron_job_run(delivery_status, started_at)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
-- RFC-063r §2.9: bind a cron job to its originating channel (MySQL dialect).
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND COLUMN_NAME = 'channel_id');
|
||||||
|
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job ADD COLUMN channel_id BIGINT', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND COLUMN_NAME = 'delivery_config');
|
||||||
|
SET @s := IF(@c = 0, 'ALTER TABLE mate_cron_job ADD COLUMN delivery_config TEXT', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_cron_job' AND INDEX_NAME = 'idx_cron_channel');
|
||||||
|
SET @s := IF(@c = 0, 'CREATE INDEX idx_cron_channel ON mate_cron_job(channel_id)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- RFC-063r §2.12: persist ChatOrigin Memento snapshot on approval (MySQL dialect).
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_tool_approval' AND COLUMN_NAME = 'chat_origin');
|
||||||
|
SET @s := IF(@c = 0, 'ALTER TABLE mate_tool_approval ADD COLUMN chat_origin TEXT', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@ -1432,6 +1432,15 @@ export default {
|
|||||||
lastRun: 'Last Run',
|
lastRun: 'Last Run',
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
|
// RFC-063r §2.14: list-page "Last Delivery" column
|
||||||
|
lastDelivery: 'Last Delivery',
|
||||||
|
},
|
||||||
|
// RFC-063r §2.14: delivery_status state machine labels
|
||||||
|
lastDelivery: {
|
||||||
|
none: 'No delivery',
|
||||||
|
pending: 'In flight',
|
||||||
|
delivered: 'Delivered',
|
||||||
|
not_delivered: 'Failed',
|
||||||
},
|
},
|
||||||
taskTypes: { text: 'Text Message', agent: 'Agent Goal' },
|
taskTypes: { text: 'Text Message', agent: 'Agent Goal' },
|
||||||
cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' },
|
cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' },
|
||||||
|
|||||||
@ -1442,6 +1442,15 @@ export default {
|
|||||||
lastRun: '上次执行',
|
lastRun: '上次执行',
|
||||||
enabled: '启用',
|
enabled: '启用',
|
||||||
actions: '操作',
|
actions: '操作',
|
||||||
|
// RFC-063r §2.14: 列表页"最近投递"列
|
||||||
|
lastDelivery: '最近投递',
|
||||||
|
},
|
||||||
|
// RFC-063r §2.14: delivery_status 状态机展示
|
||||||
|
lastDelivery: {
|
||||||
|
none: '无投递',
|
||||||
|
pending: '投递中',
|
||||||
|
delivered: '已送达',
|
||||||
|
not_delivered: '投递失败',
|
||||||
},
|
},
|
||||||
taskTypes: { text: '文字消息', agent: 'Agent 目标' },
|
taskTypes: { text: '文字消息', agent: 'Agent 目标' },
|
||||||
cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' },
|
cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' },
|
||||||
|
|||||||
@ -786,4 +786,12 @@ export interface CronJob {
|
|||||||
lastRunTime?: string
|
lastRunTime?: string
|
||||||
createTime?: string
|
createTime?: string
|
||||||
updateTime?: string
|
updateTime?: string
|
||||||
|
// RFC-063r §2.9 / §2.14: channel binding + most-recent delivery snapshot.
|
||||||
|
// channelId / deliveryConfig: round-trippable on create/update.
|
||||||
|
// lastDeliveryStatus / lastDeliveryError: read-only, populated by
|
||||||
|
// selectListWithDeliveryStatus / selectByIdWithDeliveryStatus on the backend.
|
||||||
|
channelId?: number | null
|
||||||
|
deliveryConfig?: { targetId?: string | null; threadId?: string | null; accountId?: string | null } | null
|
||||||
|
lastDeliveryStatus?: 'NONE' | 'PENDING' | 'DELIVERED' | 'NOT_DELIVERED'
|
||||||
|
lastDeliveryError?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
<th>{{ t('cronJobs.columns.name') }}</th>
|
<th>{{ t('cronJobs.columns.name') }}</th>
|
||||||
<th>{{ t('cronJobs.columns.cron') }}</th>
|
<th>{{ t('cronJobs.columns.cron') }}</th>
|
||||||
<th>{{ t('tokenUsage.date') }}</th>
|
<th>{{ t('tokenUsage.date') }}</th>
|
||||||
|
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
|
||||||
<th>{{ t('cronJobs.columns.enabled') }}</th>
|
<th>{{ t('cronJobs.columns.enabled') }}</th>
|
||||||
<th>{{ t('cronJobs.columns.actions') }}</th>
|
<th>{{ t('cronJobs.columns.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -53,6 +54,14 @@
|
|||||||
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
|
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- RFC-063r §2.14: most-recent delivery status badge.
|
||||||
|
hover surfaces the error detail when not delivered. -->
|
||||||
|
<span class="delivery-badge" :class="'delivery-' + (job.lastDeliveryStatus || 'NONE').toLowerCase()"
|
||||||
|
:title="job.lastDeliveryError || t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase())">
|
||||||
|
{{ t('cronJobs.lastDelivery.' + (job.lastDeliveryStatus || 'NONE').toLowerCase()) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
|
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
|
||||||
@ -87,7 +96,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="store.jobs.length === 0">
|
<tr v-if="store.jobs.length === 0">
|
||||||
<td colspan="5" class="empty-row">
|
<td colspan="6" class="empty-row">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<span class="empty-icon">⏱</span>
|
<span class="empty-icon">⏱</span>
|
||||||
<p>{{ t('cronJobs.noJobs') }}</p>
|
<p>{{ t('cronJobs.noJobs') }}</p>
|
||||||
@ -653,6 +662,21 @@ function formatTime(datetime: string | undefined): string {
|
|||||||
}
|
}
|
||||||
.time-empty { color: var(--mc-text-tertiary); }
|
.time-empty { color: var(--mc-text-tertiary); }
|
||||||
|
|
||||||
|
/* RFC-063r §2.14: delivery-status badge — neutral / blue / green / red. */
|
||||||
|
.delivery-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.delivery-none { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||||
|
.delivery-pending { background: rgba(59, 130, 246, 0.12); color: rgb(59, 130, 246); }
|
||||||
|
.delivery-delivered { background: rgba(34, 197, 94, 0.12); color: rgb(34, 197, 94); }
|
||||||
|
.delivery-not_delivered { background: rgba(239, 68, 68, 0.12); color: rgb(239, 68, 68); }
|
||||||
|
|
||||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user