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:
matevip 2026-04-28 21:43:58 +08:00
parent f57b31f379
commit 4011050ceb
59 changed files with 2058 additions and 145 deletions

View File

@ -367,6 +367,20 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>
<build>

View File

@ -7,6 +7,8 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
@ -98,31 +100,67 @@ public class AgentService {
// ==================== 运行时入口 ====================
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);
BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleSync(agentId, message, conversationId,
(msg, convId) -> agent.chat(msg, convId));
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
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) {
return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY);
}
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId),
chunk -> chunk);
// Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state.
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) {
return chatStructuredStream(agentId, message, conversationId, "", null);
return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY);
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
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,
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);
BaseAgent agent = getOrBuildAgent(agentId);
@ -139,27 +177,45 @@ public class AgentService {
}
}
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
if (agent instanceof StructuredStreamCapable capable) {
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> capable.chatStructuredStream(msg, convId,
requesterId != null ? requesterId : "")
.doFinally(signal -> ThinkingLevelHolder.clear()),
StreamDelta::content);
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> capable.chatStructuredStream(msg, convId,
requesterId != null ? requesterId : "")
.doFinally(signal -> ThinkingLevelHolder.clear()),
StreamDelta::content);
})
.doFinally(signal -> ChatOriginHolder.clear());
}
// 降级不支持结构化流的 Agent包装为纯内容流
ThinkingLevelHolder.clear();
return withLifecycleFlux(agentId, message, conversationId,
(msg, convId) -> agent.chatStream(msg, convId)
.map(chunk -> new StreamDelta(chunk, null)),
StreamDelta::content);
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
return withLifecycleFlux(agentId, message, conversationId,
(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) {
return execute(agentId, goal, conversationId, ChatOrigin.EMPTY);
}
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleSync(agentId, goal, conversationId,
(msg, convId) -> agent.execute(msg, convId));
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
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,
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);
BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleSync(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload));
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
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,
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,
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);
BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleFlux(agentId, userMessage, conversationId,
(msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
requesterId != null ? requesterId : ""),
StreamDelta::content);
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
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) {

View File

@ -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
) {
}

View File

@ -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;
}
}

View File

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

View File

@ -410,6 +410,19 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
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;
}

View File

@ -4,10 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
@ -202,6 +204,8 @@ public class ToolExecutionExecutor {
private volatile String currentRequesterId;
/** 当前工作区活动目录(为空不限制),传递给 ToolExecutionContext */
private volatile String currentWorkspaceBasePath;
/** RFC-063r §2.5: 当前执行的 ChatOrigin构建 ToolContext 时透传给工具 */
private volatile ChatOrigin currentChatOrigin = ChatOrigin.EMPTY;
public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
String conversationId, String agentId,
@ -213,8 +217,30 @@ public class ToolExecutionExecutor {
String conversationId, String agentId,
boolean isReplay, String requesterId,
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.currentWorkspaceBasePath = workspaceBasePath;
this.currentChatOrigin = origin != null ? origin : ChatOrigin.EMPTY;
List<ToolResponseMessage.ToolResponse> allResponses = new ArrayList<>();
List<GraphEventPublisher.GraphEvent> events = Collections.synchronizedList(new ArrayList<>());
// RFC-052: accumulate full-text outputs from returnDirect tools so the
@ -309,7 +335,7 @@ public class ToolExecutionExecutor {
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
conversationId, currentRequesterId, currentWorkspaceBasePath));
conversationId, currentRequesterId, currentWorkspaceBasePath, currentChatOrigin));
// 占位Phase 2 填充
allResponses.add(null);
}
@ -382,7 +408,13 @@ public class ToolExecutionExecutor {
try {
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;
// 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
? 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);
String result;
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 {
ToolExecutionContext.clear();
}
@ -781,7 +821,8 @@ public class ToolExecutionExecutor {
int resultIndex,
String conversationId,
String requesterId,
String workspaceBasePath
String workspaceBasePath,
ChatOrigin origin
) {}
private record ApprovalBarrier(String pendingId, String toolName) {}

View File

@ -65,9 +65,13 @@ public class ActionNode implements NodeAction {
// 获取工作区活动目录
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.ToolExecutionResult result = executor.execute(
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath);
toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin);
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(result.responses())

View File

@ -285,6 +285,19 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
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;
}

