diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
index 618eab3f..45182bf8 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
@@ -83,6 +83,7 @@ public class AgentService {
if (agent.getAgentType() == null) {
agent.setAgentType("react");
}
+ requireUniqueName(agent, null);
agentMapper.insert(agent);
publishLifecycle(agent, "spawned");
return agent;
@@ -93,6 +94,19 @@ public class AgentService {
// intent rather than every metadata edit. Reading the prior row
// is cheap and gives us a clean diff source.
AgentEntity prior = agentMapper.selectById(agent.getId());
+ // Only re-validate uniqueness when the name actually changes —
+ // a pure metadata edit (icon, prompt, ...) shouldn't pay the
+ // SELECT cost or risk a false positive against the row itself.
+ if (prior != null
+ && agent.getName() != null
+ && !agent.getName().equals(prior.getName())) {
+ // Workspace cannot be moved (Controller pins it to prior.workspaceId),
+ // so reuse it for the lookup even if the incoming DTO left it null.
+ if (agent.getWorkspaceId() == null) {
+ agent.setWorkspaceId(prior.getWorkspaceId());
+ }
+ requireUniqueName(agent, agent.getId());
+ }
agentMapper.updateById(agent);
agentInstances.remove(agent.getId());
if (prior != null && prior.getEnabled() != null
@@ -103,6 +117,40 @@ public class AgentService {
return agent;
}
+ /**
+ * Friendly business-code surface for the {@code (workspace_id, name)}
+ * unique index added in V102.
+ *
+ *
The wire shape is the project-wide R<T> envelope: HTTP status
+ * stays 200 (per the convention in {@code R.fail} and the axios
+ * interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in
+ * the response body's {@code code} field so the front-end can branch
+ * without breaking on an axios error. Without this pre-check the
+ * duplicate save would surface as an opaque
+ * {@code DataIntegrityViolation} stack trace.
+ *
+ * @param excludeId when non-null, skip this row in the lookup so
+ * {@link #updateAgent} doesn't mistake the row for its
+ * own duplicate.
+ */
+ private void requireUniqueName(AgentEntity agent, Long excludeId) {
+ if (agent.getName() == null || agent.getName().isBlank()) {
+ throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空");
+ }
+ Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
+ LambdaQueryWrapper q = new LambdaQueryWrapper()
+ .eq(AgentEntity::getWorkspaceId, workspaceId)
+ .eq(AgentEntity::getName, agent.getName());
+ if (excludeId != null) {
+ q.ne(AgentEntity::getId, excludeId);
+ }
+ Long count = agentMapper.selectCount(q);
+ if (count != null && count > 0) {
+ throw new MateClawException("err.agent.duplicate_name", 409,
+ "工作区内已存在同名 Agent: " + agent.getName());
+ }
+ }
+
public void deleteAgent(Long id) {
AgentEntity prior = agentMapper.selectById(id);
agentMapper.deleteById(id);
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
index 438834c9..97e1c08b 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
@@ -12,7 +12,13 @@ import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
+import vip.mate.agent.model.AgentEntity;
+import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
+import vip.mate.skill.acp.AcpSkillBridge;
+import vip.mate.skill.mcp.McpSkillBridge;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.model.AvailableToolDTO;
@@ -53,18 +59,44 @@ public class AgentBindingService {
* could still be saved by hitting the API directly.
*/
private final AvailableToolService availableToolService;
+ /**
+ * Direct mapper access (instead of {@code AgentService}) to look up an
+ * agent's workspace before binding a skill. {@code AgentService} pulls
+ * in {@code AgentGraphBuilder}, which itself depends on
+ * {@code AgentBindingService} — going through the service would create a
+ * boot-time cycle. The mapper has no such transitive dependency.
+ */
+ private final AgentMapper agentMapper;
+ /** Same reasoning as {@link #agentMapper}: skill workspace lookup. */
+ private final SkillMapper skillMapper;
+ /**
+ * ACP virtual skills aren't rows in {@code mate_skill}; the bridge
+ * synthesizes them from {@code mate_acp_endpoint}. We need this to
+ * answer "what workspace does this virtual id belong to?" when an
+ * agent tries to bind one. MCP virtual skills don't need a bridge
+ * reference — {@link McpSkillBridge#isVirtualMcpSkillId(Long)} is a
+ * static range check, and MCP servers carry no workspace today, so
+ * binding any MCP virtual id is allowed for any agent.
+ */
+ private final AcpSkillBridge acpSkillBridge;
@Autowired
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
AgentToolBindingMapper toolBindingMapper,
AgentProviderPreferenceMapper providerPreferenceMapper,
@Lazy SkillRuntimeService skillRuntimeService,
- AvailableToolService availableToolService) {
+ AvailableToolService availableToolService,
+ AgentMapper agentMapper,
+ SkillMapper skillMapper,
+ AcpSkillBridge acpSkillBridge) {
this.skillBindingMapper = skillBindingMapper;
this.toolBindingMapper = toolBindingMapper;
this.providerPreferenceMapper = providerPreferenceMapper;
this.skillRuntimeService = skillRuntimeService;
this.availableToolService = availableToolService;
+ this.agentMapper = agentMapper;
+ this.skillMapper = skillMapper;
+ this.acpSkillBridge = acpSkillBridge;
}
// ==================== Skill Bindings ====================
@@ -92,6 +124,7 @@ public class AgentBindingService {
}
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
+ requireSameWorkspace(agentId, skillId);
// 检查是否已绑定
AgentSkillBinding existing = skillBindingMapper.selectOne(
new LambdaQueryWrapper()
@@ -121,6 +154,15 @@ public class AgentBindingService {
* 批量设置 Agent 的 skill 绑定(替换模式)
*/
public void setSkillBindings(Long agentId, List skillIds) {
+ // Validate every incoming skill BEFORE touching the binding rows;
+ // a half-applied save (old bindings dropped, new set rejected
+ // mid-loop) would leave the agent silently un-bound from skills it
+ // had a moment ago.
+ if (skillIds != null) {
+ for (Long skillId : skillIds) {
+ requireSameWorkspace(agentId, skillId);
+ }
+ }
// 删除旧绑定
skillBindingMapper.delete(
new LambdaQueryWrapper()
@@ -137,6 +179,78 @@ public class AgentBindingService {
}
}
+ /**
+ * Refuse to bind a skill that doesn't share the agent's workspace.
+ * Skills are per-workspace installable artifacts (each workspace has
+ * its own catalog under {@code mate_skill.workspace_id}); letting
+ * workspace A's agent bind workspace B's skill would leak capabilities
+ * — and prompt content — across the tenancy boundary.
+ *
+ * Three skill id flavors to handle:
+ *
+ * - Real {@code mate_skill} rows — straight mapper lookup,
+ * compare {@code workspace_id} to the agent's.
+ * - Virtual MCP-derived ids ({@code >= McpSkillBridge.VIRTUAL_ID_BASE})
+ * — pass through. MCP servers carry no workspace concept today,
+ * so any agent in any workspace may bind any MCP virtual skill.
+ * The picker (/skills/enabled) hands these out to every workspace.
+ * - Virtual ACP-derived ids ({@code AcpSkillBridge}'s range)
+ * — resolve through the bridge so the {@link SkillEntity#getWorkspaceId()}
+ * comes from the backing {@code mate_acp_endpoint.workspace_id},
+ * then apply the same workspace comparison.
+ *
+ *
+ * Most {@code mate_skill} rows currently sit in the default workspace
+ * (id=1) because skill creation doesn't yet honor the
+ * {@code X-Workspace-Id} header; the real-skill branch is therefore
+ * defense-in-depth right now and flips on automatically the moment
+ * workspace-scoped skill creation lands. ACP enforcement is live today.
+ *
+ * @throws MateClawException 404 if the agent or skill doesn't exist;
+ * 403 on a workspace mismatch.
+ */
+ private void requireSameWorkspace(Long agentId, Long skillId) {
+ if (agentId == null) {
+ throw new MateClawException("err.agent.not_found", 404, "Agent ID is required");
+ }
+ if (skillId == null) {
+ throw new MateClawException("err.skill.not_found", 404, "Skill ID is required");
+ }
+ AgentEntity agent = agentMapper.selectById(agentId);
+ if (agent == null) {
+ throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId);
+ }
+ // MCP virtual: no workspace on McpServerEntity — globally bindable.
+ if (McpSkillBridge.isVirtualMcpSkillId(skillId)) {
+ return;
+ }
+ SkillEntity skill;
+ if (AcpSkillBridge.isVirtualAcpSkillId(skillId)) {
+ // ACP virtual: synthesize from the bridge so workspace_id flows
+ // through from mate_acp_endpoint. A null reply here means the
+ // backing endpoint was deleted or disabled between picker render
+ // and save — same surface as a deleted real skill.
+ skill = acpSkillBridge.findEntityById(skillId);
+ if (skill == null) {
+ throw new MateClawException("err.skill.not_found", 404,
+ "ACP endpoint backing skill " + skillId + " is gone or disabled");
+ }
+ } else {
+ skill = skillMapper.selectById(skillId);
+ if (skill == null) {
+ throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId);
+ }
+ }
+ long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
+ long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId();
+ if (agentWs != skillWs) {
+ throw new MateClawException("err.skill.cross_workspace_binding", 403,
+ "Skill " + skillId + " (workspace=" + skillWs
+ + ") cannot be bound to Agent " + agentId
+ + " (workspace=" + agentWs + ")");
+ }
+ }
+
// ==================== Tool Bindings ====================
public List listToolBindings(Long agentId) {
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntityRefEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntityRefEntity.java
deleted file mode 100644
index 2fe75991..00000000
--- a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntityRefEntity.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package vip.mate.memory.fact.model;
-
-import com.baomidou.mybatisplus.annotation.*;
-import lombok.Data;
-
-import java.time.LocalDateTime;
-
-/**
- * Entity reference for multi-hop graph queries on facts.
- *
- * @author MateClaw Team
- */
-@Data
-@TableName("mate_fact_entity_ref")
-public class FactEntityRefEntity {
-
- @TableId(type = IdType.AUTO)
- private Long id;
-
- private Long factId;
-
- private String entityName;
-
- /** person, tool, project, concept */
- private String entityType;
-
- /** subject | object */
- private String role;
-
- @TableField(fill = FieldFill.INSERT)
- private LocalDateTime createTime;
-}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java
index f8a98bc5..d5ec9f0f 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java
@@ -6,7 +6,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.memory.fact.model.FactContradictionEntity;
import vip.mate.memory.fact.model.FactEntity;
-import vip.mate.memory.fact.model.FactEntityRefEntity;
import vip.mate.memory.fact.repository.FactMapper;
import java.time.LocalDateTime;
@@ -24,7 +23,6 @@ import java.util.List;
public class FactQueryService {
private final FactMapper factMapper;
- private final vip.mate.memory.fact.repository.FactEntityRefMapper refMapper;
private final vip.mate.memory.fact.repository.FactContradictionMapper contradictionMapper;
/**
@@ -41,27 +39,6 @@ public class FactQueryService {
.last("LIMIT 20"));
}
- /**
- * Find related facts via entity references (multi-hop).
- */
- public List related(Long agentId, String entity, int hops) {
- // Find fact IDs that reference this entity
- List refs = refMapper.selectList(
- new LambdaQueryWrapper()
- .like(FactEntityRefEntity::getEntityName, entity)
- .last("LIMIT 50"));
- List factIds = refs.stream().map(FactEntityRefEntity::getFactId).distinct().toList();
- if (factIds.isEmpty()) return List.of();
-
- return factMapper.selectList(
- new LambdaQueryWrapper()
- .eq(FactEntity::getAgentId, agentId)
- .eq(FactEntity::getDeleted, 0)
- .in(FactEntity::getId, factIds)
- .orderByDesc(FactEntity::getTrust)
- .last("LIMIT 20"));
- }
-
/**
* List unresolved contradictions for an agent.
*/
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/repository/FactEntityRefMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/repository/FactEntityRefMapper.java
deleted file mode 100644
index 0e8829a9..00000000
--- a/mateclaw-server/src/main/java/vip/mate/memory/fact/repository/FactEntityRefMapper.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package vip.mate.memory.fact.repository;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import org.apache.ibatis.annotations.Mapper;
-import vip.mate.memory.fact.model.FactEntityRefEntity;
-
-@Mapper
-public interface FactEntityRefMapper extends BaseMapper {
-}
diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java
index 30aea726..c2732ee7 100644
--- a/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java
@@ -43,24 +43,6 @@ public class FactQueryTool {
.collect(Collectors.joining("\n"));
}
- @Tool(description = "Find facts related to an entity via entity references (multi-hop graph query).")
- public String fact_related(
- @ToolParam(description = "Agent ID") Long agentId,
- @ToolParam(description = "Entity name") String entity,
- @ToolParam(description = "Number of hops (1-3)") int hops) {
- if (!properties.getFact().isProjectionEnabled()) {
- return "Fact projection is disabled.";
- }
- List facts = queryService.related(agentId, entity, Math.min(hops, 3));
- if (facts.isEmpty()) return "No related facts found for: " + entity;
-
- queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList());
-
- return facts.stream()
- .map(f -> String.format("- %s %s %s (trust=%.2f)", f.getSubject(), f.getPredicate(), f.getObjectValue(), f.getTrust()))
- .collect(Collectors.joining("\n"));
- }
-
@Tool(description = "List unresolved fact contradictions detected during Dream consolidation.")
public String fact_list_contradictions(
@ToolParam(description = "Agent ID") Long agentId) {
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
index a200ec40..e5ae7a20 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java
@@ -301,6 +301,12 @@ public class AcpSkillBridge {
s.setEnabled(Boolean.TRUE.equals(ep.getEnabled()));
s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin()));
s.setTags("acp");
+ // Carry the backing endpoint's workspace through to the virtual
+ // SkillEntity so binding-time tenancy checks can compare it against
+ // the agent's workspace. Without this the bridge synthesizes rows
+ // with workspaceId = null and an agent in any workspace could bind
+ // any ACP endpoint regardless of where the endpoint was provisioned.
+ s.setWorkspaceId(ep.getWorkspaceId());
s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts
s.setConfigJson(buildConfigJson(ep));
s.setManifestJson(serializeManifest(buildManifest(ep)));
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
index 0199066a..44c29730 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java
@@ -97,7 +97,17 @@ public class SkillEntity {
/** 标签(逗号分隔) */
private String tags;
- /** RFC-023:来源对话 ID(Agent 自治合成时记录) */
+ /**
+ * Owning workspace. The DB column has existed since the baseline schema
+ * (default = 1) but the field was missing from the entity, so MyBatis
+ * Plus silently ignored both reads and writes. Surfacing it here lets
+ * binding-time tenancy checks see the value; default behavior on insert
+ * remains "fall through to the column DEFAULT" because the field stays
+ * {@code null} in the no-arg create path.
+ */
+ private Long workspaceId;
+
+ /** 来源对话 ID(Agent 自治合成时记录) */
private String sourceConversationId;
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
index 77dd8a07..d332cce5 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java
@@ -354,16 +354,20 @@ public class SkillRuntimeService {
Long agentId) {
List activeSkills;
if (boundSkillIds != null) {
- // Per-agent 过滤:从全局 enabled skills 中按 ID 过滤。RFC-090
- // §14.1 — must use the same features-aware gate as
- // refreshActiveSkills() so legacy dependencyReady drift
- // doesn't silently let setup-needed manifest skills through
- // (or hide partially-ready features that should be visible).
- List enabledSkills = skillService.listEnabledSkills();
- activeSkills = enabledSkills.stream()
- .filter(s -> boundSkillIds.contains(s.getId()))
- .map(packageResolver::resolve)
- .filter(SkillRuntimeService::passesActiveGate)
+ // Per-agent filter: pick the agent's bound subset from the
+ // already-merged active set (real + MCP/ACP virtual). Using
+ // getActiveSkills() — instead of a fresh
+ // skillService.listEnabledSkills() walk — is what makes bound
+ // virtual skills surface in the prompt catalog. The earlier
+ // implementation only looked at mate_skill rows, so a user
+ // who explicitly checked an MCP/ACP card in the agent picker
+ // got its tools (via AgentBindingService.getEffectiveToolNames)
+ // but lost the corresponding `## Skills` catalog row, which
+ // confused the LLM when it tried to dispatch by skill name.
+ // Cache-backed get + same passesActiveGate semantics, so this
+ // is strictly additive for real skills.
+ activeSkills = getActiveSkills().stream()
+ .filter(s -> s.getId() != null && boundSkillIds.contains(s.getId()))
.collect(java.util.stream.Collectors.toList());
} else {
activeSkills = getActiveSkills();
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V102__agent_unique_name_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/h2/V102__agent_unique_name_per_workspace.sql
new file mode 100644
index 00000000..7f9e3e0a
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V102__agent_unique_name_per_workspace.sql
@@ -0,0 +1,35 @@
+-- Enforce unique Agent name within a workspace.
+--
+-- Before V102 the application allowed two Agents with the same name in the
+-- same workspace, which made name-based routing (e.g. @-mention an Agent in
+-- an IM channel) ambiguous and let an attacker shadow an existing Agent.
+--
+-- Step 1 — rename pre-existing duplicates so the new index can be created
+-- without an offline migration. The oldest row per (workspace_id, name)
+-- keeps the original name; later rows are renamed to a fully synthetic
+-- migration tag.
+--
+-- The rename target intentionally drops the original name and substitutes
+-- `__mate_dup_v102____`. Any deterministic transformation of
+-- the original name has a non-zero collision risk against a hand-typed
+-- pre-existing row that happens to match the pattern (e.g. someone named
+-- their agent `foo__v102_dup__2`). A random UUID component drives the
+-- collision probability to ~1/2^122, low enough to call "provably unique"
+-- for a one-shot admin migration. The original name is recoverable via
+-- the audit log; the row id stays embedded in the new name for traceability.
+UPDATE mate_agent
+SET name = CONCAT('__mate_dup_v102__', id, '__', RANDOM_UUID())
+WHERE id IN (
+ SELECT a.id FROM mate_agent a
+ WHERE EXISTS (
+ SELECT 1 FROM mate_agent b
+ WHERE b.workspace_id = a.workspace_id
+ AND b.name = a.name
+ AND b.id < a.id
+ )
+);
+
+-- Step 2 — DB-level guarantee. Service layer also pre-checks for friendly
+-- 409 messages; this index is the racy-write safety net.
+CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_workspace_name
+ ON mate_agent(workspace_id, name);
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V103__drop_fact_entity_ref.sql b/mateclaw-server/src/main/resources/db/migration/h2/V103__drop_fact_entity_ref.sql
new file mode 100644
index 00000000..50cd447b
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V103__drop_fact_entity_ref.sql
@@ -0,0 +1,15 @@
+-- Drop the dead mate_fact_entity_ref table.
+--
+-- Introduced in V29 to back a multi-hop "find facts related to entity X"
+-- query, but no writer was ever shipped — FactProjectionBuilder only
+-- populated mate_fact, never mate_fact_entity_ref. The downstream
+-- FactQueryService.related() and the fact_related agent tool therefore
+-- always returned empty results, and the table was missing an agent_id
+-- column that would have been needed for tenancy isolation if a writer
+-- ever did land. Removing the empty table + the dead Java code (deleted
+-- in the same change set) keeps the fact projection honest about what
+-- it actually offers.
+--
+-- If multi-hop fact graph queries become desirable later, add agent_id
+-- from day one and ship the writer in the same change.
+DROP TABLE IF EXISTS mate_fact_entity_ref;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V102__agent_unique_name_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V102__agent_unique_name_per_workspace.sql
new file mode 100644
index 00000000..21867d60
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V102__agent_unique_name_per_workspace.sql
@@ -0,0 +1,37 @@
+-- Enforce unique Agent name within a workspace. See H2 variant for context.
+--
+-- Step 1 — rename pre-existing duplicates. MySQL forbids referencing the
+-- target table in a subquery for UPDATE, so we use a self-join with a
+-- derived "min id per group" table to pick which row keeps the original
+-- name (the oldest by id) and rename the rest.
+--
+-- The rename target drops the original name and substitutes
+-- `__mate_dup_v102____`. Any deterministic transformation of
+-- the original name has a non-zero collision risk against a hand-typed
+-- pre-existing row that happens to match the pattern (e.g. someone named
+-- their agent `foo__v102_dup__2`). A random UUID component drives the
+-- collision probability to ~1/2^122 — provably unique for a one-shot
+-- migration. Mirrors the H2 variant via MySQL's UUID() function.
+UPDATE mate_agent t
+JOIN (
+ SELECT workspace_id, name, MIN(id) AS keep_id
+ FROM mate_agent
+ GROUP BY workspace_id, name
+ HAVING COUNT(*) > 1
+) k
+ ON t.workspace_id = k.workspace_id
+ AND t.name = k.name
+ AND t.id <> k.keep_id
+SET t.name = CONCAT('__mate_dup_v102__', t.id, '__', UUID());
+
+-- Step 2 — add the unique index, idempotent via INFORMATION_SCHEMA guard
+-- (matches the V69 cron-job pattern; works on MySQL < 8.0.29 which has no
+-- CREATE INDEX IF NOT EXISTS).
+SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'mate_agent'
+ AND INDEX_NAME = 'uk_agent_workspace_name');
+SET @stmt := IF(@idx_exists = 0,
+ 'CREATE UNIQUE INDEX uk_agent_workspace_name ON mate_agent(workspace_id, name)',
+ 'SELECT 1');
+PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V103__drop_fact_entity_ref.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V103__drop_fact_entity_ref.sql
new file mode 100644
index 00000000..9f0ce948
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V103__drop_fact_entity_ref.sql
@@ -0,0 +1,2 @@
+-- Drop the dead mate_fact_entity_ref table. See H2 variant for context.
+DROP TABLE IF EXISTS mate_fact_entity_ref;
diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties
index 2d012a77..34494499 100644
--- a/mateclaw-server/src/main/resources/messages.properties
+++ b/mateclaw-server/src/main/resources/messages.properties
@@ -146,6 +146,8 @@ err.auth.user_not_found=\u7528\u6237\u4e0d\u5b58\u5728
err.auth.wrong_password=\u539f\u5bc6\u7801\u9519\u8bef
err.agent.not_found=Agent\u4e0d\u5b58\u5728
err.agent.disabled=Agent \u5df2\u7981\u7528
+err.agent.name_required=Agent \u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a
+err.agent.duplicate_name=\u5f53\u524d\u5de5\u4f5c\u533a\u5df2\u5b58\u5728\u540c\u540d Agent
err.workspace.not_found=\u5de5\u4f5c\u533a\u4e0d\u5b58\u5728
err.workspace.slug_exists=\u5de5\u4f5c\u533a\u6807\u8bc6\u5df2\u5b58\u5728
err.workspace.cannot_modify_default=\u4e0d\u80fd\u4fee\u6539\u9ed8\u8ba4\u5de5\u4f5c\u533a\u7684\u6807\u8bc6
@@ -164,6 +166,7 @@ err.skill.not_found=\u6280\u80fd\u4e0d\u5b58\u5728
err.skill.name_required=\u6280\u80fd\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a
err.skill.name_exists=\u6280\u80fd\u540d\u79f0\u5df2\u5b58\u5728
err.skill.builtin_readonly=\u5185\u7f6e\u6280\u80fd\u4e0d\u53ef\u5220\u9664
+err.skill.cross_workspace_binding=\u4e0d\u80fd\u5c06\u5176\u5b83\u5de5\u4f5c\u533a\u7684\u6280\u80fd\u7ed1\u5b9a\u5230\u5f53\u524d Agent
err.mcp.not_found=MCP server \u4e0d\u5b58\u5728
err.mcp.builtin_readonly=\u5185\u7f6e MCP server \u4e0d\u53ef\u5220\u9664
err.mcp.name_required=MCP server \u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a
diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties
index fa383fe5..413d4d3d 100644
--- a/mateclaw-server/src/main/resources/messages_en.properties
+++ b/mateclaw-server/src/main/resources/messages_en.properties
@@ -152,6 +152,8 @@ err.auth.wrong_password=Incorrect current password
# agent
err.agent.not_found=Agent not found
err.agent.disabled=Agent is disabled
+err.agent.name_required=Agent name is required
+err.agent.duplicate_name=An Agent with this name already exists in the current workspace
# workspace
err.workspace.not_found=Workspace not found
err.workspace.slug_exists=Workspace slug already exists
@@ -174,6 +176,7 @@ err.skill.not_found=Skill not found
err.skill.name_required=Skill name cannot be empty
err.skill.name_exists=Skill name already exists
err.skill.builtin_readonly=Built-in skill cannot be deleted
+err.skill.cross_workspace_binding=Cannot bind a skill from a different workspace to this Agent
# mcp
err.mcp.not_found=MCP server not found
err.mcp.builtin_readonly=Built-in MCP server cannot be deleted
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 8342d23d..6fc3b178 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -3110,7 +3110,6 @@ export default {
remember_structured: 'Save Memory',
forget_structured: 'Clear Memory',
fact_probe: 'Probe Facts',
- fact_related: 'Query Related Facts',
fact_list_contradictions: 'Check Contradictions',
session_search: 'Search Sessions',
read_workspace_memory_file: 'Read Memory File',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index e83bbb07..6dcdc668 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -3114,7 +3114,6 @@ export default {
remember_structured: '保存记忆',
forget_structured: '清除记忆',
fact_probe: '探查事实',
- fact_related: '查询关联事实',
fact_list_contradictions: '检查矛盾事实',
session_search: '搜索会话',
read_workspace_memory_file: '读取记忆文件',