mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): harden digital-employee isolation across creation, skill binding and prompt catalog
This commit is contained in:
parent
9fd5843dda
commit
6d5fb595d3
@ -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.
|
||||
*
|
||||
* <p>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<AgentEntity> q = new LambdaQueryWrapper<AgentEntity>()
|
||||
.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);
|
||||
|
||||
@ -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<AgentSkillBinding>()
|
||||
@ -121,6 +154,15 @@ public class AgentBindingService {
|
||||
* 批量设置 Agent 的 skill 绑定(替换模式)
|
||||
*/
|
||||
public void setSkillBindings(Long agentId, List<Long> 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<AgentSkillBinding>()
|
||||
@ -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.
|
||||
*
|
||||
* <p>Three skill id flavors to handle:
|
||||
* <ul>
|
||||
* <li><b>Real {@code mate_skill} rows</b> — straight mapper lookup,
|
||||
* compare {@code workspace_id} to the agent's.</li>
|
||||
* <li><b>Virtual MCP-derived ids</b> ({@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.</li>
|
||||
* <li><b>Virtual ACP-derived ids</b> ({@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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<AgentToolBinding> listToolBindings(Long agentId) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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<FactEntity> related(Long agentId, String entity, int hops) {
|
||||
// Find fact IDs that reference this entity
|
||||
List<FactEntityRefEntity> refs = refMapper.selectList(
|
||||
new LambdaQueryWrapper<FactEntityRefEntity>()
|
||||
.like(FactEntityRefEntity::getEntityName, entity)
|
||||
.last("LIMIT 50"));
|
||||
List<Long> factIds = refs.stream().map(FactEntityRefEntity::getFactId).distinct().toList();
|
||||
if (factIds.isEmpty()) return List.of();
|
||||
|
||||
return factMapper.selectList(
|
||||
new LambdaQueryWrapper<FactEntity>()
|
||||
.eq(FactEntity::getAgentId, agentId)
|
||||
.eq(FactEntity::getDeleted, 0)
|
||||
.in(FactEntity::getId, factIds)
|
||||
.orderByDesc(FactEntity::getTrust)
|
||||
.last("LIMIT 20"));
|
||||
}
|
||||
|
||||
/**
|
||||
* List unresolved contradictions for an agent.
|
||||
*/
|
||||
|
||||
@ -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<FactEntityRefEntity> {
|
||||
}
|
||||
@ -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<FactEntity> 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) {
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -354,16 +354,20 @@ public class SkillRuntimeService {
|
||||
Long agentId) {
|
||||
List<ResolvedSkill> 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<SkillEntity> 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();
|
||||
|
||||
@ -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__<id>__<uuid>`. 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);
|
||||
@ -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;
|
||||
@ -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__<id>__<uuid>`. 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;
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -3114,7 +3114,6 @@ export default {
|
||||
remember_structured: '保存记忆',
|
||||
forget_structured: '清除记忆',
|
||||
fact_probe: '探查事实',
|
||||
fact_related: '查询关联事实',
|
||||
fact_list_contradictions: '检查矛盾事实',
|
||||
session_search: '搜索会话',
|
||||
read_workspace_memory_file: '读取记忆文件',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user