View File

@ -86,6 +86,12 @@ public class StepExecutionNode implements NodeAction {
String conversationId = state.value(MateClawStateKeys.CONVERSATION_ID, "");
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
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()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
@ -196,7 +202,7 @@ public class StepExecutionNode implements NodeAction {
} else {
// 非预批准工具走正常执行器
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath);
List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
@ -212,7 +218,7 @@ public class StepExecutionNode implements NodeAction {
} else {
// 正常路径委托 ToolExecutionExecutor支持并发执行 + 审批 barrier
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
allToolCalls, conversationId, agentId, false, "", workspaceBasePath);
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {

View File

@ -3,6 +3,7 @@ package vip.mate.agent.graph.state;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import java.util.*;
@ -212,6 +213,17 @@ public final class MateClawStateAccessor {
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 =====
public int promptTokens() {
@ -398,6 +410,11 @@ public final class MateClawStateAccessor {
return put(FORCED_TOOL_CALL, json);
}
// ---- RFC-063r: ChatOrigin ----
public OutputBuilder chatOrigin(ChatOrigin origin) {
return put(CHAT_ORIGIN, origin);
}
// ---- Token Usage ----
/** 将本次 LLM 调用的 usage 累加到 state 已有值上 */

View File

@ -155,4 +155,16 @@ public final class MateClawStateKeys {
* tool batch, used by FinalAnswerNode to assemble the final answer.
*/
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";
}

View File

@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
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.repository.ToolApprovalMapper;
import vip.mate.tool.guard.model.GuardEvaluation;
@ -137,6 +139,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
snapshot.setFindingsJson(entity.getFindingsJson());
snapshot.setMaxSeverity(entity.getMaxSeverity());
snapshot.setSummary(entity.getSummary());
snapshot.setChatOrigin(entity.getChatOrigin());
approvalService.registerRecovered(snapshot);
recovered++;
@ -200,6 +203,14 @@ public class ApprovalWorkflowService implements ApplicationRunner {
conversationId, userId, toolName, toolArguments, reason,
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. 增强内存记录
approvalService.getPending(pendingId).ifPresent(pending -> {
if (evaluation != null) {
@ -207,11 +218,12 @@ public class ApprovalWorkflowService implements ApplicationRunner {
pending.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
pending.setSummary(evaluation.summary());
}
pending.setChatOrigin(chatOriginJson);
});
// 3. DB
persistToDb(pendingId, conversationId, userId, toolName, toolArguments,
toolCallPayload, siblingToolCalls, agentId, evaluation);
toolCallPayload, siblingToolCalls, agentId, evaluation, chatOriginJson);
return pendingId;
}
@ -517,7 +529,7 @@ public class ApprovalWorkflowService implements ApplicationRunner {
private void persistToDb(String pendingId, String conversationId, String userId,
String toolName, String toolArguments,
String toolCallPayload, String siblingToolCalls, String agentId,
GuardEvaluation evaluation) {
GuardEvaluation evaluation, String chatOriginJson) {
try {
ToolApprovalEntity entity = new ToolApprovalEntity();
entity.setPendingId(pendingId);
@ -531,6 +543,9 @@ public class ApprovalWorkflowService implements ApplicationRunner {
entity.setStatus("PENDING");
entity.setCreatedAt(LocalDateTime.now());
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) {
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) {
try {
LambdaUpdateWrapper<ToolApprovalEntity> wrapper = new LambdaUpdateWrapper<ToolApprovalEntity>()

View File

@ -59,6 +59,16 @@ public class PendingApproval {
/** 风险摘要 */
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,
String toolName, String toolArguments, String reason) {
this.pendingId = pendingId;
@ -110,6 +120,7 @@ public class PendingApproval {
public String getFindingsJson() { return findingsJson; }
public String getMaxSeverity() { return maxSeverity; }
public String getSummary() { return summary; }
public String getChatOrigin() { return chatOrigin; }
// === Setters ===
@ -125,4 +136,5 @@ public class PendingApproval {
public void setFindingsJson(String findingsJson) { this.findingsJson = findingsJson; }
public void setMaxSeverity(String maxSeverity) { this.maxSeverity = maxSeverity; }
public void setSummary(String summary) { this.summary = summary; }
public void setChatOrigin(String chatOrigin) { this.chatOrigin = chatOrigin; }
}

View File

@ -36,6 +36,16 @@ public class ToolApprovalEntity {
private LocalDateTime resolvedAt;
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)
private LocalDateTime createTime;

View File

@ -113,6 +113,19 @@ public interface ChannelAdapter {
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);
}
/**
* 当前渠道是否支持主动推送
*

View File

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

View File

@ -330,13 +330,23 @@ public class ChannelManager {
* @throws IllegalStateException 渠道未启动或不支持主动推送
*/
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)
.orElseThrow(() -> new IllegalStateException("Channel not active: " + channelId));
if (!adapter.supportsProactiveSend()) {
throw new UnsupportedOperationException(
"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",
adapter.getDisplayName(), targetId, content.length());
}

View File

@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.ResolveOutcome;
import vip.mate.approval.PendingApproval;
@ -54,6 +55,7 @@ public class ChannelMessageRouter {
private final TtsService ttsService;
private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker;
private final ChannelChatOriginFactory chatOriginFactory;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@ -98,7 +100,8 @@ public class ChannelMessageRouter {
ConversationCompletionPublisher completionPublisher,
TtsService ttsService,
ObjectMapper objectMapper,
ChatStreamTracker streamTracker) {
ChatStreamTracker streamTracker,
ChannelChatOriginFactory chatOriginFactory) {
this.agentService = agentService;
this.conversationService = conversationService;
this.channelService = channelService;
@ -109,6 +112,7 @@ public class ChannelMessageRouter {
this.ttsService = ttsService;
this.objectMapper = objectMapper;
this.streamTracker = streamTracker;
this.chatOriginFactory = chatOriginFactory;
}
// ==================== 防抖辅助类 ====================
@ -440,11 +444,17 @@ public class ChannelMessageRouter {
Long savedAssistantId = null;
try {
// 流式路径渠道实现了 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) {
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
} else {
// 同步路径直接获取完整回复
String reply = agentService.chat(agentId, promptText, conversationId);
String reply = agentService.chat(agentId, promptText, conversationId, chatOrigin);
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
@ -521,14 +531,14 @@ public class ChannelMessageRouter {
*/
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
String conversationId, Long agentId, String promptText,
ChannelEntity channelEntity) {
ChannelEntity channelEntity, ChatOrigin chatOrigin) {
String channelType = streamingAdapter.getChannelType();
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
try {
// Step 1: 产生事件流
// Step 1: 产生事件流RFC-063r §2.5: forward ChatOrigin so tools see channelId
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
agentId, promptText, conversationId, message.getSenderId());
agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
// Step 2: 委托渠道渲染渠道内部消费 Flux 并处理 UI 更新
String finalContent = streamingAdapter.processStream(stream, message, conversationId);
@ -591,8 +601,17 @@ public class ChannelMessageRouter {
String replayPrompt = "继续执行已批准的工具调用。";
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(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload());
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
// 保存 replay 结果这是正常结果入库
conversationService.saveMessage(conversationId, "assistant", reply);
@ -644,7 +663,11 @@ public class ChannelMessageRouter {
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);
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);
}
// ==================== 优雅关闭 ====================

View File

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

View File

@ -238,6 +238,38 @@ public class SlackChannelAdapter extends AbstractChannelAdapter {
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 优先
*/

View File

@ -715,6 +715,80 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
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
public String getChannelType() {
return CHANNEL_TYPE;

View File

@ -283,8 +283,18 @@ public class ChatController {
String replayPrompt = "继续执行已批准的工具调用。";
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(
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username)
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
.doOnNext(delta -> {
if (approvalEmitterDone.get()) return;
try {
@ -460,7 +470,12 @@ public class ChatController {
));
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 -> {
if (emitterDone.get()) return;
try {
@ -1061,7 +1076,12 @@ public class ChatController {
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
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 -> {
if (emitterDone.get()) return;
try {

View File

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

View File

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

View File

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

View File

@ -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;
}
}

View File

@ -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) {
}

View File

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

View File

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

View File

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

View File

@ -28,6 +28,22 @@ public class CronJobDTO {
private LocalDateTime createTime;
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) {
CronJobDTO dto = new CronJobDTO();
dto.setId(entity.getId());
@ -43,6 +59,15 @@ public class CronJobDTO {
dto.setLastRunTime(entity.getLastRunTime());
dto.setCreateTime(entity.getCreateTime());
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;
}
@ -63,6 +88,8 @@ public class CronJobDTO {
entity.setTriggerMessage(this.triggerMessage);
entity.setRequestBody(this.requestBody);
entity.setEnabled(this.enabled);
entity.setChannelId(this.channelId);
entity.setDeliveryConfig(this.deliveryConfig);
return entity;
}
}

View File

@ -1,6 +1,7 @@
package vip.mate.cron.model;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.time.LocalDateTime;
@ -11,7 +12,7 @@ import java.time.LocalDateTime;
* @author MateClaw Team
*/
@Data
@TableName("mate_cron_job")
@TableName(value = "mate_cron_job", autoResultMap = true)
public class CronJobEntity {
@TableId(type = IdType.ASSIGN_ID)
@ -49,6 +50,21 @@ public class CronJobEntity {
/** 上次执行时间 */
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)
private LocalDateTime createTime;
@ -56,4 +72,16 @@ public class CronJobEntity {
private LocalDateTime updateTime;
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;
}

View File

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

View File

@ -2,8 +2,12 @@ package vip.mate.cron.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
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 java.util.List;
/**
* 定时任务 Mapper
*
@ -11,4 +15,47 @@ import vip.mate.cron.model.CronJobEntity;
*/
@Mapper
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);
}

View File

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

View File

@ -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;
}
}

View File

@ -12,15 +12,12 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Service;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper;
import vip.mate.exception.MateClawException;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.workspace.conversation.ConversationService;
import java.time.LocalDateTime;
import java.time.ZoneId;
@ -45,17 +42,23 @@ public class CronJobService implements ApplicationRunner {
private final CronJobMapper cronJobMapper;
private final AgentMapper agentMapper;
private final AgentService agentService;
private final ConversationService conversationService;
private final ConversationCompletionPublisher completionPublisher;
/**
* RFC-063r §2.7.1: cron-tick execution moved to {@link CronJobRunner}
* (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 ConcurrentHashMap<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
private final ReentrantLock schedulerLock = new ReentrantLock();
/** 定时任务触发时使用的系统用户标识 */
private static final String SYSTEM_USER = "system";
// ==================== 初始化与销毁 ====================
/**
@ -88,9 +91,10 @@ public class CronJobService implements ApplicationRunner {
// ==================== CRUD ====================
public List<CronJobDTO> list() {
List<CronJobEntity> entities = cronJobMapper.selectList(
new LambdaQueryWrapper<CronJobEntity>()
.orderByDesc(CronJobEntity::getCreateTime));
// RFC-063r §2.14: use the variant that aggregates the most-recent
// delivery_status from mate_cron_job_run so the list page can
// render the "最近投递" badge without a per-row N+1 query.
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus();
// 批量加载 Agent 名称
List<Long> agentIds = entities.stream()
@ -107,7 +111,9 @@ public class CronJobService implements ApplicationRunner {
}
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) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
@ -219,8 +225,19 @@ public class CronJobService implements ApplicationRunner {
if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
}
// 异步执行不阻塞请求线程
scheduler.submit(() -> executeJob(entity));
// RFC-063r §2.7.1: delegate to CronJobRunner via Spring proxy so
// 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());
ZoneId zoneId = ZoneId.of(job.getTimezone());
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);
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(),
job.getCronExpression(), job.getTimezone());
@ -249,46 +275,18 @@ public class CronJobService implements ApplicationRunner {
}
// ==================== 任务执行 ====================
private void executeJob(CronJobEntity job) {
String conversationId = "cron:" + job.getId();
try {
log.info("[CronJob] Executing job {} ({}), type={}", job.getId(), job.getName(), job.getTaskType());
// 确保会话存在使用 SYSTEM_USER 作为定时触发的所有者标识workspace agent 获取
AgentEntity cronAgent = agentMapper.selectById(job.getAgentId());
Long cronWorkspaceId = cronAgent != null ? cronAgent.getWorkspaceId() : 1L;
conversationService.getOrCreateConversation(conversationId, job.getAgentId(), SYSTEM_USER, cronWorkspaceId);
String userMessage;
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);
}
}
//
// RFC-063r §2.7.1: the executeJob body moved to CronJobRunner so the
// three-segment transactional model in CronJobLifecycleService runs
// through a Spring AOP proxy. CronJobService now only owns CRUD +
// scheduler registration, and the lastRunTime / nextRunTime bookkeeping
// hook below which deliberately runs *after* the runner returns so a
// failed run still advances the next-run pointer (otherwise a single
// bad run wedges all future ticks).
//
// Both register() and runNow() now delegate to cronJobRunner.executeJob;
// see those methods above. The wrap below ensures next-run rolls forward
// regardless of run outcome.
/**
* 合并更新 lastRunTime nextRunTime单次 DB 写入替代原来的 4 selectById + updateById

View File

@ -19,6 +19,24 @@ public class CronJobRunEntity {
private LocalDateTime finishedAt;
private String errorMessage;
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)
private LocalDateTime createTime;
}

View File

@ -1,15 +1,23 @@
package vip.mate.i18n;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.lang.Nullable;
/**
* 国际化工具回调装饰器
* <p>
* 包装原始 ToolCallback覆写 {@link #getToolDefinition()} 返回本地化描述
* 其他方法callname 全部委托给原始回调
* I18n decorator around a {@link ToolCallback} overrides
* {@link #getToolDefinition()} so the LLM sees the localized description.
*
* @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 {
@ -31,8 +39,18 @@ public class LocaleAwareToolCallback implements ToolCallback {
.build();
}
@Override
public ToolMetadata getToolMetadata() {
return delegate.getToolMetadata();
}
@Override
public String call(String toolInput) {
return delegate.call(toolInput);
}
@Override
public String call(String toolInput, @Nullable ToolContext toolContext) {
return delegate.call(toolInput, toolContext);
}
}

View File

@ -12,8 +12,10 @@ import com.microsoft.playwright.PlaywrightException;
import com.microsoft.playwright.options.LoadState;
import jakarta.annotation.PreDestroy;
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.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.browser.BrowserLauncher;
@ -61,6 +63,15 @@ public class BrowserUseTool {
private final Object playwrightLock = new Object();
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 -> {
Thread t = new Thread(r, "browser-idle-watchdog");
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 = "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 = "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()) {
return error("action is required");
}
@ -163,7 +181,7 @@ public class BrowserUseTool {
*/
private void broadcastBrowserEvent(String action, boolean success, String url, String title,
String screenshot, long durationMs) {
String conversationId = ToolExecutionContext.conversationId();
String conversationId = ToolExecutionContext.conversationId(currentToolContext);
if (conversationId == null || streamTracker == null) {
return;
}

View File

@ -5,9 +5,12 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
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.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.cron.model.CronJobDTO;
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 = "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 = "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 {
// Resolve current agent ID from conversation context
String conversationId = ToolExecutionContext.conversationId();
Long agentId = resolveAgentId(conversationId);
// RFC-063r §2.5: prefer the explicit ChatOrigin (channelId / channelTarget
// / agentId all live there). Fall back to the legacy ToolExecutionContext
// 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();
dto.setName(name);
@ -54,6 +66,12 @@ public class CronJobTool {
dto.setTaskType("text");
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);
JSONObject result = new JSONObject();
@ -166,4 +184,17 @@ public class CronJobTool {
result.set("error", message);
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()));
}
}
}

View File

@ -5,10 +5,13 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
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.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.channel.web.ChatStreamTracker;
@ -76,7 +79,12 @@ public class DelegateAgentTool {
For multiple parallel tasks, use delegateParallel instead.""")
public String delegateToAgent(
@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()) {
return "[错误] 请指定目标 Agent 名称。" + availableAgentsHint();
@ -111,8 +119,11 @@ public class DelegateAgentTool {
}
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
// Execute child agent
ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId);
// Execute child agent RFC-063r §2.5 改动点 5: inherit the parent
// 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
if (stopRelay != null) stopRelay.run();
@ -133,7 +144,9 @@ public class DelegateAgentTool {
Input is a JSON array: [{"agentName":"Agent名称","task":"任务描述"}, ...]""")
public String delegateParallel(
@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
List<Map<String, String>> tasks;
@ -206,9 +219,14 @@ public class DelegateAgentTool {
long startTime = System.currentTimeMillis();
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) {
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);
// Broadcast per-child completion as soon as each child finishes
@ -385,11 +403,18 @@ public class DelegateAgentTool {
* truncated length, making "blank_success" detection unreliable.
*/
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);
try {
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;
// Measure lengths before truncation so ChildResult carries accurate metadata.
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,

View File

@ -2,8 +2,10 @@ package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
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.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
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 = "Generation count (1-4), default 1", required = false) Integer count,
@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();
return switch (normalizedAction) {
case "list" -> handleListAction();
case "status" -> handleStatusAction(taskId);
default -> handleGenerateAction(prompt, size, aspectRatio, count, model);
case "status" -> handleStatusAction(taskId, ctx);
default -> handleGenerateAction(prompt, size, aspectRatio, count, model, ctx);
};
}
@ -84,8 +88,8 @@ public class ImageGenerateTool {
// ==================== action=status ====================
private String handleStatusAction(String taskId) {
String conversationId = ToolExecutionContext.conversationId();
private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
if (taskId != null && !taskId.isBlank()) {
AsyncTaskInfo info = imageGenerationService.checkTaskStatus(taskId);
@ -117,9 +121,9 @@ public class ImageGenerateTool {
// ==================== action=generate ====================
private String handleGenerateAction(String prompt, String size, String aspectRatio,
Integer count, String model) {
String conversationId = ToolExecutionContext.conversationId();
String username = ToolExecutionContext.username();
Integer count, String model, @Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
String username = ToolExecutionContext.username(ctx);
if (conversationId == null || conversationId.isBlank()) {
return "错误:无法获取当前会话信息,请重试";

View File

@ -3,8 +3,10 @@ package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
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.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
@ -57,7 +59,9 @@ public class ReadFileTool {
public String read_file(
@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 = "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();
result.set("filePath", filePath);
@ -65,7 +69,9 @@ public class ReadFileTool {
try {
Path path;
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) {
// Sandbox rejected the literal path. The LLM may have hallucinated
// a Linux-style path (e.g. /app/Dockerfile) for a chat-upload that

View File

@ -1,11 +1,21 @@
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 方法传递执行环境信息
* <p>
* ToolExecutionExecutor.executeSingleTool() set finally clear
* 视频生成等需要知道 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
*/
public final class ToolExecutionContext {
@ -47,4 +57,34 @@ public final class ToolExecutionContext {
USERNAME.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();
}
}

View File

@ -2,8 +2,10 @@ package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
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.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
@ -45,15 +47,18 @@ public class VideoGenerateTool {
@ToolParam(description = "视频时长(秒),如 5 或 10默认 5", required = false) Integer duration,
@ToolParam(description = "参考图片 URL图生视频模式", required = false) String imageUrl,
@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
String normalizedAction = (action == null || action.isBlank()) ? "generate" : action.trim().toLowerCase();
return switch (normalizedAction) {
case "list" -> handleListAction();
case "status" -> handleStatusAction(taskId);
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model);
case "status" -> handleStatusAction(taskId, ctx);
default -> handleGenerateAction(prompt, aspectRatio, duration, imageUrl, model, ctx);
};
}
@ -92,8 +97,8 @@ public class VideoGenerateTool {
// ==================== action=status ====================
private String handleStatusAction(String taskId) {
String conversationId = ToolExecutionContext.conversationId();
private String handleStatusAction(String taskId, @Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
// 指定 taskId 查询
if (taskId != null && !taskId.isBlank()) {
@ -124,9 +129,9 @@ public class VideoGenerateTool {
// ==================== action=generate ====================
private String handleGenerateAction(String prompt, String aspectRatio, Integer duration,
String imageUrl, String model) {
String conversationId = ToolExecutionContext.conversationId();
String username = ToolExecutionContext.username();
String imageUrl, String model, @Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
String username = ToolExecutionContext.username(ctx);
if (conversationId == null || conversationId.isBlank()) {
return "错误:无法获取当前会话信息,请重试";

View File

@ -1,6 +1,9 @@
package vip.mate.tool.guard;
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 java.io.IOException;
@ -33,9 +36,19 @@ public final class WorkspacePathGuard {
* @throws IllegalArgumentException 路径不在允许范围内
*/
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();
String basePath = ToolExecutionContext.workspaceBasePath();
String basePath = resolveBasePath(ctx);
if (basePath == null || basePath.isBlank()) {
return normalized; // 未配置活动目录不限制
}
@ -72,10 +85,35 @@ public final class WorkspacePathGuard {
* @return 活动目录 Path未配置时返回 null
*/
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()) {
return null;
}
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();
}
}

View File

@ -2,8 +2,10 @@ package vip.mate.tool.music;
import lombok.RequiredArgsConstructor;
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.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.builtin.ToolExecutionContext;
@ -23,9 +25,11 @@ public class MusicGenerateTool {
public String music_generate(
@ToolParam(description = "音乐风格/场景描述,如:'轻快的钢琴爵士乐'、'史诗电影配乐'、'欢快的流行歌曲'") String prompt,
@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) {
return "无法获取会话 ID";
}

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1432,6 +1432,15 @@ export default {
lastRun: 'Last Run',
enabled: 'Enabled',
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' },
cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' },

View File

@ -1442,6 +1442,15 @@ export default {
lastRun: '上次执行',
enabled: '启用',
actions: '操作',
// RFC-063r §2.14: 列表页"最近投递"列
lastDelivery: '最近投递',
},
// RFC-063r §2.14: delivery_status 状态机展示
lastDelivery: {
none: '无投递',
pending: '投递中',
delivered: '已送达',
not_delivered: '投递失败',
},
taskTypes: { text: '文字消息', agent: 'Agent 目标' },
cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' },

View File

@ -786,4 +786,12 @@ export interface CronJob {
lastRunTime?: string
createTime?: 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
}

View File

@ -23,6 +23,7 @@
<th>{{ t('cronJobs.columns.name') }}</th>
<th>{{ t('cronJobs.columns.cron') }}</th>
<th>{{ t('tokenUsage.date') }}</th>
<th>{{ t('cronJobs.columns.lastDelivery') }}</th>
<th>{{ t('cronJobs.columns.enabled') }}</th>
<th>{{ t('cronJobs.columns.actions') }}</th>
</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>
</div>
</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>
<label class="toggle-switch">
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
@ -87,7 +96,7 @@
</td>
</tr>
<tr v-if="store.jobs.length === 0">
<td colspan="5" class="empty-row">
<td colspan="6" class="empty-row">
<div class="empty-state">
<span class="empty-icon">&#9201;</span>
<p>{{ t('cronJobs.noJobs') }}</p>
@ -653,6 +662,21 @@ function formatTime(datetime: string | undefined): string {
}
.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 input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }