mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
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
91 lines
3.2 KiB
Java
91 lines
3.2 KiB
Java
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();
|
||
}
|
||
}
|