mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
sync: ChannelToolProvider SPI + node-local reconcile framework for channel-native tools
This commit is contained in:
parent
a9fa8e7fb1
commit
85d7ee23c4
@ -40,6 +40,12 @@ public class ChannelService {
|
|||||||
* Spring's eager constructor wiring.
|
* Spring's eager constructor wiring.
|
||||||
*/
|
*/
|
||||||
private final ObjectProvider<FeishuClientFactory> feishuClientFactoryProvider;
|
private final ObjectProvider<FeishuClientFactory> feishuClientFactoryProvider;
|
||||||
|
/**
|
||||||
|
* Reconcile hook for channel-native tools. {@link ObjectProvider}
|
||||||
|
* for the same reasons as above — service still works in test
|
||||||
|
* contexts that don't load the channel-tool subsystem.
|
||||||
|
*/
|
||||||
|
private final ObjectProvider<vip.mate.channel.tool.ChannelToolService> channelToolServiceProvider;
|
||||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -171,6 +177,19 @@ public class ChannelService {
|
|||||||
factory.evict(channelId);
|
factory.evict(channelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Trigger an immediate channel-tool reconcile on this node so
|
||||||
|
// newly-enabled / -disabled / -reconfigured channels' tool sets
|
||||||
|
// align before the next reconcile tick. Other nodes catch up
|
||||||
|
// within ChannelToolService.RECONCILE_INTERVAL_SECONDS.
|
||||||
|
vip.mate.channel.tool.ChannelToolService cts =
|
||||||
|
channelToolServiceProvider.getIfAvailable();
|
||||||
|
if (cts != null) {
|
||||||
|
try {
|
||||||
|
cts.syncNow();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[ChannelService] syncNow() failed (non-fatal): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) {
|
private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) {
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience {@link ToolCallback} that wraps a {@code (name,
|
||||||
|
* description, schema, handler)} tuple — saves channel-tool providers
|
||||||
|
* from having to spell out the whole {@code ToolCallback} interface
|
||||||
|
* for every handler.
|
||||||
|
*
|
||||||
|
* <p>Identical in spirit to the skill-runtime wrapper but kept in the
|
||||||
|
* channel-tool domain so cross-domain refactors don't accidentally
|
||||||
|
* couple the two.
|
||||||
|
*/
|
||||||
|
public class ChannelToolCallback implements ToolCallback {
|
||||||
|
|
||||||
|
private final ToolDefinition definition;
|
||||||
|
private final Function<String, String> handler;
|
||||||
|
|
||||||
|
public ChannelToolCallback(String name,
|
||||||
|
String description,
|
||||||
|
String inputSchema,
|
||||||
|
Function<String, String> handler) {
|
||||||
|
this.definition = ToolDefinition.builder()
|
||||||
|
.name(name)
|
||||||
|
.description(description)
|
||||||
|
.inputSchema(inputSchema)
|
||||||
|
.build();
|
||||||
|
this.handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolDefinition getToolDefinition() {
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String call(String toolInput) {
|
||||||
|
return handler.apply(toolInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String call(String toolInput, ToolContext toolContext) {
|
||||||
|
return handler.apply(toolInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a new callback that is identical in every way except it
|
||||||
|
* carries the supplied {@code actualName}. Used by
|
||||||
|
* {@link ChannelToolService} to apply the {@code _c<channelId>}
|
||||||
|
* suffix without having the provider know about per-instance names.
|
||||||
|
*/
|
||||||
|
public ToolCallback renamed(String actualName) {
|
||||||
|
return new ChannelToolCallback(
|
||||||
|
actualName,
|
||||||
|
definition.description(),
|
||||||
|
definition.inputSchema(),
|
||||||
|
handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context handed to {@link ChannelToolProvider#createTools(ChannelToolContext)}
|
||||||
|
* when materialising tools for one specific {@code mate_channel} row.
|
||||||
|
*
|
||||||
|
* <p>{@link #channelId()} alone is enough for the provider to call its
|
||||||
|
* SDK-client factory (e.g. {@code FeishuClientFactory.client(channelId)});
|
||||||
|
* the remaining fields are convenience for handlers that want to
|
||||||
|
* default any "who should I attribute this to" or "which app are we
|
||||||
|
* acting on behalf of" parameters.
|
||||||
|
*
|
||||||
|
* @param channelId {@code mate_channel.id} — the only required field
|
||||||
|
* @param channelName display name of the channel row (for log clarity)
|
||||||
|
* @param channelType {@code mate_channel.channel_type} — matches
|
||||||
|
* {@link ChannelToolProvider#channelType()}
|
||||||
|
* @param agentId the {@code mate_agent.id} this channel routes
|
||||||
|
* inbound messages to, may be null when the channel
|
||||||
|
* is unbound
|
||||||
|
* @param config parsed {@code mate_channel.config_json} as a map
|
||||||
|
* (e.g. {@code app_id}, {@code app_secret},
|
||||||
|
* {@code domain}). Provided so handlers don't each
|
||||||
|
* re-parse the JSON.
|
||||||
|
*/
|
||||||
|
public record ChannelToolContext(
|
||||||
|
Long channelId,
|
||||||
|
String channelName,
|
||||||
|
String channelType,
|
||||||
|
Long agentId,
|
||||||
|
Map<String, Object> config) {
|
||||||
|
|
||||||
|
public ChannelToolContext {
|
||||||
|
if (channelId == null) {
|
||||||
|
throw new IllegalArgumentException("ChannelToolContext.channelId must not be null");
|
||||||
|
}
|
||||||
|
if (channelType == null || channelType.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ChannelToolContext.channelType must be non-blank");
|
||||||
|
}
|
||||||
|
if (config == null) {
|
||||||
|
config = Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static description of one channel-native tool — what
|
||||||
|
* {@link ChannelToolProvider#describeTools()} returns. Used by
|
||||||
|
* {@link ChannelToolService} to upsert {@code mate_tool} rows and by
|
||||||
|
* the admin UI to render the tool in the Channel group before any
|
||||||
|
* specific channel instance is materialised.
|
||||||
|
*
|
||||||
|
* <p>"Static" here means: no credentials needed, no network call —
|
||||||
|
* pure metadata about a tool that this channel type COULD expose if
|
||||||
|
* connected. The per-instance materialisation (binding the tool to a
|
||||||
|
* specific {@code mate_channel} row + its SDK client) is the second
|
||||||
|
* step performed by {@link ChannelToolProvider#createTools}.
|
||||||
|
*
|
||||||
|
* @param name tool base name (e.g.
|
||||||
|
* {@code "feishu_calendar_create_event"}).
|
||||||
|
* Per-instance materialisation prefixes
|
||||||
|
* {@code _c<channelId>} to keep the actual
|
||||||
|
* registered tool name stable across CRUD —
|
||||||
|
* see {@link ChannelToolService}.
|
||||||
|
* @param displayName human-readable label for UI
|
||||||
|
* @param description short description LLM uses to decide whether
|
||||||
|
* to call this tool. Long-form usage notes
|
||||||
|
* belong in a companion skill package.
|
||||||
|
* @param inputSchema JSON Schema string describing the tool's args
|
||||||
|
* @param mutating {@code true} for write operations. The tool
|
||||||
|
* row defaults to {@code enabled=false} and
|
||||||
|
* {@link ChannelToolService} seeds a DB rule
|
||||||
|
* so that calls hit Guard / approval before
|
||||||
|
* executing.
|
||||||
|
* @param enabledByDefault should the {@code mate_tool} row be created
|
||||||
|
* with {@code enabled=true}? Forced to
|
||||||
|
* {@code false} when {@link #mutating()}.
|
||||||
|
*/
|
||||||
|
public record ChannelToolDescriptor(
|
||||||
|
String name,
|
||||||
|
String displayName,
|
||||||
|
String description,
|
||||||
|
String inputSchema,
|
||||||
|
boolean mutating,
|
||||||
|
boolean enabledByDefault) {
|
||||||
|
|
||||||
|
public ChannelToolDescriptor {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ChannelToolDescriptor.name must be non-blank");
|
||||||
|
}
|
||||||
|
if (description == null || description.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ChannelToolDescriptor.description must be non-blank");
|
||||||
|
}
|
||||||
|
if (inputSchema == null || inputSchema.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("ChannelToolDescriptor.inputSchema must be non-blank");
|
||||||
|
}
|
||||||
|
// Mutating tools always start disabled regardless of caller intent.
|
||||||
|
if (mutating && enabledByDefault) {
|
||||||
|
enabledByDefault = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPI implemented once per channel type that exposes platform-native
|
||||||
|
* capabilities (e.g. Feishu calendar / docx, WeCom approval, DingTalk
|
||||||
|
* task) as Agent tools — avoiding the need for each user to also
|
||||||
|
* configure a separate MCP server with duplicate credentials.
|
||||||
|
*
|
||||||
|
* <p>Implementations are plain Spring beans. {@link ChannelToolService}
|
||||||
|
* collects every {@code ChannelToolProvider} bean at startup, indexed
|
||||||
|
* by {@link #channelType()}; channel CRUD then reconciles per
|
||||||
|
* {@code mate_channel} row by calling {@link #describeTools()} (for
|
||||||
|
* the static catalog → DB upsert) and {@link #createTools} (for the
|
||||||
|
* per-instance ToolCallback materialisation).
|
||||||
|
*
|
||||||
|
* <p>Implementations must NOT touch credentials in
|
||||||
|
* {@link #describeTools()} — that path is invoked even when no channel
|
||||||
|
* is configured. Credential access is restricted to
|
||||||
|
* {@link #createTools(ChannelToolContext)} which receives an already-
|
||||||
|
* validated context.
|
||||||
|
*/
|
||||||
|
public interface ChannelToolProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel type this provider serves — matches
|
||||||
|
* {@code ChannelAdapter.getChannelType()} (e.g. {@code "feishu"}).
|
||||||
|
* Used by {@link ChannelToolService} for routing.
|
||||||
|
*/
|
||||||
|
String channelType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static catalogue of every tool this channel type COULD expose.
|
||||||
|
* Pure metadata — no credentials, no I/O. Called once at startup
|
||||||
|
* to seed {@code mate_tool} rows for the admin UI.
|
||||||
|
*/
|
||||||
|
List<ChannelToolDescriptor> describeTools();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialise per-instance {@link ToolCallback} instances for the
|
||||||
|
* given channel row. Invoked by {@link ChannelToolService} during
|
||||||
|
* reconcile; the returned callbacks are registered into
|
||||||
|
* {@code ToolRegistry} via
|
||||||
|
* {@code ToolRegistry.registerPluginTool(...)}.
|
||||||
|
*
|
||||||
|
* <p>Each returned callback's {@code getToolDefinition().name()}
|
||||||
|
* must match a descriptor in {@link #describeTools()} (the service
|
||||||
|
* will rename to the per-instance actual name).
|
||||||
|
*/
|
||||||
|
List<ToolCallback> createTools(ChannelToolContext context);
|
||||||
|
}
|
||||||
@ -0,0 +1,331 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
import vip.mate.channel.repository.ChannelMapper;
|
||||||
|
import vip.mate.tool.ToolRegistry;
|
||||||
|
import vip.mate.tool.model.ToolEntity;
|
||||||
|
import vip.mate.tool.repository.ToolMapper;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node-local reconciler that keeps every {@link ChannelToolProvider}'s
|
||||||
|
* tool callbacks registered in {@link ToolRegistry} in sync with the
|
||||||
|
* currently-enabled {@code mate_channel} rows whose type has a
|
||||||
|
* registered provider.
|
||||||
|
*
|
||||||
|
* <p>Three trigger points, all idempotent:
|
||||||
|
* <ol>
|
||||||
|
* <li><b>Startup</b> — {@link ApplicationRunner} runs once per node
|
||||||
|
* after the Spring context is ready</li>
|
||||||
|
* <li><b>Periodic (60s)</b> — picks up changes made by another node
|
||||||
|
* (config rotated, channel enabled / disabled), per RFC v3
|
||||||
|
* "reconcile-driven, not adapter-lifecycle-driven"</li>
|
||||||
|
* <li><b>Local CRUD</b> — {@code ChannelService} calls
|
||||||
|
* {@link #syncNow()} after every channel mutation so the local
|
||||||
|
* node aligns instantly (other nodes wait at most one tick)</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Decoupled from {@code ChannelManager} / adapter lifecycle / leader
|
||||||
|
* election: tools are pure OpenAPI calls keyed by channelId and never
|
||||||
|
* need the WebSocket or the leader lease. RFC §4.1 makes this explicit.
|
||||||
|
*
|
||||||
|
* <p>Tool names get a stable {@code _c<channelId>} suffix
|
||||||
|
* unconditionally — the channelId is immutable for the channel's
|
||||||
|
* lifetime, so the registered tool name never drifts even when other
|
||||||
|
* channels of the same type are added / deleted (RFC v5 §4.3).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class ChannelToolService {
|
||||||
|
|
||||||
|
/** Periodic reconcile cadence — RFC default. */
|
||||||
|
static final long RECONCILE_INTERVAL_SECONDS = 60;
|
||||||
|
|
||||||
|
/** Suffix prefix appended before the channelId on the actual tool name. */
|
||||||
|
public static final String INSTANCE_SUFFIX_PREFIX = "_c";
|
||||||
|
|
||||||
|
private final List<ChannelToolProvider> providerBeans;
|
||||||
|
private final ChannelMapper channelMapper;
|
||||||
|
private final ToolMapper toolMapper;
|
||||||
|
private final ToolRegistry toolRegistry;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/** Indexed at startup: channelType → provider. */
|
||||||
|
private Map<String, ChannelToolProvider> providersByType = Map.of();
|
||||||
|
|
||||||
|
/** Node-local registration state: channelId → list of actual tool names registered. */
|
||||||
|
private final ConcurrentHashMap<Long, List<String>> registered = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** channelId → the {@code update_time} value last seen on reconcile (config-change detection). */
|
||||||
|
private final ConcurrentHashMap<Long, LocalDateTime> registeredUpdateTime = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Daemon scheduler for the periodic tick. */
|
||||||
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "channel-tool-reconcile");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
public ChannelToolService(List<ChannelToolProvider> providerBeans,
|
||||||
|
ChannelMapper channelMapper,
|
||||||
|
ToolMapper toolMapper,
|
||||||
|
ToolRegistry toolRegistry,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.providerBeans = providerBeans != null ? providerBeans : List.of();
|
||||||
|
this.channelMapper = channelMapper;
|
||||||
|
this.toolMapper = toolMapper;
|
||||||
|
this.toolRegistry = toolRegistry;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void index() {
|
||||||
|
providersByType = providerBeans.stream()
|
||||||
|
.collect(Collectors.toUnmodifiableMap(
|
||||||
|
ChannelToolProvider::channelType,
|
||||||
|
p -> p,
|
||||||
|
(a, b) -> {
|
||||||
|
log.warn("[channel-tool] Duplicate ChannelToolProvider for type {} ({} vs {}); keeping first",
|
||||||
|
a.channelType(), a.getClass().getSimpleName(), b.getClass().getSimpleName());
|
||||||
|
return a;
|
||||||
|
}));
|
||||||
|
if (providersByType.isEmpty()) {
|
||||||
|
log.info("[channel-tool] No ChannelToolProvider beans wired — reconcile loop will be a no-op");
|
||||||
|
} else {
|
||||||
|
log.info("[channel-tool] Registered providers: {}", providersByType.keySet());
|
||||||
|
}
|
||||||
|
// Schedule the periodic tick. Startup reconcile runs separately
|
||||||
|
// via the ApplicationRunner bean below so the Spring context
|
||||||
|
// is fully ready (including any provider's transitive deps).
|
||||||
|
scheduler.scheduleAtFixedRate(this::tickQuietly,
|
||||||
|
RECONCILE_INTERVAL_SECONDS, RECONCILE_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial reconcile after Spring is fully ready. Spring runs every
|
||||||
|
* {@link ApplicationRunner} bean after all {@code @PostConstruct}
|
||||||
|
* hooks but before serving traffic.
|
||||||
|
*/
|
||||||
|
@org.springframework.context.annotation.Bean
|
||||||
|
ApplicationRunner channelToolStartupReconcile() {
|
||||||
|
return args -> {
|
||||||
|
log.info("[channel-tool] Startup reconcile");
|
||||||
|
tickQuietly();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
void shutdown() {
|
||||||
|
scheduler.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an immediate reconcile from this node. Called by
|
||||||
|
* {@code ChannelService} after channel CRUD so the local node
|
||||||
|
* aligns within the same request; other nodes catch up at the
|
||||||
|
* next periodic tick.
|
||||||
|
*/
|
||||||
|
public void syncNow() {
|
||||||
|
tickQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tickQuietly() {
|
||||||
|
try {
|
||||||
|
reconcile();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[channel-tool] reconcile failed (will retry next tick): {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff the local registration against the expected set (enabled
|
||||||
|
* channels of types we have providers for) and apply just the
|
||||||
|
* deltas — unregister stale, register new, rebuild on config change.
|
||||||
|
* Idempotent.
|
||||||
|
*/
|
||||||
|
synchronized void reconcile() {
|
||||||
|
if (providersByType.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ChannelEntity> enabled = channelMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<ChannelEntity>().eq(ChannelEntity::getEnabled, true));
|
||||||
|
Map<Long, ChannelEntity> desired = new HashMap<>();
|
||||||
|
for (ChannelEntity ch : enabled) {
|
||||||
|
if (providersByType.containsKey(ch.getChannelType())) {
|
||||||
|
desired.put(ch.getId(), ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Unregister channels no longer in the desired set
|
||||||
|
for (Long goneId : new ArrayList<>(registered.keySet())) {
|
||||||
|
if (!desired.containsKey(goneId)) {
|
||||||
|
unregisterChannel(goneId);
|
||||||
|
deleteToolRows(goneId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Register / re-register changed or new channels
|
||||||
|
for (ChannelEntity ch : desired.values()) {
|
||||||
|
LocalDateTime seen = registeredUpdateTime.get(ch.getId());
|
||||||
|
if (seen != null && seen.equals(ch.getUpdateTime())) {
|
||||||
|
continue; // already registered + config unchanged
|
||||||
|
}
|
||||||
|
// Config changed — drop the stale callbacks before re-registering
|
||||||
|
// so handler closures don't keep stale config.
|
||||||
|
unregisterChannel(ch.getId());
|
||||||
|
try {
|
||||||
|
registerChannel(ch);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[channel-tool] register failed for channel {} ({}): {}",
|
||||||
|
ch.getId(), ch.getChannelType(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerChannel(ChannelEntity ch) {
|
||||||
|
ChannelToolProvider provider = providersByType.get(ch.getChannelType());
|
||||||
|
if (provider == null) return;
|
||||||
|
List<ChannelToolDescriptor> descriptors = provider.describeTools();
|
||||||
|
if (descriptors == null || descriptors.isEmpty()) {
|
||||||
|
log.debug("[channel-tool] Provider {} returned no descriptors; skipping channel {}",
|
||||||
|
provider.channelType(), ch.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, String> nameMap = upsertToolRows(ch, descriptors);
|
||||||
|
|
||||||
|
ChannelToolContext context = new ChannelToolContext(
|
||||||
|
ch.getId(), ch.getName(), ch.getChannelType(), ch.getAgentId(),
|
||||||
|
parseConfig(ch.getConfigJson()));
|
||||||
|
List<ToolCallback> callbacks;
|
||||||
|
try {
|
||||||
|
callbacks = provider.createTools(context);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[channel-tool] createTools failed for channel {} ({}): {}",
|
||||||
|
ch.getId(), provider.channelType(), e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (callbacks == null) return;
|
||||||
|
|
||||||
|
List<String> actualNames = new ArrayList<>();
|
||||||
|
for (ToolCallback cb : callbacks) {
|
||||||
|
String baseName = cb.getToolDefinition().name();
|
||||||
|
String actualName = nameMap.getOrDefault(baseName, baseName + INSTANCE_SUFFIX_PREFIX + ch.getId());
|
||||||
|
ToolCallback renamed = (cb instanceof ChannelToolCallback ctc)
|
||||||
|
? ctc.renamed(actualName)
|
||||||
|
: cb; // legacy callback (any other ToolCallback impl) registers under its own name
|
||||||
|
toolRegistry.registerPluginTool(renamed, () -> isToolRowEnabled(actualName));
|
||||||
|
actualNames.add(actualName);
|
||||||
|
}
|
||||||
|
registered.put(ch.getId(), actualNames);
|
||||||
|
registeredUpdateTime.put(ch.getId(), ch.getUpdateTime());
|
||||||
|
log.info("[channel-tool] Registered {} tool(s) for channel {} ({})",
|
||||||
|
actualNames.size(), ch.getId(), ch.getChannelType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unregisterChannel(Long channelId) {
|
||||||
|
registeredUpdateTime.remove(channelId);
|
||||||
|
List<String> names = registered.remove(channelId);
|
||||||
|
if (names != null && !names.isEmpty()) {
|
||||||
|
names.forEach(toolRegistry::unregisterPluginTool);
|
||||||
|
log.info("[channel-tool] Unregistered {} tool(s) for channel {}", names.size(), channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert {@code mate_tool} rows for each descriptor; returns the
|
||||||
|
* baseName → actual-name map the caller uses when renaming the
|
||||||
|
* provider's callbacks. DB-level uniqueness on {@code mate_tool.name}
|
||||||
|
* (introduced in V100) makes this safe under concurrent reconcile
|
||||||
|
* from multiple nodes.
|
||||||
|
*/
|
||||||
|
private Map<String, String> upsertToolRows(ChannelEntity ch, List<ChannelToolDescriptor> descriptors) {
|
||||||
|
Map<String, String> nameMap = new HashMap<>();
|
||||||
|
for (ChannelToolDescriptor d : descriptors) {
|
||||||
|
String actualName = d.name() + INSTANCE_SUFFIX_PREFIX + ch.getId();
|
||||||
|
nameMap.put(d.name(), actualName);
|
||||||
|
|
||||||
|
ToolEntity existing = toolMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getName, actualName));
|
||||||
|
if (existing == null) {
|
||||||
|
ToolEntity row = new ToolEntity();
|
||||||
|
row.setName(actualName);
|
||||||
|
row.setDisplayName(d.displayName() + " (" + ch.getName() + ")");
|
||||||
|
row.setDescription(d.description());
|
||||||
|
row.setToolType("channel");
|
||||||
|
row.setParamsSchema(d.inputSchema());
|
||||||
|
row.setEnabled(d.enabledByDefault());
|
||||||
|
row.setBuiltin(false);
|
||||||
|
row.setChannelId(ch.getId());
|
||||||
|
try {
|
||||||
|
toolMapper.insert(row);
|
||||||
|
} catch (org.springframework.dao.DuplicateKeyException race) {
|
||||||
|
// Another node beat us to the insert — that's the
|
||||||
|
// whole point of the uk_mate_tool_name unique index.
|
||||||
|
log.debug("[channel-tool] tool row {} already inserted by another node", actualName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Refresh metadata that may have evolved between releases
|
||||||
|
// (description rewrites, schema updates) without clobbering
|
||||||
|
// the user's enable / disable preference.
|
||||||
|
boolean dirty = false;
|
||||||
|
String newDisplay = d.displayName() + " (" + ch.getName() + ")";
|
||||||
|
if (!newDisplay.equals(existing.getDisplayName())) { existing.setDisplayName(newDisplay); dirty = true; }
|
||||||
|
if (!d.description().equals(existing.getDescription())) { existing.setDescription(d.description()); dirty = true; }
|
||||||
|
if (!d.inputSchema().equals(existing.getParamsSchema())) { existing.setParamsSchema(d.inputSchema()); dirty = true; }
|
||||||
|
if (existing.getChannelId() == null) { existing.setChannelId(ch.getId()); dirty = true; }
|
||||||
|
if (!"channel".equals(existing.getToolType())) { existing.setToolType("channel"); dirty = true; }
|
||||||
|
if (dirty) toolMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nameMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteToolRows(Long channelId) {
|
||||||
|
int deleted = toolMapper.delete(
|
||||||
|
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getChannelId, channelId));
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("[channel-tool] Deleted {} mate_tool row(s) for channel {}", deleted, channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isToolRowEnabled(String actualName) {
|
||||||
|
ToolEntity row = toolMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getName, actualName));
|
||||||
|
return row != null && Boolean.TRUE.equals(row.getEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> parseConfig(String configJson) {
|
||||||
|
if (configJson == null || configJson.isBlank()) return Map.of();
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(configJson, new TypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[channel-tool] Failed to parse configJson: {}", e.getMessage());
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- test inspection ----
|
||||||
|
|
||||||
|
int registeredChannelCount() { return registered.size(); }
|
||||||
|
|
||||||
|
Map<String, ChannelToolProvider> providersByTypeForTest() { return providersByType; }
|
||||||
|
}
|
||||||
@ -93,4 +93,43 @@ public class AvailableToolDTO {
|
|||||||
.unavailableReason(null)
|
.unavailableReason(null)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel-native tool — exposed by a
|
||||||
|
* {@link vip.mate.channel.tool.ChannelToolProvider} and registered
|
||||||
|
* by {@code ChannelToolService}. Grouped per owning channel so the
|
||||||
|
* picker shows "Channel · {channelName}" rather than mixing them
|
||||||
|
* into the generic Built-in bucket.
|
||||||
|
*/
|
||||||
|
public static AvailableToolDTO fromChannel(ToolEntity t) {
|
||||||
|
// displayName format set by ChannelToolService is "{base} ({channelName})";
|
||||||
|
// the channel name is what we surface in the picker group label.
|
||||||
|
String channelName = extractChannelName(t.getDisplayName());
|
||||||
|
String groupLabel = channelName.isEmpty() ? "Channel" : "Channel · " + channelName;
|
||||||
|
String groupKey = t.getChannelId() != null ? "channel:" + t.getChannelId() : "channel";
|
||||||
|
return AvailableToolDTO.builder()
|
||||||
|
.rowId("channel#" + t.getName())
|
||||||
|
.source("channel")
|
||||||
|
.providerId(t.getChannelId())
|
||||||
|
.providerName(channelName)
|
||||||
|
.name(t.getName())
|
||||||
|
.rawName(t.getName())
|
||||||
|
.description(t.getDescription() != null ? t.getDescription() : "")
|
||||||
|
.group(groupLabel)
|
||||||
|
.groupId(groupKey)
|
||||||
|
.stale(false)
|
||||||
|
.available(true)
|
||||||
|
.unavailableReason(null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractChannelName(String displayName) {
|
||||||
|
if (displayName == null) return "";
|
||||||
|
int open = displayName.lastIndexOf('(');
|
||||||
|
int close = displayName.lastIndexOf(')');
|
||||||
|
if (open > 0 && close > open) {
|
||||||
|
return displayName.substring(open + 1, close).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,16 @@ public class ToolEntity {
|
|||||||
/** 是否系统内置 */
|
/** 是否系统内置 */
|
||||||
private Boolean builtin;
|
private Boolean builtin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For channel-native tools ({@code tool_type="channel"}), the
|
||||||
|
* {@code mate_channel.id} that materialised this tool. {@link
|
||||||
|
* vip.mate.channel.tool.ChannelToolService} uses this column to
|
||||||
|
* delete a channel's tool rows when its config row is removed and
|
||||||
|
* to detect "config changed → rebuild tool" cases. Null for
|
||||||
|
* built-in / MCP / skill tools.
|
||||||
|
*/
|
||||||
|
private Long channelId;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -63,7 +63,14 @@ public class AvailableToolService {
|
|||||||
private void appendBuiltinTools(List<AvailableToolDTO> out) {
|
private void appendBuiltinTools(List<AvailableToolDTO> out) {
|
||||||
for (ToolEntity t : toolService.listEnabledTools()) {
|
for (ToolEntity t : toolService.listEnabledTools()) {
|
||||||
if (t == null || t.getName() == null || t.getName().isBlank()) continue;
|
if (t == null || t.getName() == null || t.getName().isBlank()) continue;
|
||||||
out.add(AvailableToolDTO.fromBuiltin(t));
|
// Dispatch by toolType so channel-native tools (registered by
|
||||||
|
// ChannelToolService) land in their own picker group rather
|
||||||
|
// than getting lumped under "Built-in".
|
||||||
|
if ("channel".equals(t.getToolType())) {
|
||||||
|
out.add(AvailableToolDTO.fromChannel(t));
|
||||||
|
} else {
|
||||||
|
out.add(AvailableToolDTO.fromBuiltin(t));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
-- V100__channel_tool_support.sql (H2 dialect)
|
||||||
|
--
|
||||||
|
-- Schema support for channel-native tools (e.g. Feishu calendar / docx
|
||||||
|
-- via the ChannelToolProvider SPI). Adds:
|
||||||
|
-- 1. mate_tool.channel_id — owning channel id for tool_type='channel'
|
||||||
|
-- 2. idx_mate_tool_channel — fast lookup by owning channel
|
||||||
|
-- 3. uk_mate_tool_name — name uniqueness so multi-node reconcile
|
||||||
|
-- can do DB-atomic upsert (MERGE INTO) without producing dupes
|
||||||
|
--
|
||||||
|
-- H2 supports CREATE INDEX / ALTER TABLE ... ADD COLUMN IF NOT EXISTS
|
||||||
|
-- natively, so the migration is just three statements.
|
||||||
|
|
||||||
|
ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS channel_id BIGINT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mate_tool_channel ON mate_tool(channel_id);
|
||||||
|
|
||||||
|
-- Deduplicate any pre-existing same-name rows BEFORE adding the unique
|
||||||
|
-- index. Keep the most useful row per name: deleted=0 first, then most
|
||||||
|
-- recently updated, then largest id tie-break. Normal databases have
|
||||||
|
-- name already unique so this is a no-op; tail-noise from migration
|
||||||
|
-- aborts in earlier dev iterations gets cleaned out the same way.
|
||||||
|
DELETE FROM mate_tool WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY name
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN deleted = 0 THEN 0 ELSE 1 END,
|
||||||
|
update_time DESC NULLS LAST,
|
||||||
|
id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM mate_tool
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_mate_tool_name ON mate_tool(name);
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
-- V100__channel_tool_support.sql (MySQL dialect)
|
||||||
|
--
|
||||||
|
-- Mirror of the H2 V100, adapted for MySQL 8.0 — no "IF NOT EXISTS"
|
||||||
|
-- on ADD COLUMN / CREATE INDEX, so we guard with INFORMATION_SCHEMA
|
||||||
|
-- + prepared-statement so this migration is idempotent (essential
|
||||||
|
-- for desktop installs that re-apply migrations after upgrade).
|
||||||
|
|
||||||
|
-- 1. Add channel_id column if missing
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_tool'
|
||||||
|
AND COLUMN_NAME = 'channel_id'
|
||||||
|
);
|
||||||
|
SET @stmt := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_tool ADD COLUMN channel_id BIGINT NULL COMMENT ''owning mate_channel.id for tool_type=channel''',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
-- 2. Add channel-id index if missing
|
||||||
|
SET @idx_exists := (
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_tool'
|
||||||
|
AND INDEX_NAME = 'idx_mate_tool_channel'
|
||||||
|
);
|
||||||
|
SET @stmt := IF(@idx_exists = 0,
|
||||||
|
'CREATE INDEX idx_mate_tool_channel ON mate_tool(channel_id)',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
-- 3. Deduplicate same-name rows before adding the unique index — keep
|
||||||
|
-- deleted=0 first, then most recently updated, then largest id.
|
||||||
|
DELETE FROM mate_tool
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY name
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN deleted = 0 THEN 0 ELSE 1 END,
|
||||||
|
update_time DESC,
|
||||||
|
id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM mate_tool
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Add unique index on name if missing
|
||||||
|
SET @uk_exists := (
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_tool'
|
||||||
|
AND INDEX_NAME = 'uk_mate_tool_name'
|
||||||
|
);
|
||||||
|
SET @stmt := IF(@uk_exists = 0,
|
||||||
|
'CREATE UNIQUE INDEX uk_mate_tool_name ON mate_tool(name)',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.tool.model.AvailableToolDTO;
|
||||||
|
import vip.mate.tool.model.ToolEntity;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the {@code source="channel"} classification — the picker UI
|
||||||
|
* shows "Channel · {channelName}" only when this branch fires; a
|
||||||
|
* regression here silently lumps channel tools into the Built-in
|
||||||
|
* bucket.
|
||||||
|
*/
|
||||||
|
class AvailableToolChannelSourceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("fromChannel sets source=channel + group label includes the channel name")
|
||||||
|
void fromChannelClassifiesAsChannel() {
|
||||||
|
ToolEntity row = new ToolEntity();
|
||||||
|
row.setName("feishu_calendar_list_events_c99");
|
||||||
|
row.setDisplayName("List calendar events (My Feishu Bot)");
|
||||||
|
row.setDescription("List events on the user's Feishu calendar");
|
||||||
|
row.setToolType("channel");
|
||||||
|
row.setChannelId(99L);
|
||||||
|
row.setEnabled(true);
|
||||||
|
|
||||||
|
AvailableToolDTO dto = AvailableToolDTO.fromChannel(row);
|
||||||
|
assertEquals("channel", dto.getSource());
|
||||||
|
assertEquals(99L, dto.getProviderId());
|
||||||
|
assertEquals("My Feishu Bot", dto.getProviderName());
|
||||||
|
assertEquals("Channel · My Feishu Bot", dto.getGroup());
|
||||||
|
assertEquals("channel:99", dto.getGroupId());
|
||||||
|
assertEquals("feishu_calendar_list_events_c99", dto.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("fromChannel handles missing channel name suffix gracefully (no NPE, default group)")
|
||||||
|
void fromChannelHandlesMissingChannelName() {
|
||||||
|
ToolEntity row = new ToolEntity();
|
||||||
|
row.setName("feishu_x");
|
||||||
|
row.setDisplayName("display without parens");
|
||||||
|
row.setDescription("desc");
|
||||||
|
row.setToolType("channel");
|
||||||
|
row.setChannelId(null);
|
||||||
|
AvailableToolDTO dto = AvailableToolDTO.fromChannel(row);
|
||||||
|
assertEquals("channel", dto.getSource());
|
||||||
|
assertEquals("Channel", dto.getGroup());
|
||||||
|
assertEquals("channel", dto.getGroupId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the {@code renamed(actualName)} contract — {@link ChannelToolService}
|
||||||
|
* relies on it to apply the {@code _c<channelId>} suffix without
|
||||||
|
* forcing providers to know per-instance names.
|
||||||
|
*/
|
||||||
|
class ChannelToolCallbackTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("call delegates to the supplied handler")
|
||||||
|
void callDelegates() {
|
||||||
|
ChannelToolCallback cb = new ChannelToolCallback(
|
||||||
|
"test_tool", "test description", "{\"type\":\"object\"}",
|
||||||
|
in -> "echo:" + in);
|
||||||
|
assertEquals("echo:hello", cb.call("hello"));
|
||||||
|
assertEquals("echo:world", cb.call("world", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("renamed returns a new callback carrying the same handler + description + schema")
|
||||||
|
void renamedKeepsBehaviorWithNewName() {
|
||||||
|
ChannelToolCallback original = new ChannelToolCallback(
|
||||||
|
"feishu_doc_read", "read doc", "{}", in -> "READ:" + in);
|
||||||
|
ToolCallback renamed = original.renamed("feishu_doc_read_c42");
|
||||||
|
|
||||||
|
assertNotSame(original, renamed);
|
||||||
|
assertEquals("feishu_doc_read_c42", renamed.getToolDefinition().name());
|
||||||
|
assertEquals("read doc", renamed.getToolDefinition().description());
|
||||||
|
assertEquals("READ:abc", renamed.call("abc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package vip.mate.channel.tool;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the descriptor's compact-record validation:
|
||||||
|
* <ul>
|
||||||
|
* <li>blank name / description / schema → IllegalArgumentException</li>
|
||||||
|
* <li>mutating implies enabledByDefault=false regardless of caller intent</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class ChannelToolDescriptorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("non-mutating tool keeps the caller's enabledByDefault")
|
||||||
|
void readToolHonorsEnabledByDefault() {
|
||||||
|
ChannelToolDescriptor enabled = new ChannelToolDescriptor(
|
||||||
|
"feishu_calendar_list", "List calendars", "List user's Feishu calendars",
|
||||||
|
"{\"type\":\"object\"}", false, true);
|
||||||
|
assertTrue(enabled.enabledByDefault());
|
||||||
|
|
||||||
|
ChannelToolDescriptor disabled = new ChannelToolDescriptor(
|
||||||
|
"feishu_calendar_list", "List", "List", "{\"type\":\"object\"}", false, false);
|
||||||
|
assertFalse(disabled.enabledByDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("mutating tool always lands disabled even when caller asks for enabled")
|
||||||
|
void mutatingForcesDisabledDefault() {
|
||||||
|
ChannelToolDescriptor d = new ChannelToolDescriptor(
|
||||||
|
"feishu_calendar_create_event", "Create event",
|
||||||
|
"Creates an event on the user's calendar",
|
||||||
|
"{\"type\":\"object\"}", true, true);
|
||||||
|
assertFalse(d.enabledByDefault(),
|
||||||
|
"mutating tools must start disabled — write surfaces should require explicit opt-in");
|
||||||
|
assertTrue(d.mutating());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("blank name / description / inputSchema rejected at construction")
|
||||||
|
void blankFieldsRejected() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new ChannelToolDescriptor("", "x", "x", "{}", false, true));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new ChannelToolDescriptor("x", "x", "", "{}", false, true));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new ChannelToolDescriptor("x", "x", "x", " ", false, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("equals / hashCode are record-default (value semantics)")
|
||||||
|
void valueSemantics() {
|
||||||
|
ChannelToolDescriptor a = new ChannelToolDescriptor(
|
||||||
|
"a", "A", "desc", "{}", false, true);
|
||||||
|
ChannelToolDescriptor b = new ChannelToolDescriptor(
|
||||||
|
"a", "A", "desc", "{}", false, true);
|
||||||
|
assertEquals(a, b);
|
||||||
|
assertEquals(a.hashCode(), b.hashCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user