mateclaw/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java
matevip 4011050ceb 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
2026-04-28 21:43:58 +08:00

91 lines
3.2 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
private static final ThreadLocal<String> CONVERSATION_ID = new ThreadLocal<>();
private static final ThreadLocal<String> USERNAME = new ThreadLocal<>();
/** 工作区活动目录(为空不限制) */
private static final ThreadLocal<String> WORKSPACE_BASE_PATH = new ThreadLocal<>();
private ToolExecutionContext() {}
public static void set(String conversationId, String username) {
CONVERSATION_ID.set(conversationId);
USERNAME.set(username);
WORKSPACE_BASE_PATH.remove();
}
public static void set(String conversationId, String username, String workspaceBasePath) {
CONVERSATION_ID.set(conversationId);
USERNAME.set(username);
WORKSPACE_BASE_PATH.set(workspaceBasePath);
}
public static String conversationId() {
return CONVERSATION_ID.get();
}
public static String username() {
return USERNAME.get();
}
/** 获取当前工作区活动目录,为 null 表示不限制 */
public static String workspaceBasePath() {
return WORKSPACE_BASE_PATH.get();
}
public static void clear() {
CONVERSATION_ID.remove();
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();
}
}