mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +08:00
feat(agent): explicit "no skills / no tools" opt-out flags (#184)
This commit is contained in:
parent
ff2620dfcf
commit
0ac325a337
@ -52,8 +52,12 @@ public class AgentBindingController {
|
|||||||
verifyAgentWorkspace(agentId, workspaceId);
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
bindingService.setSkillBindings(agentId, skillIds);
|
bindingService.setSkillBindings(agentId, skillIds);
|
||||||
agentService.invalidateAgentCache(agentId);
|
agentService.invalidateAgentCache(agentId);
|
||||||
|
// The Vue client always sends an array, but a non-Vue caller (curl /
|
||||||
|
// SDK) can POST a body of just `null`, which Spring binds to a null
|
||||||
|
// list. The service tolerates that — guard the audit message too.
|
||||||
|
int count = skillIds == null ? 0 : skillIds.size();
|
||||||
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
|
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
|
||||||
"skills=" + skillIds.size(), null);
|
"skills=" + count, null);
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,8 +102,10 @@ public class AgentBindingController {
|
|||||||
verifyAgentWorkspace(agentId, workspaceId);
|
verifyAgentWorkspace(agentId, workspaceId);
|
||||||
bindingService.setToolBindings(agentId, toolNames);
|
bindingService.setToolBindings(agentId, toolNames);
|
||||||
agentService.invalidateAgentCache(agentId);
|
agentService.invalidateAgentCache(agentId);
|
||||||
|
// Same null-safety rationale as setSkills above.
|
||||||
|
int count = toolNames == null ? 0 : toolNames.size();
|
||||||
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
|
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
|
||||||
"tools=" + toolNames.size(), null);
|
"tools=" + count, null);
|
||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -118,14 +118,33 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Agent 绑定的 enabled skill ID 集合。
|
* Effective bound skill IDs for the agent. Three return states:
|
||||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code null} — no binding rows exist and the agent has not opted
|
||||||
|
* out of skills. Caller treats this as "no agent-level restriction;
|
||||||
|
* inherit every globally-enabled skill" (legacy default).</li>
|
||||||
|
* <li>{@code Set.of()} — either {@code skills_disabled=true} on the agent,
|
||||||
|
* or binding rows exist but none are {@code enabled=true}. Caller
|
||||||
|
* treats this as "this agent is explicitly scoped to zero skills" —
|
||||||
|
* no SKILL.md catalog injection, no skill-expanded tools.</li>
|
||||||
|
* <li>non-empty set — the explicit allowlist.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The {@code skills_disabled} flag takes precedence over row count, so
|
||||||
|
* a stale (disabled flag + leftover rows) row combination still surfaces
|
||||||
|
* as "no skills". The {@code setSkillBindings} / {@code bindSkill} writers
|
||||||
|
* keep these in sync by auto-clearing the flag when a non-empty row set is
|
||||||
|
* persisted.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Set<Long> getBoundSkillIds(Long agentId) {
|
public Set<Long> getBoundSkillIds(Long agentId) {
|
||||||
|
if (isSkillsDisabled(agentId)) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
|
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
|
||||||
if (bindings.isEmpty()) {
|
if (bindings.isEmpty()) {
|
||||||
return null; // 无绑定 → 全局默认
|
return null; // no rows → inherit global default
|
||||||
}
|
}
|
||||||
return bindings.stream()
|
return bindings.stream()
|
||||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||||
@ -135,7 +154,11 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
|
|
||||||
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
|
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
|
||||||
requireSameWorkspace(agentId, skillId);
|
requireSameWorkspace(agentId, skillId);
|
||||||
// 检查是否已绑定
|
// Adding any skill binding is a concrete commitment — the operator
|
||||||
|
// wants this skill on the agent, which contradicts an opt-out flag.
|
||||||
|
// Clear the flag here so the data layer never holds a
|
||||||
|
// "skills_disabled=true + binding rows" contradiction.
|
||||||
|
clearSkillsDisabledFlag(agentId);
|
||||||
AgentSkillBinding existing = skillBindingMapper.selectOne(
|
AgentSkillBinding existing = skillBindingMapper.selectOne(
|
||||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||||
.eq(AgentSkillBinding::getAgentId, agentId)
|
.eq(AgentSkillBinding::getAgentId, agentId)
|
||||||
@ -161,7 +184,14 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量设置 Agent 的 skill 绑定(替换模式)
|
* Replace the agent's skill binding set.
|
||||||
|
*
|
||||||
|
* <p>Side effect: when {@code skillIds} contains at least one entry,
|
||||||
|
* the {@code skills_disabled} flag on the agent is auto-cleared. A
|
||||||
|
* non-empty save is a concrete commitment to those skills, so the
|
||||||
|
* data layer never holds a {@code disabled=true} + binding rows
|
||||||
|
* contradiction. An empty / null save does <strong>not</strong>
|
||||||
|
* touch the flag — the caller (UI toggle) owns that bit.
|
||||||
*/
|
*/
|
||||||
public void setSkillBindings(Long agentId, List<Long> skillIds) {
|
public void setSkillBindings(Long agentId, List<Long> skillIds) {
|
||||||
// Validate every incoming skill BEFORE touching the binding rows;
|
// Validate every incoming skill BEFORE touching the binding rows;
|
||||||
@ -173,11 +203,17 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
requireSameWorkspace(agentId, skillId);
|
requireSameWorkspace(agentId, skillId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 删除旧绑定
|
// Auto-clear the flag only when an explicit non-empty binding is
|
||||||
|
// being committed. An empty save is ambiguous — the UI may be
|
||||||
|
// either "uncheck everything" (keep flag as-is so the toggle
|
||||||
|
// remains the source of truth) or just "no rows" (legacy). We let
|
||||||
|
// the writer of skills_disabled (typically the agent PUT) own that.
|
||||||
|
if (skillIds != null && !skillIds.isEmpty()) {
|
||||||
|
clearSkillsDisabledFlag(agentId);
|
||||||
|
}
|
||||||
skillBindingMapper.delete(
|
skillBindingMapper.delete(
|
||||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||||
.eq(AgentSkillBinding::getAgentId, agentId));
|
.eq(AgentSkillBinding::getAgentId, agentId));
|
||||||
// 创建新绑定
|
|
||||||
if (skillIds != null) {
|
if (skillIds != null) {
|
||||||
for (Long skillId : skillIds) {
|
for (Long skillId : skillIds) {
|
||||||
AgentSkillBinding binding = new AgentSkillBinding();
|
AgentSkillBinding binding = new AgentSkillBinding();
|
||||||
@ -374,13 +410,26 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Agent 绑定的 enabled tool name 集合。
|
* Effective bound tool names for the agent. Mirrors the three-state
|
||||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
* contract of {@link #getBoundSkillIds}:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code null} — no binding rows and {@code tools_disabled=false}.
|
||||||
|
* Caller defers to the global default tool set.</li>
|
||||||
|
* <li>{@code Set.of()} — {@code tools_disabled=true}, or all rows are
|
||||||
|
* {@code enabled=false}. The agent is explicitly scoped to no
|
||||||
|
* user-pickable tools (system-level memory primitives still flow
|
||||||
|
* through {@link #getEffectiveToolNames}).</li>
|
||||||
|
* <li>non-empty set — the explicit allowlist.</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public Set<String> getBoundToolNames(Long agentId) {
|
public Set<String> getBoundToolNames(Long agentId) {
|
||||||
|
if (isToolsDisabled(agentId)) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
List<AgentToolBinding> bindings = listToolBindings(agentId);
|
List<AgentToolBinding> bindings = listToolBindings(agentId);
|
||||||
if (bindings.isEmpty()) {
|
if (bindings.isEmpty()) {
|
||||||
return null; // 无绑定 → 全局默认
|
return null; // no rows → inherit global default
|
||||||
}
|
}
|
||||||
return bindings.stream()
|
return bindings.stream()
|
||||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||||
@ -433,26 +482,43 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public Set<String> getEffectiveToolNames(Long agentId) {
|
public Set<String> getEffectiveToolNames(Long agentId) {
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
boolean skillsDisabled = agent != null && Boolean.TRUE.equals(agent.getSkillsDisabled());
|
||||||
|
boolean toolsDisabled = agent != null && Boolean.TRUE.equals(agent.getToolsDisabled());
|
||||||
|
|
||||||
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
|
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
|
||||||
Set<String> directTools = getBoundToolNames(agentId);
|
Set<String> directTools = getBoundToolNames(agentId);
|
||||||
|
|
||||||
// (1) null + null → no restriction; defer to the global default.
|
// Four-state matrix — see issue #184.
|
||||||
|
//
|
||||||
|
// (1) Pure legacy: no flags, no rows on either side → defer to global
|
||||||
|
// default (returns null). Agents created before V126 must remain
|
||||||
|
// bit-identical to their previous runtime contract.
|
||||||
if (boundSkillIds == null && directTools == null) {
|
if (boundSkillIds == null && directTools == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (2) Skills-only opt-out with no explicit tool restriction → still
|
||||||
|
// defer tools to the global default. Without this carve-out, a
|
||||||
|
// user who only said "no skills" would silently lose every
|
||||||
|
// non-MCP global tool because the merge branch only emits the
|
||||||
|
// SYSTEM_LEVEL set. The SKILL.md catalog itself is still
|
||||||
|
// suppressed via getBoundSkillIds returning Set.of().
|
||||||
|
if (skillsDisabled && !toolsDisabled && directTools == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> merged = new LinkedHashSet<>();
|
Set<String> merged = new LinkedHashSet<>();
|
||||||
|
|
||||||
if (boundSkillIds != null) {
|
if (boundSkillIds != null && !boundSkillIds.isEmpty()) {
|
||||||
for (Long skillId : boundSkillIds) {
|
for (Long skillId : boundSkillIds) {
|
||||||
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
||||||
if (resolved == null) continue;
|
if (resolved == null) continue;
|
||||||
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
|
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
|
||||||
// §14.2 fix: a disabled / security-blocked / setup-needed
|
// A disabled / security-blocked / setup-needed skill must
|
||||||
// skill must not contribute tools to the LLM
|
// not contribute tools to the LLM advertisement even if
|
||||||
// advertisement even if it's still bound. Without this
|
// it's still bound — otherwise the user sees ghost tools
|
||||||
// guard, users see ghost tools for skills they thought
|
// for skills they thought were off.
|
||||||
// were off.
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Set<String> skillTools = resolved.getEffectiveAllowedTools();
|
Set<String> skillTools = resolved.getEffectiveAllowedTools();
|
||||||
@ -461,32 +527,36 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (directTools != null) {
|
if (directTools != null) {
|
||||||
// ∪ Advanced 直选的原子 tool(§9.2 调整 B)
|
|
||||||
merged.addAll(directTools);
|
merged.addAll(directTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
// System-level tools that don't belong to any single skill but
|
// System-level tools that don't belong to any single skill but
|
||||||
// are agent-wide capabilities. Without this carve-out, binding
|
// are agent-wide capabilities — structured memory primitives,
|
||||||
// any skill silently strips record_lesson / remember / structured-
|
// workspace memory CRUD, etc. Without this carve-out, binding any
|
||||||
// memory tools, breaking the §11 self-evolution loop entirely
|
// skill silently strips record_lesson / remember / *memory_file
|
||||||
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
|
// tools, breaking the self-evolution loop. These survive even
|
||||||
|
// toolsDisabled=true because they are agent-internal infrastructure,
|
||||||
|
// unrelated to the user-facing capability picker.
|
||||||
merged.addAll(SYSTEM_LEVEL_TOOLS);
|
merged.addAll(SYSTEM_LEVEL_TOOLS);
|
||||||
|
|
||||||
// MCP tools. An agent that bound only a skill or a built-in tool
|
// MCP tools. An agent that bound only a skill or a built-in tool
|
||||||
// and ticked no MCP row keeps full access to every enabled MCP
|
// and ticked no MCP row normally keeps full access to every enabled
|
||||||
// tool: MCP servers are an administrator-enabled capability and
|
// MCP tool (administrator-level capabilities should not silently
|
||||||
// must not silently vanish just because some unrelated binding
|
// vanish just because some unrelated binding exists). Two cases
|
||||||
// exists. But once the operator ticks specific MCP rows, that is a
|
// suppress the auto-include:
|
||||||
// deliberate per-agent scope — only those MCP tools (already merged
|
// - tools_disabled=true → the user explicitly opted out of every
|
||||||
// via directTools above) stay, and the rest are not auto-joined, so
|
// non-system tool. Auto-joining MCP would defeat that intent.
|
||||||
// a role can be limited to a fixed MCP tool set. To instead hide a
|
// - The agent ticked at least one MCP tool itself → that signals a
|
||||||
// single MCP tool from an agent that ticked no MCP row, use the
|
// deliberate per-agent MCP scope; only the ticked subset stays.
|
||||||
// tool-guard deny path applied upstream in AgentGraphBuilder.
|
// To deny a single MCP tool when none are ticked and tools are
|
||||||
Set<String> enabledMcpTools = getEnabledMcpToolNames();
|
// enabled, use the tool-guard deny path in AgentGraphBuilder.
|
||||||
boolean agentScopedMcpExplicitly =
|
if (!toolsDisabled) {
|
||||||
directTools != null && !Collections.disjoint(directTools, enabledMcpTools);
|
Set<String> enabledMcpTools = getEnabledMcpToolNames();
|
||||||
if (!agentScopedMcpExplicitly) {
|
boolean agentScopedMcpExplicitly = directTools != null && !directTools.isEmpty()
|
||||||
merged.addAll(enabledMcpTools);
|
&& !Collections.disjoint(directTools, enabledMcpTools);
|
||||||
|
if (!agentScopedMcpExplicitly) {
|
||||||
|
merged.addAll(enabledMcpTools);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
@ -669,6 +739,9 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public AgentToolBinding bindTool(Long agentId, String toolName) {
|
public AgentToolBinding bindTool(Long agentId, String toolName) {
|
||||||
|
// Mirror of bindSkill: writing any tool row clears the opt-out flag
|
||||||
|
// so the binding state cannot contradict the agent-level toggle.
|
||||||
|
clearToolsDisabledFlag(agentId);
|
||||||
AgentToolBinding existing = toolBindingMapper.selectOne(
|
AgentToolBinding existing = toolBindingMapper.selectOne(
|
||||||
new LambdaQueryWrapper<AgentToolBinding>()
|
new LambdaQueryWrapper<AgentToolBinding>()
|
||||||
.eq(AgentToolBinding::getAgentId, agentId)
|
.eq(AgentToolBinding::getAgentId, agentId)
|
||||||
@ -715,6 +788,12 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
public void setToolBindings(Long agentId, List<String> toolNames) {
|
public void setToolBindings(Long agentId, List<String> toolNames) {
|
||||||
validateNewToolBindings(agentId, toolNames);
|
validateNewToolBindings(agentId, toolNames);
|
||||||
|
|
||||||
|
// Side effect parallel to setSkillBindings: a non-empty save is an
|
||||||
|
// explicit commitment to those tools, so the opt-out flag is
|
||||||
|
// auto-cleared. Empty saves leave the flag untouched.
|
||||||
|
if (toolNames != null && !toolNames.isEmpty()) {
|
||||||
|
clearToolsDisabledFlag(agentId);
|
||||||
|
}
|
||||||
toolBindingMapper.delete(
|
toolBindingMapper.delete(
|
||||||
new LambdaQueryWrapper<AgentToolBinding>()
|
new LambdaQueryWrapper<AgentToolBinding>()
|
||||||
.eq(AgentToolBinding::getAgentId, agentId));
|
.eq(AgentToolBinding::getAgentId, agentId));
|
||||||
@ -827,4 +906,57 @@ public class AgentBindingService implements AgentBindingResolver {
|
|||||||
providerPreferenceMapper.insert(row);
|
providerPreferenceMapper.insert(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Binding-mode flags (V126) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-side check for the agent's "skills opted out entirely" toggle.
|
||||||
|
* Returns {@code false} when the agent row is missing — a missing agent
|
||||||
|
* has no opinion, so binding queries fall through to the legacy
|
||||||
|
* row-count path (which will surface the missing-agent issue at a more
|
||||||
|
* useful layer than a binding read).
|
||||||
|
*/
|
||||||
|
private boolean isSkillsDisabled(Long agentId) {
|
||||||
|
if (agentId == null) return false;
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
return agent != null && Boolean.TRUE.equals(agent.getSkillsDisabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of {@link #isSkillsDisabled} for the tools opt-out toggle. */
|
||||||
|
private boolean isToolsDisabled(Long agentId) {
|
||||||
|
if (agentId == null) return false;
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
return agent != null && Boolean.TRUE.equals(agent.getToolsDisabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip {@code skills_disabled} back to false on the agent row. No-op
|
||||||
|
* when already false or the agent doesn't exist. Used as an auto-clear
|
||||||
|
* step in {@link #bindSkill} / {@link #setSkillBindings} so writing a
|
||||||
|
* concrete binding always wins over a stale opt-out flag.
|
||||||
|
*/
|
||||||
|
private void clearSkillsDisabledFlag(Long agentId) {
|
||||||
|
if (agentId == null) return;
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
if (agent == null || !Boolean.TRUE.equals(agent.getSkillsDisabled())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AgentEntity update = new AgentEntity();
|
||||||
|
update.setId(agentId);
|
||||||
|
update.setSkillsDisabled(false);
|
||||||
|
agentMapper.updateById(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of {@link #clearSkillsDisabledFlag} for the tools toggle. */
|
||||||
|
private void clearToolsDisabledFlag(Long agentId) {
|
||||||
|
if (agentId == null) return;
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
if (agent == null || !Boolean.TRUE.equals(agent.getToolsDisabled())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AgentEntity update = new AgentEntity();
|
||||||
|
update.setId(agentId);
|
||||||
|
update.setToolsDisabled(false);
|
||||||
|
agentMapper.updateById(update);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,42 @@ public class AgentEntity {
|
|||||||
@TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS)
|
@TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private String workspaceBasePath;
|
private String workspaceBasePath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit opt-out from every skill. When {@code true}, the binding service
|
||||||
|
* returns {@link java.util.Collections#emptySet()} from
|
||||||
|
* {@code getBoundSkillIds}, which (a) suppresses every {@code SKILL.md}
|
||||||
|
* catalog entry from the system prompt and (b) drops skill-expanded tools
|
||||||
|
* out of the effective tool set.
|
||||||
|
*
|
||||||
|
* <p>Default {@code false} preserves the legacy "zero rows = inherit global
|
||||||
|
* default" behaviour for every legacy agent. The flag is auto-cleared when
|
||||||
|
* a non-empty skill binding is written, so the data layer never holds a
|
||||||
|
* "{@code disabled=true} + binding rows" contradiction.
|
||||||
|
*
|
||||||
|
* <p>Default {@code NOT_NULL} update strategy is deliberate: a frontend
|
||||||
|
* PUT that explicitly carries {@code true} or {@code false} writes through
|
||||||
|
* (both are non-null Boolean), while a sparse partial update (e.g. the
|
||||||
|
* auto-clear helper that constructs a one-field entity) won't emit the
|
||||||
|
* other flag's column as a stray {@code SET ... = NULL} that would
|
||||||
|
* collide with the {@code NOT NULL} DDL.
|
||||||
|
*/
|
||||||
|
@TableField(value = "skills_disabled")
|
||||||
|
private Boolean skillsDisabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit opt-out from every non-system-level tool. When {@code true},
|
||||||
|
* {@code getBoundToolNames} returns {@link java.util.Collections#emptySet()}
|
||||||
|
* and the MCP auto-include in {@code getEffectiveToolNames} is suppressed;
|
||||||
|
* the structured-memory primitives (record_lesson / remember / workspace
|
||||||
|
* memory CRUD) still pass through because they are agent-internal
|
||||||
|
* capabilities unrelated to the user-facing capability picker.
|
||||||
|
*
|
||||||
|
* <p>Same defaulting / auto-clear / update strategy contract as
|
||||||
|
* {@link #skillsDisabled}.
|
||||||
|
*/
|
||||||
|
@TableField(value = "tools_disabled")
|
||||||
|
private Boolean toolsDisabled;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
-- V126: Two binding-mode flags on mate_agent.
|
||||||
|
--
|
||||||
|
-- skills_disabled / tools_disabled flip the "zero binding rows" semantic from
|
||||||
|
-- "inherit every globally-enabled capability" to "this agent has explicitly
|
||||||
|
-- opted out". Without these columns, an operator who wanted an agent with no
|
||||||
|
-- skills had to bind a dummy skill — otherwise the runtime fell back to the
|
||||||
|
-- global default and every skill's catalog entry got injected into the system
|
||||||
|
-- prompt (issue #184).
|
||||||
|
--
|
||||||
|
-- Both default to FALSE so legacy agents are bit-identical to pre-V126 behavior.
|
||||||
|
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS skills_disabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS tools_disabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
-- V126: Two binding-mode flags on mate_agent (MySQL).
|
||||||
|
--
|
||||||
|
-- skills_disabled / tools_disabled flip the "zero binding rows" semantic from
|
||||||
|
-- "inherit every globally-enabled capability" to "this agent has explicitly
|
||||||
|
-- opted out". Without these columns, an operator who wanted an agent with no
|
||||||
|
-- skills had to bind a dummy skill — otherwise the runtime fell back to the
|
||||||
|
-- global default and every skill's catalog entry got injected into the system
|
||||||
|
-- prompt (issue #184).
|
||||||
|
--
|
||||||
|
-- Idempotent: INFORMATION_SCHEMA guard for each column, since MySQL does not
|
||||||
|
-- support `ADD COLUMN IF NOT EXISTS`.
|
||||||
|
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_agent'
|
||||||
|
AND COLUMN_NAME = 'skills_disabled'
|
||||||
|
);
|
||||||
|
SET @stmt := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_agent ADD COLUMN skills_disabled TINYINT(1) NOT NULL DEFAULT 0',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_agent'
|
||||||
|
AND COLUMN_NAME = 'tools_disabled'
|
||||||
|
);
|
||||||
|
SET @stmt := IF(@col_exists = 0,
|
||||||
|
'ALTER TABLE mate_agent ADD COLUMN tools_disabled TINYINT(1) NOT NULL DEFAULT 0',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
@ -431,4 +431,218 @@ class AgentBindingServiceTest {
|
|||||||
assertNotNull(count);
|
assertNotNull(count);
|
||||||
assertEquals(0, count, "unbind 应该物理删除,而不是软删(软删会留 deleted=1 行,占用唯一索引槽位导致 rebind 失败)");
|
assertEquals(0, count, "unbind 应该物理删除,而不是软删(软删会留 deleted=1 行,占用唯一索引槽位导致 rebind 失败)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== V126 binding-mode flags (issue #184) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip the {@code skills_disabled} column on the seeded agent row.
|
||||||
|
* Tests need a direct lever because {@link AgentBindingService} only
|
||||||
|
* exposes the auto-clear side; setting the flag is the controller's job.
|
||||||
|
*/
|
||||||
|
private void setSkillsDisabledFlag(boolean value) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE mate_agent SET skills_disabled = ? WHERE id = ?",
|
||||||
|
value, agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror of {@link #setSkillsDisabledFlag} for the tools toggle. */
|
||||||
|
private void setToolsDisabledFlag(boolean value) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE mate_agent SET tools_disabled = ? WHERE id = ?",
|
||||||
|
value, agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boolean column readback so the auto-clear assertions don't lie. */
|
||||||
|
private boolean readSkillsDisabledFlag() {
|
||||||
|
Boolean v = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT skills_disabled FROM mate_agent WHERE id = ?",
|
||||||
|
Boolean.class, agentId);
|
||||||
|
return Boolean.TRUE.equals(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean readToolsDisabledFlag() {
|
||||||
|
Boolean v = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT tools_disabled FROM mate_agent WHERE id = ?",
|
||||||
|
Boolean.class, agentId);
|
||||||
|
return Boolean.TRUE.equals(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184: skills_disabled=true → getBoundSkillIds 返回 emptySet(不是 null)")
|
||||||
|
void getBoundSkillIdsReturnsEmptyWhenSkillsDisabled() {
|
||||||
|
// No binding rows at all + flag on. Pre-V126 contract returned null
|
||||||
|
// (= inherit global default); the new flag flips the read to an
|
||||||
|
// explicit "no skills" so SKILL.md catalog injection stays off.
|
||||||
|
setSkillsDisabledFlag(true);
|
||||||
|
|
||||||
|
Set<Long> result = bindingService.getBoundSkillIds(agentId);
|
||||||
|
assertNotNull(result, "skills_disabled=true 时绝不能返回 null —— 否则下游会把它当作 'inherit global default'");
|
||||||
|
assertTrue(result.isEmpty(), "应当是显式的空 set");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184: tools_disabled=true → getBoundToolNames 返回 emptySet(不是 null)")
|
||||||
|
void getBoundToolNamesReturnsEmptyWhenToolsDisabled() {
|
||||||
|
setToolsDisabledFlag(true);
|
||||||
|
|
||||||
|
Set<String> result = bindingService.getBoundToolNames(agentId);
|
||||||
|
assertNotNull(result, "tools_disabled=true 时绝不能返回 null");
|
||||||
|
assertTrue(result.isEmpty(), "应当是显式的空 set");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184 matrix (T,F,*,0): skillsDisabled 但无 tool 绑定 → effective 返回 null(工具继承全局默认)")
|
||||||
|
void skillsDisabledNoToolBindingsStillInheritsDefaultTools() {
|
||||||
|
// This is the critical case the design review caught: silently
|
||||||
|
// returning {SYSTEM + MCP} here would strip every non-MCP global
|
||||||
|
// built-in tool just because the user said "no skills". The fix
|
||||||
|
// returns null so AgentToolSet.withAllowedToolsOnly(null) → no
|
||||||
|
// restriction → global default tools flow through.
|
||||||
|
setSkillsDisabledFlag(true);
|
||||||
|
|
||||||
|
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
|
||||||
|
assertNull(effective,
|
||||||
|
"skillsDisabled=true 但用户没主动限制工具时,effective 必须返回 null —— "
|
||||||
|
+ "否则非 MCP 的全局工具会被悄悄收窄掉");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184 matrix (T,F,*,>0): skillsDisabled + 显式 tool 绑定 → 仅这些 tool + SYSTEM + MCP-rule")
|
||||||
|
void skillsDisabledWithToolBindingsScopesToTools() {
|
||||||
|
seedBuiltinTool("scoped_tool_probe");
|
||||||
|
setSkillsDisabledFlag(true);
|
||||||
|
bindingService.setToolBindings(agentId, List.of("scoped_tool_probe"));
|
||||||
|
|
||||||
|
// Auto-clear is opt-in: we want to verify the matrix when both states
|
||||||
|
// coexist transiently (i.e. a client wrote tool bindings without
|
||||||
|
// touching the flag through the UI). bindSkill/setSkillBindings only
|
||||||
|
// clears its own flag; setToolBindings clears tools_disabled, not
|
||||||
|
// skills_disabled — so skills_disabled survives here.
|
||||||
|
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
|
||||||
|
assertNotNull(effective, "存在显式 tool 绑定时不能返回 null");
|
||||||
|
assertTrue(effective.contains("scoped_tool_probe"), "用户勾选的工具必须在 allowlist");
|
||||||
|
assertTrue(effective.contains("record_lesson"), "system-level 内核工具必须保留");
|
||||||
|
assertFalse(effective.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184 matrix (F,T,0,*): toolsDisabled 无 skill 绑定 → 仅 system-level(不并入 MCP,不继承默认)")
|
||||||
|
void toolsDisabledReturnsSystemOnlyAndSkipsMcp() {
|
||||||
|
seedMcpServerWithOneTool(8_888_201L, "issue184-mcp", "leaked_probe");
|
||||||
|
setToolsDisabledFlag(true);
|
||||||
|
|
||||||
|
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
|
||||||
|
assertNotNull(effective, "toolsDisabled=true 时绝不能返回 null(那会让全局默认工具又流回来)");
|
||||||
|
assertTrue(effective.contains("record_lesson"), "system-level memory 工具必须保留");
|
||||||
|
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
|
||||||
|
assertFalse(hasMcp,
|
||||||
|
"toolsDisabled=true 时 enabled MCP 工具绝不能自动并入 —— 否则用户的 '禁用所有工具' 意图被违背。"
|
||||||
|
+ "实际 allowlist: " + effective);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184 matrix (F,T,>0,*): toolsDisabled + skill 绑定 → skill 扩展 + SYSTEM(不并入 MCP)")
|
||||||
|
void toolsDisabledKeepsSkillExpansionButSkipsMcp() {
|
||||||
|
long skillId = 7_777_801L;
|
||||||
|
seedSkill(skillId);
|
||||||
|
bindingService.bindSkill(agentId, skillId);
|
||||||
|
seedMcpServerWithOneTool(8_888_202L, "issue184-mcp-b", "mcp_should_be_hidden");
|
||||||
|
setToolsDisabledFlag(true);
|
||||||
|
|
||||||
|
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
|
||||||
|
assertNotNull(effective);
|
||||||
|
// Skill expansion is contingent on the resolved manifest declaring
|
||||||
|
// allowed_tools, which test fixtures don't seed; the contract we
|
||||||
|
// verify here is the MCP suppression + SYSTEM survival. (Skill
|
||||||
|
// expansion correctness is exercised in other tests / by the runtime.)
|
||||||
|
assertTrue(effective.contains("record_lesson"), "system-level 必须保留");
|
||||||
|
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
|
||||||
|
assertFalse(hasMcp, "toolsDisabled=true 即使有 skill 绑定也不能自动并入 MCP");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184 matrix (T,T,*,*): 两 flag 都开 → 仅 system-level,无 MCP,无默认")
|
||||||
|
void bothDisabledReturnsSystemOnly() {
|
||||||
|
seedMcpServerWithOneTool(8_888_203L, "issue184-mcp-c", "should_be_hidden");
|
||||||
|
setSkillsDisabledFlag(true);
|
||||||
|
setToolsDisabledFlag(true);
|
||||||
|
|
||||||
|
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
|
||||||
|
assertNotNull(effective);
|
||||||
|
assertTrue(effective.contains("record_lesson"), "system-level 必须保留");
|
||||||
|
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
|
||||||
|
assertFalse(hasMcp, "两 flag 全开时 MCP 必须完全隐藏");
|
||||||
|
// Sanity: the set should be roughly the SYSTEM_LEVEL_TOOLS list —
|
||||||
|
// we don't enforce equality (the constant evolves) but it should be
|
||||||
|
// substantially smaller than the catalog of every enabled tool.
|
||||||
|
assertTrue(effective.size() < 100,
|
||||||
|
"两 flag 全开时返回的应该只是 system-level 内核工具,体积明显小于完整默认集。"
|
||||||
|
+ "实际大小: " + effective.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184: setSkillBindings 非空保存自动清掉 skills_disabled(数据层不留矛盾态)")
|
||||||
|
void setSkillBindingsNonEmptyAutoClearsSkillsDisabledFlag() {
|
||||||
|
long skillId = 7_777_802L;
|
||||||
|
seedSkill(skillId);
|
||||||
|
setSkillsDisabledFlag(true);
|
||||||
|
assertTrue(readSkillsDisabledFlag(), "前置:flag 应为 true");
|
||||||
|
|
||||||
|
bindingService.setSkillBindings(agentId, List.of(skillId));
|
||||||
|
|
||||||
|
assertFalse(readSkillsDisabledFlag(),
|
||||||
|
"写入非空 skill 绑定应当自动清掉 skills_disabled —— "
|
||||||
|
+ "否则 DB 会出现 'disabled=true + 有绑定行' 的矛盾态");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184: setSkillBindings 空保存不动 flag(toggle 自己拥有该位)")
|
||||||
|
void setSkillBindingsEmptySaveDoesNotTouchFlag() {
|
||||||
|
// Empty save is ambiguous: it might be "uncheck everything" from the
|
||||||
|
// UI that owns skills_disabled separately, or just "no rows". Letting
|
||||||
|
// the writer of the flag own it (agent PUT) keeps the toggle the
|
||||||
|
// single source of truth.
|
||||||
|
setSkillsDisabledFlag(true);
|
||||||
|
bindingService.setSkillBindings(agentId, List.of());
|
||||||
|
|
||||||
|
assertTrue(readSkillsDisabledFlag(),
|
||||||
|
"空保存不应清掉 flag —— 否则 UI 的'禁用所有技能' toggle 在用户'取消所有勾选'后会被悄悄翻掉");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184: bindSkill 单次绑定自动清掉 skills_disabled")
|
||||||
|
void bindSkillSingleAutoClearsSkillsDisabledFlag() {
|
||||||
|
long skillId = 7_777_803L;
|
||||||
|
seedSkill(skillId);
|
||||||
|
setSkillsDisabledFlag(true);
|
||||||
|
|
||||||
|
bindingService.bindSkill(agentId, skillId);
|
||||||
|
|
||||||
|
assertFalse(readSkillsDisabledFlag(),
|
||||||
|
"单条 bindSkill 也算明确的承诺,应当自动清掉 flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184: setToolBindings 非空保存自动清掉 tools_disabled")
|
||||||
|
void setToolBindingsNonEmptyAutoClearsToolsDisabledFlag() {
|
||||||
|
seedBuiltinTool("autoclear_probe");
|
||||||
|
setToolsDisabledFlag(true);
|
||||||
|
assertTrue(readToolsDisabledFlag(), "前置:flag 应为 true");
|
||||||
|
|
||||||
|
bindingService.setToolBindings(agentId, List.of("autoclear_probe"));
|
||||||
|
|
||||||
|
assertFalse(readToolsDisabledFlag(),
|
||||||
|
"写入非空 tool 绑定应当自动清掉 tools_disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("issue #184: bindTool 单次绑定自动清掉 tools_disabled")
|
||||||
|
void bindToolSingleAutoClearsToolsDisabledFlag() {
|
||||||
|
setToolsDisabledFlag(true);
|
||||||
|
|
||||||
|
bindingService.bindTool(agentId, "autoclear_single_probe");
|
||||||
|
|
||||||
|
assertFalse(readToolsDisabledFlag(),
|
||||||
|
"单条 bindTool 也算明确的承诺,应当自动清掉 flag");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1219,9 +1219,15 @@ export default {
|
|||||||
skillsKicker: 'Trained workflows',
|
skillsKicker: 'Trained workflows',
|
||||||
skillsTagline: 'A skill is a workflow — a step-by-step playbook the LLM follows for a coherent multi-step task.',
|
skillsTagline: 'A skill is a workflow — a step-by-step playbook the LLM follows for a coherent multi-step task.',
|
||||||
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
|
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
|
||||||
|
disableAllSkills: 'This agent uses no skills',
|
||||||
|
disableAllSkillsHint: 'Saving with this on clears the agent\'s skill bindings and marks it as "explicitly no skills": the LLM loads no SKILL.md catalog entries and skill-expanded tools do not enter the context. System-level primitives (memory, delegation, etc.) are unaffected. When off, picking nothing still falls back to "inherit global default".',
|
||||||
|
disableAllSkillsBadge: 'Off',
|
||||||
toolsKicker: 'Atomic tools the agent can call',
|
toolsKicker: 'Atomic tools the agent can call',
|
||||||
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.',
|
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.',
|
||||||
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
|
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
|
||||||
|
disableAllTools: 'This agent uses no user-pickable tools',
|
||||||
|
disableAllToolsHint: 'Saving with this on clears the agent\'s tool bindings and marks it as "explicitly no tools": neither user-pickable tools nor any enabled MCP tools enter the allowlist. System-level primitives (structured memory, workspace memory files, delegation, etc.) remain available so the agent can still operate. When off, picking nothing still falls back to "inherit global default".',
|
||||||
|
disableAllToolsBadge: 'Off',
|
||||||
searchSkills: 'Search skill name, description, or version',
|
searchSkills: 'Search skill name, description, or version',
|
||||||
searchTools: 'Search tool name, description, source, or group',
|
searchTools: 'Search tool name, description, source, or group',
|
||||||
advancedToolsTitle: 'Advanced: Hand-picked atomic tools',
|
advancedToolsTitle: 'Advanced: Hand-picked atomic tools',
|
||||||
|
|||||||
@ -1111,9 +1111,15 @@ export default {
|
|||||||
skillsKicker: '受过培训的工作流程',
|
skillsKicker: '受过培训的工作流程',
|
||||||
skillsTagline: '技能 = 一段流程,一份工作手册。LLM 按手册执行一系列连贯动作。',
|
skillsTagline: '技能 = 一段流程,一份工作手册。LLM 按手册执行一系列连贯动作。',
|
||||||
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
|
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
|
||||||
|
disableAllSkills: '此智能体不使用任何技能',
|
||||||
|
disableAllSkillsHint: '开启后保存会清空该智能体的技能绑定,并标记为「显式无技能」:LLM 不再加载任何 SKILL.md 目录,技能扩展的工具也不会进入上下文。系统级内核工具(记忆、委派等)不受影响。关闭后,未勾选任何技能仍按「继承全局默认」处理。',
|
||||||
|
disableAllSkillsBadge: '已禁用',
|
||||||
toolsKicker: '会用的原子工具',
|
toolsKicker: '会用的原子工具',
|
||||||
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
|
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
|
||||||
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
|
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
|
||||||
|
disableAllTools: '此智能体不使用任何用户可选工具',
|
||||||
|
disableAllToolsHint: '开启后保存会清空该智能体的工具绑定,并标记为「显式无工具」:用户可选工具与已启用的 MCP 工具都不会进入 allowlist。系统级内核工具(结构化记忆、工作区记忆文件、委派等)仍保留以保证基本运行。关闭后,未勾选任何工具仍按「继承全局默认」处理。',
|
||||||
|
disableAllToolsBadge: '已禁用',
|
||||||
searchSkills: '搜索技能名称、描述或版本',
|
searchSkills: '搜索技能名称、描述或版本',
|
||||||
searchTools: '搜索工具名称、描述、来源或分组',
|
searchTools: '搜索工具名称、描述、来源或分组',
|
||||||
advancedToolsTitle: '高级:手选原子工具',
|
advancedToolsTitle: '高级:手选原子工具',
|
||||||
|
|||||||
@ -44,6 +44,19 @@ export interface Agent {
|
|||||||
icon?: string
|
icon?: string
|
||||||
tags?: string
|
tags?: string
|
||||||
workspaceBasePath?: string
|
workspaceBasePath?: string
|
||||||
|
/**
|
||||||
|
* Explicit opt-out: drop every SKILL.md catalog entry from the system
|
||||||
|
* prompt and exclude skill-expanded tools. Independent of binding rows
|
||||||
|
* (when `true`, the agent is treated as "no skills" regardless of any
|
||||||
|
* leftover `mate_agent_skill` rows). Defaults to `false`.
|
||||||
|
*/
|
||||||
|
skillsDisabled?: boolean
|
||||||
|
/**
|
||||||
|
* Explicit opt-out: exclude every non-system-level tool from the agent's
|
||||||
|
* effective set and suppress MCP auto-include. System-level memory and
|
||||||
|
* delegation primitives still pass through. Defaults to `false`.
|
||||||
|
*/
|
||||||
|
toolsDisabled?: boolean
|
||||||
createTime?: string
|
createTime?: string
|
||||||
updateTime?: string
|
updateTime?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -209,11 +209,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'skills' }" @click="modalTab = 'skills'">
|
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'skills' }" @click="modalTab = 'skills'">
|
||||||
{{ t('agents.tabs.skills', 'Skills') }}
|
{{ t('agents.tabs.skills', 'Skills') }}
|
||||||
<span v-if="selectedSkillIds.length" class="tab-badge">{{ selectedSkillIds.length }}</span>
|
<!-- Issue #184: when the disable flag is on, suppress the
|
||||||
|
stale-pick count badge and show an "off" state instead —
|
||||||
|
the count would otherwise contradict the disable toggle
|
||||||
|
visible in the tab content. -->
|
||||||
|
<span v-if="form.skillsDisabled" class="tab-badge tab-badge--off">{{ t('agents.binding.disableAllSkillsBadge') }}</span>
|
||||||
|
<span v-else-if="selectedSkillIds.length" class="tab-badge">{{ selectedSkillIds.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'tools' }" @click="modalTab = 'tools'">
|
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'tools' }" @click="modalTab = 'tools'">
|
||||||
{{ t('agents.tabs.tools', 'Tools') }}
|
{{ t('agents.tabs.tools', 'Tools') }}
|
||||||
<span v-if="selectedToolNames.length" class="tab-badge">{{ selectedToolNames.length }}</span>
|
<span v-if="form.toolsDisabled" class="tab-badge tab-badge--off">{{ t('agents.binding.disableAllToolsBadge') }}</span>
|
||||||
|
<span v-else-if="selectedToolNames.length" class="tab-badge">{{ selectedToolNames.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'providers' }" @click="modalTab = 'providers'">
|
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'providers' }" @click="modalTab = 'providers'">
|
||||||
{{ t('agents.tabs.providers', 'Providers') }}
|
{{ t('agents.tabs.providers', 'Providers') }}
|
||||||
@ -333,24 +339,55 @@
|
|||||||
<span class="binding-intro__kicker">{{ t('agents.binding.skillsKicker') }}</span>
|
<span class="binding-intro__kicker">{{ t('agents.binding.skillsKicker') }}</span>
|
||||||
<p class="binding-intro__tagline">{{ t('agents.binding.skillsTagline') }}</p>
|
<p class="binding-intro__tagline">{{ t('agents.binding.skillsTagline') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Issue #184: explicit "no skills" toggle. Empty selection
|
||||||
|
alone falls back to "inherit global default" (legacy
|
||||||
|
contract), so users who want zero skills in the context
|
||||||
|
need this dedicated bit. -->
|
||||||
|
<div class="binding-disable-row">
|
||||||
|
<label class="binding-disable-label">
|
||||||
|
<input type="checkbox" v-model="form.skillsDisabled" class="binding-disable-checkbox" />
|
||||||
|
<span class="binding-disable-text">
|
||||||
|
<strong>{{ t('agents.binding.disableAllSkills') }}</strong>
|
||||||
|
<span class="binding-disable-hint">{{ t('agents.binding.disableAllSkillsHint') }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
|
<p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
|
||||||
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
|
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="binding-search">
|
<div class="binding-search" :class="{ 'binding-search--disabled': form.skillsDisabled }">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" />
|
<input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" :disabled="form.skillsDisabled" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredAvailableSkills.length === 0" class="binding-empty binding-empty--compact">{{ t('agents.binding.noMatchingSkills') }}</div>
|
<div v-if="filteredAvailableSkills.length === 0" class="binding-empty binding-empty--compact">{{ t('agents.binding.noMatchingSkills') }}</div>
|
||||||
<div v-else class="binding-list">
|
<div v-else class="binding-list" :class="{ 'binding-list--disabled': form.skillsDisabled }">
|
||||||
<label
|
<label
|
||||||
v-for="skill in filteredAvailableSkills"
|
v-for="skill in filteredAvailableSkills"
|
||||||
:key="skill.id"
|
:key="skill.id"
|
||||||
class="binding-item"
|
class="binding-item"
|
||||||
:class="{ selected: selectedSkillIds.includes(skill.id) }"
|
:class="{
|
||||||
|
selected: !form.skillsDisabled && selectedSkillIds.includes(skill.id),
|
||||||
|
'binding-item--inert': form.skillsDisabled,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<input type="checkbox" :value="skill.id" v-model="selectedSkillIds" class="binding-checkbox" />
|
<!-- Manual :checked instead of v-model: when skillsDisabled
|
||||||
|
is on, the stale picks still live in selectedSkillIds
|
||||||
|
(we keep them so flipping the toggle back off restores
|
||||||
|
the previous selection in one click). Driving the
|
||||||
|
checkbox from a derived expression lets us hide the
|
||||||
|
stale checked state from the user without mutating
|
||||||
|
the underlying array. The save path already clears
|
||||||
|
the array on send when the flag is on. -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="skill.id"
|
||||||
|
:checked="!form.skillsDisabled && selectedSkillIds.includes(skill.id)"
|
||||||
|
@change="onSkillToggle(skill.id, $event)"
|
||||||
|
class="binding-checkbox"
|
||||||
|
:disabled="form.skillsDisabled"
|
||||||
|
/>
|
||||||
<span class="binding-icon"><SkillIcon :value="skill.icon" :size="20" :fallback="'🧩'" /></span>
|
<span class="binding-icon"><SkillIcon :value="skill.icon" :size="20" :fallback="'🧩'" /></span>
|
||||||
<div class="binding-info">
|
<div class="binding-info">
|
||||||
<span class="binding-name">{{ resolveSkillName(skill) }}</span>
|
<span class="binding-name">{{ resolveSkillName(skill) }}</span>
|
||||||
@ -371,13 +408,30 @@
|
|||||||
<span class="binding-intro__kicker">{{ t('agents.binding.toolsKicker') }}</span>
|
<span class="binding-intro__kicker">{{ t('agents.binding.toolsKicker') }}</span>
|
||||||
<p class="binding-intro__tagline">{{ t('agents.binding.toolsTagline') }}</p>
|
<p class="binding-intro__tagline">{{ t('agents.binding.toolsTagline') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<details class="advanced-tools" :open="selectedToolNames.length > 0 || advancedToolsOpen">
|
<!-- Issue #184 mirror of the skills tab: explicit opt-out so the
|
||||||
|
LLM advertises zero user-pickable tools (system-level
|
||||||
|
memory primitives still pass — see backend SYSTEM_LEVEL_TOOLS). -->
|
||||||
|
<div class="binding-disable-row">
|
||||||
|
<label class="binding-disable-label">
|
||||||
|
<input type="checkbox" v-model="form.toolsDisabled" class="binding-disable-checkbox" />
|
||||||
|
<span class="binding-disable-text">
|
||||||
|
<strong>{{ t('agents.binding.disableAllTools') }}</strong>
|
||||||
|
<span class="binding-disable-hint">{{ t('agents.binding.disableAllToolsHint') }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- Issue #184: when the disable flag is on, treat the count as
|
||||||
|
zero for visual affordances — auto-open / count badge / chevron
|
||||||
|
should all behave as if there are no picks, matching the
|
||||||
|
"saving clears bindings" contract. The underlying array is
|
||||||
|
left intact so toggling the flag back off restores them. -->
|
||||||
|
<details class="advanced-tools" :open="(!form.toolsDisabled && selectedToolNames.length > 0) || advancedToolsOpen">
|
||||||
<summary class="advanced-tools-summary" @click.prevent="advancedToolsOpen = !advancedToolsOpen">
|
<summary class="advanced-tools-summary" @click.prevent="advancedToolsOpen = !advancedToolsOpen">
|
||||||
<span class="advanced-tools-title">
|
<span class="advanced-tools-title">
|
||||||
{{ t('agents.binding.advancedToolsTitle') }}
|
{{ t('agents.binding.advancedToolsTitle') }}
|
||||||
<span v-if="selectedToolNames.length > 0" class="advanced-tools-count">{{ selectedToolNames.length }}</span>
|
<span v-if="!form.toolsDisabled && selectedToolNames.length > 0" class="advanced-tools-count">{{ selectedToolNames.length }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="advanced-tools-chevron">{{ (advancedToolsOpen || selectedToolNames.length > 0) ? '▾' : '▸' }}</span>
|
<span class="advanced-tools-chevron">{{ (advancedToolsOpen || (!form.toolsDisabled && selectedToolNames.length > 0)) ? '▾' : '▸' }}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
|
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
|
||||||
<p class="binding-hint advanced-tools-note">{{ t('agents.binding.advancedToolsHint') }}</p>
|
<p class="binding-hint advanced-tools-note">{{ t('agents.binding.advancedToolsHint') }}</p>
|
||||||
@ -423,9 +477,10 @@
|
|||||||
:key="tool.rowId || `${group.groupId}#${tool.rawName}#${tool.name}`"
|
:key="tool.rowId || `${group.groupId}#${tool.rawName}#${tool.name}`"
|
||||||
class="binding-item"
|
class="binding-item"
|
||||||
:class="{
|
:class="{
|
||||||
selected: tool._isSelected,
|
selected: !form.toolsDisabled && tool._isSelected,
|
||||||
'binding-item--stale': tool.stale,
|
'binding-item--stale': tool.stale,
|
||||||
'binding-item--unavailable': !tool.available,
|
'binding-item--unavailable': !tool.available,
|
||||||
|
'binding-item--inert': form.toolsDisabled,
|
||||||
}"
|
}"
|
||||||
:title="!tool.available
|
:title="!tool.available
|
||||||
? t('agents.binding.toolUnavailableTooltip', { reason: tool.unavailableReason || '' })
|
? t('agents.binding.toolUnavailableTooltip', { reason: tool.unavailableReason || '' })
|
||||||
@ -437,12 +492,17 @@
|
|||||||
both, so we drive each row's checked flag from
|
both, so we drive each row's checked flag from
|
||||||
the pre-computed _isSelected derived in
|
the pre-computed _isSelected derived in
|
||||||
availableToolGroups, which considers whether
|
availableToolGroups, which considers whether
|
||||||
this row's name is owned by a bindable twin. -->
|
this row's name is owned by a bindable twin.
|
||||||
|
Issue #184: gate visual checked-state on the
|
||||||
|
disable flag so stale picks don't show through
|
||||||
|
when "this agent uses no user-pickable tools"
|
||||||
|
is on. selectedToolNames is preserved so flipping
|
||||||
|
the toggle back off restores the prior selection. -->
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="binding-checkbox"
|
class="binding-checkbox"
|
||||||
:checked="tool._isSelected"
|
:checked="!form.toolsDisabled && tool._isSelected"
|
||||||
:disabled="tool._isDisabled"
|
:disabled="tool._isDisabled || form.toolsDisabled"
|
||||||
@change="onToolToggle(tool.name, $event)"
|
@change="onToolToggle(tool.name, $event)"
|
||||||
/>
|
/>
|
||||||
<span class="binding-icon">
|
<span class="binding-icon">
|
||||||
@ -672,6 +732,24 @@ function onToolToggle(toolName: string, event: Event) {
|
|||||||
selectedToolNames.value = selectedToolNames.value.filter((n) => n !== toolName)
|
selectedToolNames.value = selectedToolNames.value.filter((n) => n !== toolName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill checkbox handler. Issue #184 — the row is driven by an explicit
|
||||||
|
* {@code :checked} expression instead of {@code v-model} so the visual
|
||||||
|
* checked state can be suppressed when {@code skillsDisabled} is on
|
||||||
|
* without dropping the picks from {@code selectedSkillIds}. Toggling
|
||||||
|
* the disable flag back off restores the prior selection in one click.
|
||||||
|
*/
|
||||||
|
function onSkillToggle(skillId: number | string, event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (target.checked) {
|
||||||
|
if (!selectedSkillIds.value.includes(skillId as number)) {
|
||||||
|
selectedSkillIds.value.push(skillId as number)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedSkillIds.value = selectedSkillIds.value.filter((id) => id !== skillId)
|
||||||
|
}
|
||||||
|
}
|
||||||
const selectedSkillIds = ref<number[]>([])
|
const selectedSkillIds = ref<number[]>([])
|
||||||
const selectedToolNames = ref<string[]>([])
|
const selectedToolNames = ref<string[]>([])
|
||||||
// RFC-009 PR-3: per-agent provider preference order
|
// RFC-009 PR-3: per-agent provider preference order
|
||||||
@ -709,6 +787,10 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
defaultThinkingLevel: null,
|
defaultThinkingLevel: null,
|
||||||
workspaceBasePath: null,
|
workspaceBasePath: null,
|
||||||
|
// Issue #184 — explicit opt-out flags. Default false matches the legacy
|
||||||
|
// "zero rows = inherit global default" contract for newly-created agents.
|
||||||
|
skillsDisabled: false,
|
||||||
|
toolsDisabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = ref(defaultForm())
|
const form = ref(defaultForm())
|
||||||
@ -897,6 +979,8 @@ async function openEditModal(agent: Agent) {
|
|||||||
enabled: agent.enabled,
|
enabled: agent.enabled,
|
||||||
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
|
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
|
||||||
workspaceBasePath: agent.workspaceBasePath || null,
|
workspaceBasePath: agent.workspaceBasePath || null,
|
||||||
|
skillsDisabled: agent.skillsDisabled === true,
|
||||||
|
toolsDisabled: agent.toolsDisabled === true,
|
||||||
}
|
}
|
||||||
profileForm.value = parsePrompt(agent.systemPrompt)
|
profileForm.value = parsePrompt(agent.systemPrompt)
|
||||||
modalTab.value = 'basic'
|
modalTab.value = 'basic'
|
||||||
@ -964,13 +1048,44 @@ async function saveAgent() {
|
|||||||
agentId = res.data?.id
|
agentId = res.data?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save bindings (only for existing agents or after create returns id)
|
// Sequential binding saves (issue #184). Two coupled concerns:
|
||||||
|
//
|
||||||
|
// 1. Disabled-flag intent must win over stale picks. The opt-out
|
||||||
|
// toggles only disable the picker visually — selectedSkillIds /
|
||||||
|
// selectedToolNames keep whatever was previously bound. If we sent
|
||||||
|
// those stale picks to setSkills/setTools while the flag is on, the
|
||||||
|
// backend's auto-clear self-heals the flag back to false (because
|
||||||
|
// "non-empty save = concrete commitment"), and the user's "disable
|
||||||
|
// everything" intent vanishes silently. So we clear the array here
|
||||||
|
// before sending — saving [] preserves the flag, and the runtime
|
||||||
|
// contract ("flag wins over rows") is honored.
|
||||||
|
//
|
||||||
|
// 2. Sequential order, not Promise.all. Parallel binding calls would
|
||||||
|
// leave half-applied state on a partial failure; serial means we
|
||||||
|
// know exactly which side persisted and can pull the authoritative
|
||||||
|
// server state back if anything throws.
|
||||||
|
const skillIdsToSave = form.value.skillsDisabled ? [] : selectedSkillIds.value
|
||||||
|
const toolNamesToSave = form.value.toolsDisabled ? [] : selectedToolNames.value
|
||||||
|
|
||||||
if (agentId && editingAgent.value) {
|
if (agentId && editingAgent.value) {
|
||||||
await Promise.all([
|
try {
|
||||||
agentBindingApi.setSkills(agentId, selectedSkillIds.value),
|
await agentBindingApi.setSkills(agentId, skillIdsToSave)
|
||||||
agentBindingApi.setTools(agentId, selectedToolNames.value),
|
await agentBindingApi.setTools(agentId, toolNamesToSave)
|
||||||
agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value),
|
await agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value)
|
||||||
])
|
} catch (bindingError: any) {
|
||||||
|
mcToast.error(bindingError?.message || t('agents.messages.saveFailed'))
|
||||||
|
// Pull the authoritative server state back into the editing form so
|
||||||
|
// the user sees what actually persisted instead of stale picks.
|
||||||
|
try {
|
||||||
|
const fresh: any = await agentApi.get(agentId)
|
||||||
|
if (fresh?.data) {
|
||||||
|
await openEditModal(fresh.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Reload failure on top of binding failure: best effort, stop here.
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mcToast.success(t('agents.messages.saveSuccess'))
|
mcToast.success(t('agents.messages.saveSuccess'))
|
||||||
@ -1295,6 +1410,14 @@ html.dark .seg-count.warn {
|
|||||||
border-radius: 9px; background: var(--mc-primary); color: white;
|
border-radius: 9px; background: var(--mc-primary); color: white;
|
||||||
font-size: 11px; font-weight: 600;
|
font-size: 11px; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
/* Issue #184: "off" variant for the disable-all state. Neutral grey instead
|
||||||
|
of brand orange because the badge represents a constraint, not a count. */
|
||||||
|
.tab-badge--off {
|
||||||
|
min-width: auto; padding: 0 8px;
|
||||||
|
background: var(--mc-bg-sunken, rgba(0,0,0,0.08));
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
/* Binding Tab */
|
/* Binding Tab */
|
||||||
.binding-tab { min-height: 200px; }
|
.binding-tab { min-height: 200px; }
|
||||||
@ -1327,6 +1450,47 @@ html.dark .seg-count.warn {
|
|||||||
}
|
}
|
||||||
.binding-empty { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
|
.binding-empty { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
|
||||||
.binding-empty--compact { padding: 24px 12px; }
|
.binding-empty--compact { padding: 24px 12px; }
|
||||||
|
/* Issue #184 — opt-out row that sits above the picker list. */
|
||||||
|
.binding-disable-row {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px dashed var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mc-bg-sunken);
|
||||||
|
}
|
||||||
|
.binding-disable-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.binding-disable-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--mc-primary);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.binding-disable-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.binding-disable-text strong { font-weight: 600; }
|
||||||
|
.binding-disable-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
/* When the opt-out is on, the picker list is still visible (so the user
|
||||||
|
can see what they're disabling) but rendered inert — no hover affordance,
|
||||||
|
greyed-out interactions. */
|
||||||
|
.binding-search--disabled,
|
||||||
|
.binding-list--disabled { opacity: 0.45; pointer-events: none; }
|
||||||
|
.binding-item--inert { cursor: not-allowed; }
|
||||||
.binding-search {
|
.binding-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user