mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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);
|
||||
bindingService.setSkillBindings(agentId, skillIds);
|
||||
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),
|
||||
"skills=" + skillIds.size(), null);
|
||||
"skills=" + count, null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@ -98,8 +102,10 @@ public class AgentBindingController {
|
||||
verifyAgentWorkspace(agentId, workspaceId);
|
||||
bindingService.setToolBindings(agentId, toolNames);
|
||||
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),
|
||||
"tools=" + toolNames.size(), null);
|
||||
"tools=" + count, null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
||||
@ -118,14 +118,33 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 绑定的 enabled skill ID 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
* Effective bound skill IDs for the agent. Three return states:
|
||||
*
|
||||
* <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
|
||||
public Set<Long> getBoundSkillIds(Long agentId) {
|
||||
if (isSkillsDisabled(agentId)) {
|
||||
return Set.of();
|
||||
}
|
||||
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
|
||||
if (bindings.isEmpty()) {
|
||||
return null; // 无绑定 → 全局默认
|
||||
return null; // no rows → inherit global default
|
||||
}
|
||||
return bindings.stream()
|
||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||
@ -135,7 +154,11 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
|
||||
public AgentSkillBinding bindSkill(Long agentId, Long 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(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.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) {
|
||||
// Validate every incoming skill BEFORE touching the binding rows;
|
||||
@ -173,11 +203,17 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
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(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.eq(AgentSkillBinding::getAgentId, agentId));
|
||||
// 创建新绑定
|
||||
if (skillIds != null) {
|
||||
for (Long skillId : skillIds) {
|
||||
AgentSkillBinding binding = new AgentSkillBinding();
|
||||
@ -374,13 +410,26 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 绑定的 enabled tool name 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
* Effective bound tool names for the agent. Mirrors the three-state
|
||||
* 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) {
|
||||
if (isToolsDisabled(agentId)) {
|
||||
return Set.of();
|
||||
}
|
||||
List<AgentToolBinding> bindings = listToolBindings(agentId);
|
||||
if (bindings.isEmpty()) {
|
||||
return null; // 无绑定 → 全局默认
|
||||
return null; // no rows → inherit global default
|
||||
}
|
||||
return bindings.stream()
|
||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||
@ -433,26 +482,43 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
* </ul>
|
||||
*/
|
||||
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<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) {
|
||||
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<>();
|
||||
|
||||
if (boundSkillIds != null) {
|
||||
if (boundSkillIds != null && !boundSkillIds.isEmpty()) {
|
||||
for (Long skillId : boundSkillIds) {
|
||||
ResolvedSkill resolved = findResolvedSkillById(skillId);
|
||||
if (resolved == null) continue;
|
||||
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
|
||||
// §14.2 fix: a disabled / security-blocked / setup-needed
|
||||
// skill must not contribute tools to the LLM
|
||||
// advertisement even if it's still bound. Without this
|
||||
// guard, users see ghost tools for skills they thought
|
||||
// were off.
|
||||
// A disabled / security-blocked / setup-needed skill must
|
||||
// not contribute tools to the LLM advertisement even if
|
||||
// it's still bound — otherwise the user sees ghost tools
|
||||
// for skills they thought were off.
|
||||
continue;
|
||||
}
|
||||
Set<String> skillTools = resolved.getEffectiveAllowedTools();
|
||||
@ -461,32 +527,36 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
if (directTools != null) {
|
||||
// ∪ Advanced 直选的原子 tool(§9.2 调整 B)
|
||||
merged.addAll(directTools);
|
||||
}
|
||||
|
||||
// System-level tools that don't belong to any single skill but
|
||||
// are agent-wide capabilities. Without this carve-out, binding
|
||||
// any skill silently strips record_lesson / remember / structured-
|
||||
// memory tools, breaking the §11 self-evolution loop entirely
|
||||
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
|
||||
// are agent-wide capabilities — structured memory primitives,
|
||||
// workspace memory CRUD, etc. Without this carve-out, binding any
|
||||
// skill silently strips record_lesson / remember / *memory_file
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
// tool: MCP servers are an administrator-enabled capability and
|
||||
// must not silently vanish just because some unrelated binding
|
||||
// exists. But once the operator ticks specific MCP rows, that is a
|
||||
// deliberate per-agent scope — only those MCP tools (already merged
|
||||
// via directTools above) stay, and the rest are not auto-joined, so
|
||||
// a role can be limited to a fixed MCP tool set. To instead hide a
|
||||
// single MCP tool from an agent that ticked no MCP row, use the
|
||||
// tool-guard deny path applied upstream in AgentGraphBuilder.
|
||||
Set<String> enabledMcpTools = getEnabledMcpToolNames();
|
||||
boolean agentScopedMcpExplicitly =
|
||||
directTools != null && !Collections.disjoint(directTools, enabledMcpTools);
|
||||
if (!agentScopedMcpExplicitly) {
|
||||
merged.addAll(enabledMcpTools);
|
||||
// and ticked no MCP row normally keeps full access to every enabled
|
||||
// MCP tool (administrator-level capabilities should not silently
|
||||
// vanish just because some unrelated binding exists). Two cases
|
||||
// suppress the auto-include:
|
||||
// - tools_disabled=true → the user explicitly opted out of every
|
||||
// non-system tool. Auto-joining MCP would defeat that intent.
|
||||
// - The agent ticked at least one MCP tool itself → that signals a
|
||||
// deliberate per-agent MCP scope; only the ticked subset stays.
|
||||
// To deny a single MCP tool when none are ticked and tools are
|
||||
// enabled, use the tool-guard deny path in AgentGraphBuilder.
|
||||
if (!toolsDisabled) {
|
||||
Set<String> enabledMcpTools = getEnabledMcpToolNames();
|
||||
boolean agentScopedMcpExplicitly = directTools != null && !directTools.isEmpty()
|
||||
&& !Collections.disjoint(directTools, enabledMcpTools);
|
||||
if (!agentScopedMcpExplicitly) {
|
||||
merged.addAll(enabledMcpTools);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
@ -669,6 +739,9 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
}
|
||||
|
||||
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(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId)
|
||||
@ -715,6 +788,12 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
public void setToolBindings(Long agentId, List<String> 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(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId));
|
||||
@ -827,4 +906,57 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
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)
|
||||
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)
|
||||
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);
|
||||
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',
|
||||
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.',
|
||||
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',
|
||||
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.',
|
||||
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',
|
||||
searchTools: 'Search tool name, description, source, or group',
|
||||
advancedToolsTitle: 'Advanced: Hand-picked atomic tools',
|
||||
|
||||
@ -1111,9 +1111,15 @@ export default {
|
||||
skillsKicker: '受过培训的工作流程',
|
||||
skillsTagline: '技能 = 一段流程,一份工作手册。LLM 按手册执行一系列连贯动作。',
|
||||
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
|
||||
disableAllSkills: '此智能体不使用任何技能',
|
||||
disableAllSkillsHint: '开启后保存会清空该智能体的技能绑定,并标记为「显式无技能」:LLM 不再加载任何 SKILL.md 目录,技能扩展的工具也不会进入上下文。系统级内核工具(记忆、委派等)不受影响。关闭后,未勾选任何技能仍按「继承全局默认」处理。',
|
||||
disableAllSkillsBadge: '已禁用',
|
||||
toolsKicker: '会用的原子工具',
|
||||
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
|
||||
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
|
||||
disableAllTools: '此智能体不使用任何用户可选工具',
|
||||
disableAllToolsHint: '开启后保存会清空该智能体的工具绑定,并标记为「显式无工具」:用户可选工具与已启用的 MCP 工具都不会进入 allowlist。系统级内核工具(结构化记忆、工作区记忆文件、委派等)仍保留以保证基本运行。关闭后,未勾选任何工具仍按「继承全局默认」处理。',
|
||||
disableAllToolsBadge: '已禁用',
|
||||
searchSkills: '搜索技能名称、描述或版本',
|
||||
searchTools: '搜索工具名称、描述、来源或分组',
|
||||
advancedToolsTitle: '高级:手选原子工具',
|
||||
|
||||
@ -44,6 +44,19 @@ export interface Agent {
|
||||
icon?: string
|
||||
tags?: 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
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
@ -209,11 +209,17 @@
|
||||
</button>
|
||||
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'skills' }" @click="modalTab = '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 v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'tools' }" @click="modalTab = '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 v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'providers' }" @click="modalTab = 'providers'">
|
||||
{{ t('agents.tabs.providers', 'Providers') }}
|
||||
@ -333,24 +339,55 @@
|
||||
<span class="binding-intro__kicker">{{ t('agents.binding.skillsKicker') }}</span>
|
||||
<p class="binding-intro__tagline">{{ t('agents.binding.skillsTagline') }}</p>
|
||||
</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>
|
||||
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
|
||||
<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">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" />
|
||||
<input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" :disabled="form.skillsDisabled" />
|
||||
</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
|
||||
v-for="skill in filteredAvailableSkills"
|
||||
:key="skill.id"
|
||||
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>
|
||||
<div class="binding-info">
|
||||
<span class="binding-name">{{ resolveSkillName(skill) }}</span>
|
||||
@ -371,13 +408,30 @@
|
||||
<span class="binding-intro__kicker">{{ t('agents.binding.toolsKicker') }}</span>
|
||||
<p class="binding-intro__tagline">{{ t('agents.binding.toolsTagline') }}</p>
|
||||
</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">
|
||||
<span class="advanced-tools-title">
|
||||
{{ 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 class="advanced-tools-chevron">{{ (advancedToolsOpen || selectedToolNames.length > 0) ? '▾' : '▸' }}</span>
|
||||
<span class="advanced-tools-chevron">{{ (advancedToolsOpen || (!form.toolsDisabled && selectedToolNames.length > 0)) ? '▾' : '▸' }}</span>
|
||||
</summary>
|
||||
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</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}`"
|
||||
class="binding-item"
|
||||
:class="{
|
||||
selected: tool._isSelected,
|
||||
selected: !form.toolsDisabled && tool._isSelected,
|
||||
'binding-item--stale': tool.stale,
|
||||
'binding-item--unavailable': !tool.available,
|
||||
'binding-item--inert': form.toolsDisabled,
|
||||
}"
|
||||
:title="!tool.available
|
||||
? t('agents.binding.toolUnavailableTooltip', { reason: tool.unavailableReason || '' })
|
||||
@ -437,12 +492,17 @@
|
||||
both, so we drive each row's checked flag from
|
||||
the pre-computed _isSelected derived in
|
||||
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
|
||||
type="checkbox"
|
||||
class="binding-checkbox"
|
||||
:checked="tool._isSelected"
|
||||
:disabled="tool._isDisabled"
|
||||
:checked="!form.toolsDisabled && tool._isSelected"
|
||||
:disabled="tool._isDisabled || form.toolsDisabled"
|
||||
@change="onToolToggle(tool.name, $event)"
|
||||
/>
|
||||
<span class="binding-icon">
|
||||
@ -672,6 +732,24 @@ function onToolToggle(toolName: string, event: Event) {
|
||||
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 selectedToolNames = ref<string[]>([])
|
||||
// RFC-009 PR-3: per-agent provider preference order
|
||||
@ -709,6 +787,10 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
|
||||
enabled: true,
|
||||
defaultThinkingLevel: 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())
|
||||
@ -897,6 +979,8 @@ async function openEditModal(agent: Agent) {
|
||||
enabled: agent.enabled,
|
||||
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
|
||||
workspaceBasePath: agent.workspaceBasePath || null,
|
||||
skillsDisabled: agent.skillsDisabled === true,
|
||||
toolsDisabled: agent.toolsDisabled === true,
|
||||
}
|
||||
profileForm.value = parsePrompt(agent.systemPrompt)
|
||||
modalTab.value = 'basic'
|
||||
@ -964,13 +1048,44 @@ async function saveAgent() {
|
||||
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) {
|
||||
await Promise.all([
|
||||
agentBindingApi.setSkills(agentId, selectedSkillIds.value),
|
||||
agentBindingApi.setTools(agentId, selectedToolNames.value),
|
||||
agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value),
|
||||
])
|
||||
try {
|
||||
await agentBindingApi.setSkills(agentId, skillIdsToSave)
|
||||
await agentBindingApi.setTools(agentId, toolNamesToSave)
|
||||
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'))
|
||||
@ -1295,6 +1410,14 @@ html.dark .seg-count.warn {
|
||||
border-radius: 9px; background: var(--mc-primary); color: white;
|
||||
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 { 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--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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user