fix(agent): harden digital-employee isolation across creation, skill binding and prompt catalog

This commit is contained in:
matevip 2026-05-11 16:10:28 +08:00
parent 9fd5843dda
commit 6d5fb595d3
17 changed files with 289 additions and 96 deletions

View File

@ -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&lt;T&gt; 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);

View File

@ -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) {

View File

@ -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;
}

View File

@ -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.
*/

View File

@ -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> {
}

View File

@ -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) {

View File

@ -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)));

View File

@ -97,7 +97,17 @@ public class SkillEntity {
/** 标签(逗号分隔) */
private String tags;
/** RFC-023来源对话 IDAgent 自治合成时记录) */
/**
* 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;
/** 来源对话 IDAgent 自治合成时记录) */
private String sourceConversationId;
/**

View File

@ -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();

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -3114,7 +3114,6 @@ export default {
remember_structured: '保存记忆',
forget_structured: '清除记忆',
fact_probe: '探查事实',
fact_related: '查询关联事实',
fact_list_contradictions: '检查矛盾事实',
session_search: '搜索会话',
read_workspace_memory_file: '读取记忆文件',