diff --git a/.env.example b/.env.example
index ce022f3a..3983a0dd 100644
--- a/.env.example
+++ b/.env.example
@@ -29,6 +29,11 @@ JWT_SECRET=
# 若留空,服务器会允许所有 origin 并在启动日志里 WARN。生产部署务必设置。
MATECLAW_CORS_ALLOWED_ORIGINS=
+# 公开访问基址(如 https://mateclaw.example.com)。用于把智能体生成文件的下载
+# 链接拼成绝对地址,便于在 Web 之外(IM 消息、复制链接、外部下载)直接打开。
+# 留空时回退到当前请求的 host,再退回相对路径。反代后部署建议显式设置。
+MATECLAW_PUBLIC_BASE_URL=
+
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
@@ -69,6 +74,36 @@ MATECLAW_BROWSER_CHANNEL=
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE=
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
+# ==================== Wiki 知识库目录白名单(Docker 模式,可选)====================
+#
+# Docker 生产部署开启了路径安全校验(fail-closed)。
+# 知识库使用「目录扫描」功能时,扫描路径必须在此白名单内,否则返回 400 错误。
+# 多个路径用英文逗号分隔;留空则禁止所有目录扫描。
+#
+# 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
+#
+# 同时在 docker-compose.yml 的 volumes 里把宿主机目录挂进容器,例如:
+# volumes:
+# - /your/host/path:/data/wiki
+MATE_WIKI_ALLOWED_SOURCE_ROOTS=
+
+# ── Wiki 知识源自动同步(变更监测)总开关 ────────────────────────
+# 定时扫描各知识库的源目录、自动消化新文件。默认关闭,运维主动开启。
+# AND 语义:全局这个开关开 *且* 某知识库自己的「自动同步」开关也开,
+# 该库才会被定时扫描;手动「立即扫描」不受此开关影响。
+# 间隔单位毫秒,默认 5 分钟(目前为全局,暂不支持按库配置)。
+MATE_WIKI_WATCHER_ENABLED=false
+MATE_WIKI_WATCHER_INTERVAL_MS=300000
+
+# ── Skill 工作区目录 ─────────────────────────────────────────────
+# 已安装的 skill、运行时积累的 LESSONS.md、skill 运行产物都落在这个目录。
+# 默认(容器内)已指向 /app/data/skills,由 docker-compose 的 server_data 卷
+# 持久化,容器重启不丢,无需额外挂卷。一般无需修改。
+# 内置 skill 由 JAR classpath 每次启动现场释放,挂空卷也不会丢内置文件。
+# 仅当你想把 skill 目录放到别处(如独立的 bind mount)时才覆盖此项,
+# 并记得在 docker-compose.yml 的 volumes 里把对应宿主机目录挂进容器。
+MATECLAW_SKILL_WORKSPACE_ROOT=
+
# ── Maven 镜像(国内加速)─────────────────────────────────────────
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
diff --git a/.gitignore b/.gitignore
index 71b27cc3..f93f1342 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,3 +111,8 @@ scripts/.*-sync-state.json
# Sandbox / external client work that lives in this directory
# but should not ship in the repo.
outputs/
+
+# This is a pnpm monorepo — pnpm-lock.yaml is the only lockfile we track.
+# Ignore stray npm/yarn lockfiles so they are not committed by mistake.
+package-lock.json
+yarn.lock
diff --git a/README.md b/README.md
index bf4f0646..3d338c90 100644
--- a/README.md
+++ b/README.md
@@ -217,9 +217,20 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
-**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0) for the full story.
+**v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0).
-**Next** — Drag-to-edit workflow canvas · run replay timeline · `loop` and `invoke_skill` step modes · trigger priorities and event replay · industry scenario marketplace · more ACP upstream integrations.
+**v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0).
+
+**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0).
+
+**v1.6.0 (in progress)** — make the autonomous employee *fast, sharp-eyed, and embeddable*:
+
+- **Faster first token** — two-stage skill loading (base skills resident, scenario skills retrieved on demand by a relevance scorer) plus prefix compression, cutting the cold-start payload that used to blow past a million characters
+- **Native code execution** — `execute_code` lets an employee write and run sandboxed code to compute, transform data, and assemble multi-format reports, all JVM-side
+- **Vision that persists** — images stay in context across turns; `image_analyze` re-reads an attachment on demand, so "zoom into that chart" follow-ups work without re-uploading
+- **Embeddable & headless** — the webchat widget becomes a Web/API surface with multi-session support and per-end-user identity (`endUserId`), isolating memory per end user
+- **A Wiki you actually read** — reading split from management, a unified Sources tab with per-KB auto-sync, and clickable cross-KB `[[wikilinks]]`
+- **Steadier under load** — self-healing MCP connections · tool-call recovery on interleaved-thinking models · evidence-gated plan execution
## Contributing
diff --git a/README_zh.md b/README_zh.md
index 5b3f9789..dae024a8 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -217,9 +217,20 @@ mateclaw/
## 路线图
-**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。完整故事见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
+**v1.5.0(2026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
-**下一步** — 工作流画布可拖拉编辑 · 运行回放时间线 · `loop` / `invoke_skill` step mode · 触发器优先级 + 事件回放 · 行业场景应用市场 · 更多 ACP 上游集成。
+**v1.4.0(2026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBAC(Owner / Admin / Member / Viewer)· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
+
+**v1.3.0(2026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
+
+**v1.6.0(开发中)** — 让自驱的数字员工*更快、更会看、更易嵌入*:
+
+- **首字节更快** — 技能两段式载入(基础技能常驻,场景技能由相关性评分器按需检索)+ prefix 压缩,砍掉过去单请求动辄上百万字符的冷启动负载
+- **原生代码执行** — `execute_code` 让员工自己写、自己跑沙箱代码,完成计算、数据加工与多格式报告生成,全程在 JVM 内
+- **能记住图的视觉** — 图片跨轮次保留在上下文里;`image_analyze` 按需重新解析某张附件,"放大看那张图表"这类追问无需重新上传
+- **可嵌入、可无头** — webchat 组件升级为 Web/API 接入面,支持多会话与按终端用户身份(`endUserId`)隔离记忆
+- **真正可读的 Wiki** — 阅读与管理分离、统一的 Sources 标签页(按知识库自动同步)、可点击的跨库 `[[wikilinks]]`
+- **高负载更稳** — MCP 连接自愈 · interleaved-thinking 模型的工具调用恢复 · 计划执行的证据闸门
## 参与贡献
diff --git a/docker-compose.yml b/docker-compose.yml
index 69408c32..36fe8052 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -96,6 +96,19 @@ services:
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}
MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST: ${MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST:-0.0.0.0}
+ # Wiki 知识库目录扫描白名单(逗号分隔,留空则禁止所有目录扫描)。
+ # 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
+ # 记得同步在 volumes 里把宿主机路径挂进容器。
+ MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-}
+ # Wiki 知识源自动同步总开关(运维总闸,默认关)。AND 语义:全局开关与
+ # 每个知识库自己的「自动同步」开关都开,该库才会被定时扫描。
+ # 间隔单位毫秒,默认 5 分钟。
+ MATE_WIKI_WATCHER_ENABLED: ${MATE_WIKI_WATCHER_ENABLED:-false}
+ MATE_WIKI_WATCHER_INTERVAL_MS: ${MATE_WIKI_WATCHER_INTERVAL_MS:-300000}
+ # Skill 工作区根目录。放在 /app/data 下,让现有的 server_data 卷一并持久化
+ # 已安装的 skill、运行时积累的 LESSONS.md 以及 skill 运行产物,容器重启不丢。
+ # 内置 skill 仍由 JAR classpath 每次启动现场释放,空卷不会丢内置文件。
+ MATECLAW_SKILL_WORKSPACE_ROOT: ${MATECLAW_SKILL_WORKSPACE_ROOT:-/app/data/skills}
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
# recommendation for Playwright / headless chrome.
@@ -104,6 +117,9 @@ services:
- "18080:18088" # host:container — app listens on 18088 inside the container
- "1455:1455"
volumes:
+ # server_data covers /app/data — H2 DB, wiki-uploads, AND the skill
+ # workspace (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a
+ # single volume persists everything. No separate skills volume needed.
- server_data:/app/data
volumes:
diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile
index 05963794..2898c2ae 100644
--- a/mateclaw-server/Dockerfile
+++ b/mateclaw-server/Dockerfile
@@ -105,9 +105,18 @@ RUN apt-get update \
# BrowserLauncher's BUNDLED strategy will then succeed without extra config.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
TZ=Asia/Shanghai \
- JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai"
+ LANG=C.UTF-8 \
+ LC_ALL=C.UTF-8 \
+ JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Shanghai -Dsun.jnu.encoding=UTF-8"
+
+# Default DB profile, overridable by the SPRING_PROFILES_ACTIVE env var
+# (compose sets it explicitly: mysql / postgres / kingbase). It must be an ENV,
+# not a -D system property on the ENTRYPOINT: a hardcoded
+# -Dspring.profiles.active outranks the SPRING_PROFILES_ACTIVE env var and would
+# silently pin the profile regardless of what compose passes.
+ENV SPRING_PROFILES_ACTIVE=mysql
COPY --from=builder /build/mateclaw-server/target/*.jar app.jar
EXPOSE 18088
EXPOSE 1455
-ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=mysql", "app.jar"]
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml
index 74cf5bb4..e04bb74f 100644
--- a/mateclaw-server/pom.xml
+++ b/mateclaw-server/pom.xml
@@ -337,6 +337,30 @@
org.flywaydbflyway-mysql
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
+
+ org.postgresql
+ postgresql
+ 42.7.7
+ runtime
+
+
+
@@ -470,5 +494,24 @@
+
+
+
+ kingbase
+
+
+ com.kingbase8
+ kingbase8
+ 8.6.0
+ runtime
+
+
+
diff --git a/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java b/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java
index 3e33d262..dba85b66 100644
--- a/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java
+++ b/mateclaw-server/src/main/java/vip/mate/MateClawApplication.java
@@ -1,19 +1,29 @@
package vip.mate;
+import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Bean;
+import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.EnableScheduling;
+import javax.sql.DataSource;
+import java.sql.Connection;
+
/**
* MateClaw - Personal AI Assistant
* Powered by Spring AI Alibaba
*
* @author MateClaw Team
*/
+@Slf4j
@SpringBootApplication(exclude = {
// Disable Spring AI MCP Client auto-configuration (lifecycle owned by McpClientManager).
org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.class,
@@ -33,22 +43,80 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@MapperScan("vip.mate.**.repository")
public class MateClawApplication {
+ @Autowired
+ private DataSource dataSource;
+
+ /** Cached DbType for the PaginationInnerInterceptor. */
+ private volatile DbType resolvedDbType;
+
public static void main(String[] args) {
SpringApplication.run(MateClawApplication.class, args);
}
+ /**
+ * Detect the actual database type from the live DataSource so the
+ * {@link PaginationInnerInterceptor} always uses the correct dialect,
+ * even when the JDBC URL is wrapped by a proxy (HikariCP, P6Spy, etc.).
+ *
+ *
DbType is cached after the first successful detection; a failure
+ * falls back to the value set in {@code mybatis-plus.global-config.db-config.db-type},
+ * or eventually to {@link DbType#MYSQL} — but by then the connection
+ * pool would already have failed.
+ */
+ @PostConstruct
+ void detectDbType() {
+ try (Connection conn = dataSource.getConnection()) {
+ String productName = conn.getMetaData().getDatabaseProductName().toLowerCase();
+ if (productName.contains("kingbase")) {
+ resolvedDbType = DbType.KINGBASE_ES;
+ } else if (productName.contains("postgresql")) {
+ resolvedDbType = DbType.POSTGRE_SQL;
+ } else if (productName.contains("mysql") || productName.contains("mariadb")) {
+ resolvedDbType = DbType.MYSQL;
+ } else if (productName.contains("h2")) {
+ resolvedDbType = DbType.H2;
+ } else {
+ // Let the PaginationInnerInterceptor auto-detect at query time
+ resolvedDbType = null;
+ }
+ if (resolvedDbType != null) {
+ log.info("Detected database type: {} (product={})", resolvedDbType, productName);
+ }
+ } catch (Exception e) {
+ log.warn("Could not detect database type — PaginationInnerInterceptor will auto-detect on first query: {}",
+ e.getMessage());
+ }
+ }
+
/**
* MyBatis Plus pagination plugin.
*
- *
DbType is auto-detected from the JDBC connection at runtime rather
- * than hardcoded. Hardcoding H2 here meant the MySQL deployment used
- * the H2 dialect for the count query, which silently returned 0 —
- * frontends saw records but total=0 and couldn't paginate (RFC-042 P0).
+ *
When {@code resolvedDbType} is available the interceptor uses it directly;
+ * otherwise it falls back to JDBC-URL auto-detection, which works for
+ * {@code jdbc:kingbase8://} but not for proxied DataSources (RFC-042 P0).
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
- interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
+ PaginationInnerInterceptor pagination = resolvedDbType != null
+ ? new PaginationInnerInterceptor(resolvedDbType)
+ : new PaginationInnerInterceptor();
+ interceptor.addInnerInterceptor(pagination);
return interceptor;
}
+
+ /**
+ * Print a clear "READY" banner after all post-startup initialization,
+ * so operators can tell at a glance when the application is ready to serve.
+ */
+ @EventListener(ApplicationReadyEvent.class)
+ public void onReady() {
+ log.info("");
+ log.info("╔══════════════════════════════════════════════════════════════════════╗");
+ log.info("║ MateClaw is READY ✓ ║");
+ log.info("║ Web UI → http://localhost:18088 ║");
+ log.info("║ Swagger → http://localhost:18088/swagger-ui.html ║");
+ log.info("╚══════════════════════════════════════════════════════════════════════╝");
+ log.info("");
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index f9e16cc2..d42f2cad 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -88,6 +88,11 @@ public class AgentGraphBuilder {
@org.springframework.beans.factory.annotation.Value(
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled;
+
+ /** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
+ @org.springframework.beans.factory.annotation.Value(
+ "${mate.agent.markdown-normalize-enabled:true}")
+ private boolean markdownNormalizeEnabled;
private final ConversationService conversationService;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
@@ -152,6 +157,30 @@ public class AgentGraphBuilder {
this.auditEventService = s;
}
+ /**
+ * Optional per-step delegation dependencies for the Plan-Execute graph.
+ * Setter injection (like {@link #auditEventService}) breaks the
+ * {@code AgentService ⇆ AgentGraphBuilder} construction cycle. Null when not
+ * wired (legacy / test) — per-step delegation is then simply disabled.
+ */
+ private AgentService agentService;
+
+ // @Lazy on the injection point: inject a lazy-resolution proxy so the
+ // AgentService ⇆ AgentGraphBuilder cycle is broken at bean-creation time
+ // (the real bean is resolved on first use, when the graph is built).
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ public void setAgentService(@org.springframework.context.annotation.Lazy AgentService agentService) {
+ this.agentService = agentService;
+ }
+
+ private vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool;
+
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ public void setDelegateAgentTool(
+ @org.springframework.context.annotation.Lazy vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool) {
+ this.delegateAgentTool = delegateAgentTool;
+ }
+
/**
* 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。
*/
@@ -549,8 +578,11 @@ public class AgentGraphBuilder {
if (auditEventService != null) {
executor.setAuditEventService(auditEventService);
}
- PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
+ PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
+ // Per-step delegation: route a step assigned to a specialist agent
+ // through DelegateAgentTool (null when delegation deps aren't wired).
+ stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
@@ -574,6 +606,7 @@ public class AgentGraphBuilder {
.addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND)
+ .addStrategy(PlanStateKeys.PLAN_REPLAN_COUNT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE)
// 工作上下文(REPLACE 策略,每次重新生成)
@@ -638,6 +671,7 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure — pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
@@ -652,6 +686,7 @@ public class AgentGraphBuilder {
// ├→ DIRECT_ANSWER_NODE → END
// └→ STEP_EXECUTION → (StepProgressDispatcher)
// ├→ STEP_EXECUTION (loop)
+ // ├→ PLAN_GENERATION (re-plan on step failure, bounded by PLAN_REPLAN_COUNT)
// └→ PLAN_SUMMARY → (active goal?)
// ├→ GOAL_EVALUATION → (followup?)
// │ ├→ PLAN_GENERATION (re-plan)
@@ -685,11 +720,18 @@ public class AgentGraphBuilder {
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
+ // Step-failure recovery: re-plan the remaining work
+ // (StepProgressDispatcher returns this on phase=plan_replan).
+ PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END))
.addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
- boolean hasGoal = a.hasActiveGoal();
+ // Same-turn activation: fall back to a DB lookup (gated on the
+ // feature flag) so a goal the agent set THIS turn is evaluated now,
+ // not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
+ boolean hasGoal = goalProperties.isEnabled()
+ && GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
@@ -714,7 +756,11 @@ public class AgentGraphBuilder {
.addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
- boolean hasGoal = a.hasActiveGoal();
+ // Same-turn activation: fall back to a DB lookup (gated on the
+ // feature flag) so a goal the agent set THIS turn is evaluated now,
+ // not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
+ boolean hasGoal = goalProperties.isEnabled()
+ && GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
@@ -751,9 +797,17 @@ public class AgentGraphBuilder {
* and tool-result chunking. Decoupled from the per-agent value so a small
* {@code max_iterations} can never accidentally re-introduce the silent
* killer.
+ *
+ * The base segment budget is further multiplied to cover goal-driven "hard
+ * continuations" — each grants a fresh full iteration budget after a
+ * max-iterations turn (see {@code GoalEvaluationNode}). One run can perform
+ * up to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING} of them, so
+ * the ceiling is sized for {@code (1 + CEILING)} segments to keep the
+ * recursion guard from tripping before the soft caps do.
*/
private static int frameworkRecursionLimit() {
- return (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4 + 100;
+ int perSegment = (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4;
+ return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100;
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
@@ -809,7 +863,7 @@ public class AgentGraphBuilder {
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
LimitExceededNode limitExceededNode = new LimitExceededNode(
chatModel, observationProcessor, streamingHelper, i18nService, progressLedgerService);
- FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache);
+ FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache, markdownNormalizeEnabled);
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
// 输入字段
@@ -821,6 +875,7 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
// 迭代控制
.addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.ITERATION_REFUND_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE)
// 工具调用
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
@@ -902,6 +957,7 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure — pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
@@ -951,7 +1007,11 @@ public class AgentGraphBuilder {
.addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
- boolean hasGoal = a.hasActiveGoal();
+ // Same-turn activation: fall back to a DB lookup (gated on the
+ // feature flag) so a goal the agent set THIS turn is evaluated now,
+ // not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
+ boolean hasGoal = goalProperties.isEnabled()
+ && GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
@@ -1315,6 +1375,23 @@ public class AgentGraphBuilder {
// ==================== Prompt 构建 ====================
+ /**
+ * Cache-stable platform identity, appended to every agent's system
+ * prompt. Answers "who are you / what are you based on". The volatile
+ * "which model right now" fact is injected per-turn by
+ * {@link vip.mate.agent.context.RuntimeContextInjector} instead, to
+ * keep this prefix's prompt-cache hash stable.
+ */
+ static final String ABOUT_YOU_BLOCK = """
+
+ ## About You
+ You are powered by MateClaw — a multi-user AI Agent platform built on
+ Spring Boot 3.5 and Spring AI Alibaba Graph. You are reachable through
+ WebChat and 8+ IM channels (DingTalk, Feishu, WeCom, WeChat, Telegram,
+ Discord, QQ, Slack). If asked who you are or what you are based on,
+ answer with MateClaw and the technology stack above.
+ """;
+
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
// The agent's own systemPrompt encodes its identity (role / goal /
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
@@ -1470,7 +1547,7 @@ public class AgentGraphBuilder {
// Wiki 知识库上下文注入
String wikiContext = wikiContextService.buildWikiContext(entity.getId());
- return basePrompt + toolGuidance + searchGuidance + wikiContext;
+ return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext;
}
/**
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 e7b9c159..217cff21 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java
@@ -505,6 +505,19 @@ public class AgentService {
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
}
+ /**
+ * Issue #289: an MCP server connecting / disconnecting / reconnecting
+ * changes the live tool set, but cached agents snapshot their tools at
+ * build time. Clear the cache so the next turn rebuilds against the
+ * current MCP tools instead of replying "from memory" with a stale,
+ * tool-less graph.
+ */
+ @EventListener
+ public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) {
+ refreshAllAgents();
+ log.info("Agent caches refreshed after MCP server change: {}", event.reason());
+ }
+
// ==================== Lifecycle helpers ====================
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java
index 801de3a0..344b8640 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java
@@ -955,6 +955,11 @@ public abstract class BaseAgent {
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
&& mediaCaptionService != null
&& decision.sidecarModel() != null) {
+ // The user's actual question (text parts only, excluding media markers)
+ // so the vision model tailors its description to what was asked rather
+ // than emitting a generic caption.
+ String userQuestion = extractUserQuestion(parts);
+ boolean captionPersisted = false;
for (MessageContentPart part : parts) {
if (part == null) continue;
String contentType = part.getContentType();
@@ -963,13 +968,23 @@ public abstract class BaseAgent {
&& !contentType.contains("svg");
if (!isImage) continue;
MediaCaptionService.CaptionResult result = mediaCaptionService.caption(
- decision.sidecarModel(), part, userLocale);
+ decision.sidecarModel(), part, userLocale, userQuestion);
if (result.isFailure()) {
log.warn("[{}] Sidecar caption failed for {}: {}",
agentName, part.getFileName(), result.failure().getMessage());
- textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
- .append(part.getFileName())
- .append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
+ if (isRemoteOnlyAttachment(part)) {
+ // The image was never downloaded locally (only a remote
+ // channel URL survives) — for WeCom/aibot that URL points
+ // at short-lived AES-encrypted bytes, so captioning can
+ // never succeed until media download is enabled.
+ textBuilder.append("\n\n[系统提示] 图片 ")
+ .append(part.getFileName())
+ .append(" 未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载。");
+ } else {
+ textBuilder.append("\n\n[系统提示] 视觉模型未能解析附件 ")
+ .append(part.getFileName())
+ .append(",请稍后重试或在「设置 → 模型」检查视觉模型配置。");
+ }
continue;
}
textBuilder.append("\n\n[图片附件描述: ")
@@ -977,9 +992,17 @@ public abstract class BaseAgent {
.append("]\n")
.append(result.description())
.append("\n[/图片附件描述]");
+ // Persist the caption onto the part so later turns retain the image
+ // content: history user messages replay as text only, and without a
+ // stored caption every follow-up question loses the attachment.
+ part.setCaption(result.description());
+ captionPersisted = true;
String identifier = identifyPart(part);
if (identifier != null) sidecarHandledIdentifiers.add(identifier);
}
+ if (captionPersisted && conversationService != null) {
+ conversationService.updateMessageParts(message, parts);
+ }
}
List mediaList = new ArrayList<>();
@@ -1048,7 +1071,7 @@ public abstract class BaseAgent {
if (mediaPath == null) {
log.warn("[{}] {} file not found for attachment: {}, path: {}, mediaId: {}",
agentName, isVideo ? "Video" : "Image", part.getFileName(), part.getPath(), part.getMediaId());
- skippedAttachments.add(part.getFileName() + "(文件未找到)");
+ skippedAttachments.add(part.getFileName() + unresolvedAttachmentReason(part));
continue;
}
try {
@@ -1083,6 +1106,52 @@ public abstract class BaseAgent {
return new CurrentTurnUserMessage(built, decision);
}
+ /**
+ * Concatenate the text parts of a message into the user's question, dropping
+ * image/file/media parts. Returns {@code null} when there is no usable text
+ * (e.g. an image-only IM message), which makes the caption fall back to the
+ * generic full-description prompt.
+ */
+ private static String extractUserQuestion(List parts) {
+ if (parts == null || parts.isEmpty()) return null;
+ StringBuilder sb = new StringBuilder();
+ for (MessageContentPart part : parts) {
+ if (part == null || !"text".equals(part.getType())) continue;
+ String text = part.getText();
+ if (text == null || text.isBlank()) continue;
+ if (sb.length() > 0) sb.append('\n');
+ sb.append(text.trim());
+ }
+ String question = sb.toString().trim();
+ return question.isEmpty() ? null : question;
+ }
+
+ /**
+ * True when an attachment has no resolvable local file and its only locator
+ * is a remote http(s) URL — i.e. the IM channel never downloaded it locally.
+ * For WeCom/aibot images that URL points at short-lived AES-encrypted bytes,
+ * so it is unusable as-is. Lets callers turn a generic "file not found" into
+ * an actionable hint instead of a dead end.
+ */
+ private static boolean isRemoteOnlyAttachment(MessageContentPart part) {
+ if (part == null) return false;
+ if (part.getPath() != null && !part.getPath().isBlank()) return false;
+ String locator = part.getMediaId();
+ if (locator == null || locator.isBlank()) locator = part.getFileUrl();
+ return locator != null && (locator.startsWith("http://") || locator.startsWith("https://"));
+ }
+
+ /**
+ * Reason string appended to a skipped attachment whose local file could not
+ * be resolved — distinguishes "never downloaded" (channel media download
+ * off) from a genuine missing-file so the user gets an actionable message.
+ */
+ private static String unresolvedAttachmentReason(MessageContentPart part) {
+ return isRemoteOnlyAttachment(part)
+ ? "(图片未下载到本地,无法识别;请在「设置 → 渠道」开启该渠道的媒体下载)"
+ : "(文件未找到)";
+ }
+
/**
* Stable identifier for de-duplicating parts already handled by the sidecar
* pass. Falls back across {@code path → mediaId → fileName} since not every
@@ -1194,7 +1263,14 @@ public abstract class BaseAgent {
if ("user".equals(msg.getRole())) {
// 用 DB 中的实际内容(可能包含 contentParts),不用传入的 text
String content = conversationService.renderMessageContent(msg);
- return buildUserMessageForCurrentTurn(msg, content != null && !content.isBlank() ? content : userMessageText);
+ CurrentTurnUserMessage built = buildUserMessageForCurrentTurn(
+ msg, content != null && !content.isBlank() ? content : userMessageText);
+ // Vision-capable models replay history as text only, so a
+ // follow-up question about an earlier image would otherwise be
+ // answered blind. Re-attach the most recent image to this turn
+ // so the model actually re-sees it. (Text-only models instead
+ // rely on the persisted sidecar caption — see buildUserMessageInternal.)
+ return maybeCarryRecentImage(history, i, msg, built);
}
}
} catch (Exception e) {
@@ -1204,6 +1280,85 @@ public abstract class BaseAgent {
return new CurrentTurnUserMessage(new UserMessage(userMessageText), null);
}
+ /** How far back (in messages) to look for an image to carry into a follow-up turn. */
+ private static final int CARRY_IMAGE_LOOKBACK = 8;
+
+ /**
+ * For a vision-capable model, re-attach the most recent image from a recent
+ * earlier turn to the current user message when the current turn carries no
+ * image of its own. History is replayed as text only (see {@link #toSpringMessage}),
+ * so without this a follow-up like "what's in the top-left of that photo?" is
+ * answered blind. Bounded to a single image within {@link #CARRY_IMAGE_LOOKBACK}
+ * messages so a long conversation doesn't re-send pixels on every turn.
+ *
+ *
No-op (returns {@code built} unchanged) when: the model can't see images,
+ * the current turn already has an image, no recent image exists, or the recent
+ * image has no resolvable local file (e.g. an undownloaded channel URL).
+ */
+ private CurrentTurnUserMessage maybeCarryRecentImage(List history, int currentIdx,
+ MessageEntity currentMsg, CurrentTurnUserMessage built) {
+ try {
+ if (built == null || !modelSupportsVision()) return built;
+ if (messageHasImagePart(currentMsg)) return built; // current turn already carries an image
+
+ int from = Math.max(0, currentIdx - CARRY_IMAGE_LOOKBACK);
+ for (int j = currentIdx - 1; j >= from; j--) {
+ MessageEntity m = history.get(j);
+ if (m == null || !"user".equals(m.getRole())) continue;
+ List parts = conversationService.parseMessageParts(m);
+ for (int k = parts.size() - 1; k >= 0; k--) {
+ MessageContentPart part = parts.get(k);
+ if (!isResolvableImagePart(part)) continue;
+ Path imgPath = resolveImagePath(part.getPath());
+ if (imgPath == null && part.getMediaId() != null) imgPath = resolveImagePath(part.getMediaId());
+ if (imgPath == null) continue;
+ String contentType = part.getContentType();
+ if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg";
+ try {
+ Media carried = new Media(MimeType.valueOf(contentType), new FileSystemResource(imgPath));
+ UserMessage orig = built.userMessage();
+ String name = part.getFileName() == null ? "image" : part.getFileName();
+ String text = (orig.getText() == null ? "" : orig.getText())
+ + "\n\n[系统提示] 以下图片是用户本次对话中较早发送的「" + name
+ + "」,当前问题很可能与它相关。请直接查看该图片作答,不要凭记忆猜测。";
+ List media = new ArrayList<>();
+ if (orig.getMedia() != null) media.addAll(orig.getMedia());
+ media.add(carried);
+ log.debug("[{}] Carried recent image {} into follow-up turn for vision model",
+ agentName, name);
+ return new CurrentTurnUserMessage(
+ UserMessage.builder().text(text).media(media).build(),
+ built.routingDecision());
+ } catch (Exception e) {
+ log.debug("[{}] Failed to carry recent image {}: {}",
+ agentName, part.getFileName(), e.getMessage());
+ return built;
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.debug("[{}] maybeCarryRecentImage failed: {}", agentName, e.getMessage());
+ }
+ return built;
+ }
+
+ private boolean messageHasImagePart(MessageEntity message) {
+ for (MessageContentPart part : conversationService.parseMessageParts(message)) {
+ if (isResolvableImagePart(part)) return true;
+ }
+ return false;
+ }
+
+ /** An image part (not SVG) — the raster kind a multimodal API can ingest. */
+ private static boolean isResolvableImagePart(MessageContentPart part) {
+ if (part == null) return false;
+ String type = part.getType();
+ String contentType = part.getContentType();
+ boolean isImage = ("image".equals(type) || "file".equals(type))
+ && contentType != null && contentType.startsWith("image/");
+ return isImage && !contentType.contains("svg");
+ }
+
protected Path resolveImagePath(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
return null;
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
index a4172b93..fccea4a5 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
@@ -8,6 +8,7 @@ import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
+import vip.mate.agent.binding.model.AgentWikiKbBinding;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.audit.service.AuditEventService;
@@ -136,6 +137,32 @@ public class AgentBindingController {
return R.ok();
}
+ // ==================== Knowledge Base Access Scope ====================
+
+ @Operation(summary = "获取 Agent 的知识库访问范围")
+ @GetMapping("/kbs")
+ @RequireWorkspaceRole("viewer")
+ public R> listKbs(@PathVariable Long agentId,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ verifyAgentWorkspace(agentId, workspaceId);
+ return R.ok(bindingService.listKbBindings(agentId));
+ }
+
+ @Operation(summary = "批量设置 Agent 的知识库访问范围(替换模式,空表示不限制)")
+ @PutMapping("/kbs")
+ @RequireWorkspaceRole("member")
+ public R setKbs(@PathVariable Long agentId, @RequestBody List kbIds,
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
+ verifyAgentWorkspace(agentId, workspaceId);
+ bindingService.setKbBindings(agentId, kbIds);
+ agentService.invalidateAgentCache(agentId);
+ // A non-Vue caller can POST a bare `null`; the service tolerates it.
+ int count = kbIds == null ? 0 : kbIds.size();
+ auditEventService.record("UPDATE", "AGENT_WIKI_KB", String.valueOf(agentId),
+ "kbs=" + count, null);
+ return R.ok();
+ }
+
// ==================== Workspace Verification ====================
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java
new file mode 100644
index 00000000..aeedb8be
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentWikiKbBinding.java
@@ -0,0 +1,27 @@
+package vip.mate.agent.binding.model;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/**
+ * Agent ↔ knowledge base access scope row.
+ *
+ * Each enabled row whitelists one KB for one agent. When an agent has at
+ * least one row the wiki tools restrict their visible KB set to the bound
+ * ones; an agent with no rows stays workspace-wide (legacy behavior).
+ */
+@Data
+@TableName("mate_agent_wiki_kb")
+public class AgentWikiKbBinding {
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+ private Long agentId;
+ private Long kbId;
+ private Boolean enabled;
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+ private Integer deleted;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java
new file mode 100644
index 00000000..0f7d911d
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentWikiKbBindingMapper.java
@@ -0,0 +1,9 @@
+package vip.mate.agent.binding.repository;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import vip.mate.agent.binding.model.AgentWikiKbBinding;
+
+@Mapper
+public interface AgentWikiKbBindingMapper extends BaseMapper {
+}
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 834d9a62..68ebb40f 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
@@ -9,9 +9,11 @@ import org.springframework.stereotype.Service;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
+import vip.mate.agent.binding.model.AgentWikiKbBinding;
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.binding.repository.AgentWikiKbBindingMapper;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
@@ -26,6 +28,8 @@ import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
+import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
+import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -55,6 +59,17 @@ public class AgentBindingService implements AgentBindingResolver {
private final AgentSkillBindingMapper skillBindingMapper;
private final AgentToolBindingMapper toolBindingMapper;
private final AgentProviderPreferenceMapper providerPreferenceMapper;
+ /**
+ * Agent ↔ KB access-scope rows. Plain mapper (no transitive deps), so it
+ * is safe to wire directly here without risking a boot-time cycle.
+ */
+ private final AgentWikiKbBindingMapper kbBindingMapper;
+ /**
+ * Used only to verify a KB lives in the agent's workspace before pinning
+ * it. Like {@link #agentMapper}, a bare mapper avoids pulling the wiki
+ * service layer (and its dependency on agent binding) into this bean.
+ */
+ private final WikiKnowledgeBaseMapper kbMapper;
/**
* {@code @Lazy} — SkillRuntimeService and AgentBindingService both sit
* near the agent boot path; the lazy proxy avoids a circular bean
@@ -93,6 +108,8 @@ public class AgentBindingService implements AgentBindingResolver {
public AgentBindingService(AgentSkillBindingMapper skillBindingMapper,
AgentToolBindingMapper toolBindingMapper,
AgentProviderPreferenceMapper providerPreferenceMapper,
+ AgentWikiKbBindingMapper kbBindingMapper,
+ WikiKnowledgeBaseMapper kbMapper,
@Lazy SkillRuntimeService skillRuntimeService,
AvailableToolService availableToolService,
AgentMapper agentMapper,
@@ -101,6 +118,8 @@ public class AgentBindingService implements AgentBindingResolver {
this.skillBindingMapper = skillBindingMapper;
this.toolBindingMapper = toolBindingMapper;
this.providerPreferenceMapper = providerPreferenceMapper;
+ this.kbBindingMapper = kbBindingMapper;
+ this.kbMapper = kbMapper;
this.skillRuntimeService = skillRuntimeService;
this.availableToolService = availableToolService;
this.agentMapper = agentMapper;
@@ -725,6 +744,11 @@ public class AgentBindingService implements AgentBindingResolver {
"write_file",
"edit_file",
"execute_shell_command",
+ // Inline code execution — an agent-wide capability alongside shell.
+ // Lets any agent act on a documentation-only skill (a SKILL.md with
+ // no scripts) by writing and running the code its instructions
+ // describe. Dangerous code is screened by the same tool guard.
+ "execute_code",
"detect_file_type",
"extract_document_text",
"extract_pdf_text",
@@ -941,6 +965,123 @@ public class AgentBindingService implements AgentBindingResolver {
}
}
+ // ==================== Knowledge base access scope ====================
+
+ /** Raw scope rows for the agent edit form, oldest first. */
+ public List listKbBindings(Long agentId) {
+ return kbBindingMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(AgentWikiKbBinding::getAgentId, agentId)
+ .orderByAsc(AgentWikiKbBinding::getCreateTime));
+ }
+
+ /**
+ * Effective KB ids the agent may see. Three states (mirror
+ * {@link #getBoundSkillIds}):
+ *
+ *
+ *
{@code null} — {@code wiki_disabled=false} AND no binding rows.
+ * Caller treats this as "no agent-level restriction; inherit every
+ * KB in the agent's workspace" (the default wiki-tool behavior).
+ *
{@code Set.of()} — either {@code wiki_disabled=true}, or binding
+ * rows exist but none are {@code enabled=true}. Caller treats this
+ * as "explicitly scoped to zero KBs" — wiki tools degrade with
+ * their standard "no knowledge base" message.
+ *
non-empty set — the explicit allowlist.
+ *
+ *
+ *
The {@code wiki_disabled} flag takes precedence over row count, so
+ * a stale (flag + leftover rows) combination still surfaces as "no KBs".
+ * Mirrors how {@code skills_disabled} interacts with
+ * {@link #getBoundSkillIds}.
+ */
+ @Override
+ public Set getBoundKbIds(Long agentId) {
+ if (isWikiDisabled(agentId)) {
+ return Set.of();
+ }
+ List bindings = listKbBindings(agentId);
+ if (bindings.isEmpty()) {
+ return null;
+ }
+ return bindings.stream()
+ .filter(b -> Boolean.TRUE.equals(b.getEnabled()))
+ .map(AgentWikiKbBinding::getKbId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ }
+
+ /**
+ * Replace the agent's KB access scope. An empty / null list clears the
+ * scope, returning the agent to workspace-wide (unrestricted) access.
+ * Every incoming KB must live in the agent's workspace — pinning a KB
+ * from another tenancy is refused (403).
+ */
+ public void setKbBindings(Long agentId, List kbIds) {
+ // De-dup defensively: the unique index is (agent_id, kb_id, deleted),
+ // so two identical ids in the incoming list would collide on insert.
+ Set distinct = new LinkedHashSet<>();
+ if (kbIds != null) {
+ for (Long kbId : kbIds) {
+ if (kbId != null) {
+ distinct.add(kbId);
+ }
+ }
+ }
+ // Validate the whole set BEFORE deleting anything, so a rejected id
+ // can't leave the agent half-scoped.
+ for (Long kbId : distinct) {
+ requireKbInAgentWorkspace(agentId, kbId);
+ }
+ // Auto-clear wiki_disabled on a non-empty save — same contract as
+ // setSkillBindings: a concrete KB commitment contradicts an opt-out
+ // flag, so the data layer must never hold both states at once.
+ if (!distinct.isEmpty()) {
+ clearWikiDisabledFlag(agentId);
+ }
+ kbBindingMapper.delete(
+ new LambdaQueryWrapper()
+ .eq(AgentWikiKbBinding::getAgentId, agentId));
+ for (Long kbId : distinct) {
+ AgentWikiKbBinding row = new AgentWikiKbBinding();
+ row.setAgentId(agentId);
+ row.setKbId(kbId);
+ row.setEnabled(true);
+ kbBindingMapper.insert(row);
+ }
+ }
+
+ /**
+ * Refuse to scope an agent to a KB outside its workspace. KBs are
+ * workspace-shared artifacts ({@code mate_wiki_knowledge_base.workspace_id});
+ * letting workspace A's agent pin workspace B's KB would cross the
+ * tenancy boundary the same way a cross-workspace skill binding would.
+ * A {@code null} workspace on either side is normalized to the default
+ * workspace (1) to match the rest of the codebase.
+ */
+ private void requireKbInAgentWorkspace(Long agentId, Long kbId) {
+ if (agentId == null) {
+ throw new MateClawException("err.agent.not_found", 404, "Agent ID is required");
+ }
+ AgentEntity agent = agentMapper.selectById(agentId);
+ if (agent == null) {
+ throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId);
+ }
+ WikiKnowledgeBaseEntity kb = kbMapper.selectById(kbId);
+ if (kb == null) {
+ throw new MateClawException("err.wiki.kb_not_found", 404,
+ "Knowledge base 不存在: " + kbId);
+ }
+ long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId();
+ long kbWs = kb.getWorkspaceId() == null ? 1L : kb.getWorkspaceId();
+ if (agentWs != kbWs) {
+ throw new MateClawException("err.wiki.cross_workspace_kb_binding", 403,
+ "Knowledge base " + kbId + " (workspace=" + kbWs
+ + ") cannot be scoped to Agent " + agentId
+ + " (workspace=" + agentWs + ")");
+ }
+ }
+
// ==================== Binding-mode flags (V126) ====================
/**
@@ -993,4 +1134,28 @@ public class AgentBindingService implements AgentBindingResolver {
update.setToolsDisabled(false);
agentMapper.updateById(update);
}
+
+ /** Mirror of {@link #isSkillsDisabled} for the wiki/knowledge-base opt-out toggle. */
+ private boolean isWikiDisabled(Long agentId) {
+ if (agentId == null) return false;
+ AgentEntity agent = agentMapper.selectById(agentId);
+ return agent != null && Boolean.TRUE.equals(agent.getWikiDisabled());
+ }
+
+ /**
+ * Mirror of {@link #clearSkillsDisabledFlag} for the wiki toggle. Used as
+ * an auto-clear step in {@link #setKbBindings} so a concrete KB commitment
+ * always wins over a stale opt-out flag.
+ */
+ private void clearWikiDisabledFlag(Long agentId) {
+ if (agentId == null) return;
+ AgentEntity agent = agentMapper.selectById(agentId);
+ if (agent == null || !Boolean.TRUE.equals(agent.getWikiDisabled())) {
+ return;
+ }
+ AgentEntity update = new AgentEntity();
+ update.setId(agentId);
+ update.setWikiDisabled(false);
+ agentMapper.updateById(update);
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java
index 6bc2a6c9..5e2e3a17 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java
@@ -58,7 +58,15 @@ public record ChatOrigin(
* vs. group conversations. Null for 1:1 chats. Distinct from
* {@link #channelTarget()} (which targets cron / proactive sends).
*/
- @Nullable String chatId
+ @Nullable String chatId,
+ /**
+ * Public base URL ({@code scheme://host[:port][/contextPath]}) resolved
+ * from the inbound HTTP request on the request thread. Carried here so
+ * tools running on async/streaming threads — where no request is bound —
+ * can still mint absolute download links. Null for IM/cron origins, which
+ * have no request host; those rely on {@code mateclaw.server.public-base-url}.
+ */
+ @Nullable String baseUrl
) {
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
@@ -66,7 +74,7 @@ public record ChatOrigin(
/** Sentinel used by AgentService default overloads where no origin is supplied. */
public static final ChatOrigin EMPTY =
- new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null);
+ new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null);
// ---------------- Factories per entry point ----------------
@@ -74,9 +82,17 @@ public record ChatOrigin(
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath) {
+ return web(conversationId, requesterId, workspaceId, workspaceBasePath, null);
+ }
+
+ public static ChatOrigin web(@Nullable String conversationId,
+ @Nullable String requesterId,
+ @Nullable Long workspaceId,
+ @Nullable String workspaceBasePath,
+ @Nullable String baseUrl) {
return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "",
- workspaceId, workspaceBasePath, null, null, false, null, "web", null);
+ workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
}
public static ChatOrigin cron(@Nullable String conversationId,
@@ -85,7 +101,7 @@ public record ChatOrigin(
@Nullable Long channelId,
@Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system",
- workspaceId, workspaceBasePath, channelId, target, true, null, null, null);
+ workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
}
// ---------------- Wither-style updates ----------------
@@ -93,20 +109,27 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
- senderName, channelType, chatId);
+ senderName, channelType, chatId, baseUrl);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
- senderName, channelType, chatId);
+ senderName, channelType, chatId, baseUrl);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
- senderName, channelType, chatId);
+ senderName, channelType, chatId, baseUrl);
+ }
+
+ /** Carry a request-derived public base URL (see {@link #baseUrl()}). */
+ public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
+ return new ChatOrigin(agentId, conversationId, requesterId,
+ workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
+ senderName, channelType, chatId, newBaseUrl);
}
/**
@@ -120,7 +143,7 @@ public record ChatOrigin(
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
- newSenderName, newChannelType, newChatId);
+ newSenderName, newChannelType, newChatId, baseUrl);
}
// ---------------- Spring AI ToolContext interop ----------------
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java
index 7b3649b1..4f595be6 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/RuntimeContextInjector.java
@@ -67,6 +67,24 @@ public final class RuntimeContextInjector {
public static String buildContextMessage(String workspaceBasePath,
vip.mate.i18n.I18nService i18n,
ChatOrigin origin) {
+ return buildContextMessage(workspaceBasePath, i18n, origin, null, null);
+ }
+
+ /**
+ * Full overload that also renders the agent's runtime model identity.
+ * The model line is emitted for EVERY origin (web / cron / IM / null)
+ * because it describes the agent, not the caller — only the sender
+ * block stays IM-only. {@code modelName}/{@code providerId} come from
+ * graph state ({@code RUNTIME_MODEL_NAME}/{@code RUNTIME_PROVIDER_ID}),
+ * i.e. the model selected at run start (mid-run failover is not
+ * reflected — accepted trade-off). Stays well under the 1024-char
+ * spring-ai user-cache threshold.
+ */
+ public static String buildContextMessage(String workspaceBasePath,
+ vip.mate.i18n.I18nService i18n,
+ ChatOrigin origin,
+ String modelName,
+ String providerId) {
LocalDateTime now = LocalDateTime.now(ZONE);
String dateStr = now.format(DATE_FMT);
String timeStr = now.format(TIME_FMT);
@@ -91,6 +109,7 @@ public final class RuntimeContextInjector {
}
appendSenderBlockIfPresent(sb, origin);
+ appendModelLineIfPresent(sb, modelName, providerId, i18n);
return sb.toString();
}
@@ -121,6 +140,33 @@ public final class RuntimeContextInjector {
}
}
+ /**
+ * Append the agent's runtime model identity. Emitted for all origins
+ * (it's an agent fact, not a sender fact). Skipped when modelName is
+ * blank. Provider parenthetical is omitted when providerId is blank.
+ */
+ private static void appendModelLineIfPresent(StringBuilder sb, String modelName,
+ String providerId,
+ vip.mate.i18n.I18nService i18n) {
+ if (modelName == null || modelName.isBlank()) return;
+ String model = modelName.trim();
+ sb.append("\n");
+ if (i18n != null) {
+ sb.append(i18n.msg("context.model_identity", model));
+ } else {
+ sb.append("[system-context] Model: ").append(model);
+ }
+ if (providerId != null && !providerId.isBlank()) {
+ sb.append(" (provider: ").append(providerId.trim()).append(')');
+ }
+ sb.append("\n");
+ if (i18n != null) {
+ sb.append(i18n.msg("context.model_identity_hint"));
+ } else {
+ sb.append("If asked which model you are using, answer with this value for the current run.");
+ }
+ }
+
/**
* Append a sender / channel / chat block when the origin carries
* meaningful IM context. Format is intentionally one line per
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java
index c38412a8..e3b77655 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java
@@ -12,7 +12,9 @@ import vip.mate.channel.web.Utf8SseEmitter;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.model.AgentEntity;
+import vip.mate.agent.service.AgentGenerationService;
import vip.mate.agent.vo.AgentCapabilitiesVO;
+import vip.mate.agent.vo.AgentDraftVO;
import vip.mate.audit.service.AuditEventService;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelCapabilityService;
@@ -51,6 +53,7 @@ public class AgentController {
private final ModelConfigService modelConfigService;
private final ModelCapabilityService modelCapabilityService;
private final SystemSettingService systemSettingService;
+ private final AgentGenerationService agentGenerationService;
private final ObjectMapper objectMapper;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@@ -128,6 +131,16 @@ public class AgentController {
}
}
+ @Operation(summary = "根据一句话需求生成员工草稿(不落库)")
+ @PostMapping("/generate")
+ @RequireWorkspaceRole("member")
+ public R generate(
+ @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
+ @RequestBody GenerateRequest request) {
+ long wsId = workspaceId != null ? workspaceId : 1L;
+ return R.ok(agentGenerationService.generateDraft(request.getRequirement(), wsId));
+ }
+
@Operation(summary = "创建Agent")
@PostMapping
@RequireWorkspaceRole("member")
@@ -270,6 +283,11 @@ public class AgentController {
private String conversationId = "default";
}
+ @lombok.Data
+ public static class GenerateRequest {
+ private String requirement;
+ }
+
/**
* 校验目标资源实际归属的 workspace 与请求 header 一致。
* 防止 "在 workspace A 鉴权,操作 workspace B 资源" 的跨域攻击。
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
index cc3dd43c..b378c137 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java
@@ -350,25 +350,57 @@ public class NodeStreamingChatHelper {
// provider during a rate-limit window wastes time without recovery.
// SERVER_ERROR keeps MAX_RETRIES (upstream flaps often self-heal).
static final int MAX_RETRIES_RATE_LIMIT = 2;
- private static final long BACKOFF_BASE_MS = 3000;
- private static final long BACKOFF_CAP_MS = 60_000;
+ // EMPTY_RESPONSE: transient gateway blip often resolves on same-model
+ // retry (e.g., proxy timeout returns HTTP 200 with empty body). Keep
+ // the cap low — if it truly takes 4+ attempts, the provider is sick.
+ static final int MAX_RETRIES_EMPTY_RESPONSE = 3;
+ // UNKNOWN: conservative retry cap. Defensive: retry what we can't
+ // classify, but with a smaller budget than SERVER_ERROR (5 vs 10) to
+ // avoid masking truly fatal errors. MAX_TOTAL_DURATION_MS is the
+ // ultimate safety net.
+ static final int MAX_RETRIES_UNKNOWN = 5;
+ // Hard time budget for the primary retry loop (3 min). Prevents
+ // retries from stalling a single conversation turn indefinitely.
+ // Aligned with WikiProcessingService.llmMaxTotalDurationMs.
+ //
+ // Because the backoff grows exponentially (3s, 6s, 12s, 24s, 48s, then
+ // capped at 60s), this wall-clock budget — not MAX_RETRIES — is what
+ // actually bounds a sustained SERVER_ERROR loop: only ~8 of the 10
+ // retries fit inside 3 minutes before the elapsed-time check in
+ // streamCallInternal breaks to the fallback chain.
+ //
+ // These three values are instance fields seeded from the DEFAULT_*
+ // constants (rather than compile-time constants) so tests can shrink
+ // them to exercise the full retry path in milliseconds instead of
+ // minutes. Production wiring never overrides them — see
+ // setRetryTimingForTest.
+ private static final long DEFAULT_MAX_TOTAL_DURATION_MS = 3 * 60 * 1000L;
+ private static final long DEFAULT_BACKOFF_BASE_MS = 3000;
+ private static final long DEFAULT_BACKOFF_CAP_MS = 60_000;
- private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
+ private long maxTotalDurationMs = DEFAULT_MAX_TOTAL_DURATION_MS;
+ private long backoffBaseMs = DEFAULT_BACKOFF_BASE_MS;
+ private long backoffCapMs = DEFAULT_BACKOFF_CAP_MS;
/**
- * 判断错误是否可重试(基于状态码/异常类型)
+ * Test-only seam to shrink the retry backoff and total-time budget so the
+ * full {@link #MAX_RETRIES} path (or the time-budget cut-off) can be
+ * exercised in milliseconds instead of minutes. Package-private and never
+ * invoked from production wiring, which always keeps the {@code DEFAULT_*}
+ * timings.
+ *
+ * @param backoffBaseMs base backoff for the first retry (doubles each attempt)
+ * @param backoffCapMs per-attempt backoff ceiling
+ * @param maxTotalDurationMs hard wall-clock budget for the whole primary retry loop
*/
- private static boolean isRetryable(Throwable error) {
- String msg = extractFullErrorChain(error);
- // Kimi engine_overloaded / 标准 HTTP 错误 / 速率限制
- return msg.contains("engine_overloaded")
- || msg.contains("rate_limit") || msg.contains("RateLimitError")
- || msg.contains("429") || msg.contains("Too Many Requests")
- || msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
- || msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
- || msg.contains("Connection reset") || msg.contains("Connection refused");
+ void setRetryTimingForTest(long backoffBaseMs, long backoffCapMs, long maxTotalDurationMs) {
+ this.backoffBaseMs = backoffBaseMs;
+ this.backoffCapMs = backoffCapMs;
+ this.maxTotalDurationMs = maxTotalDurationMs;
}
+ private static final ObjectMapper TOOL_ARG_JSON_MAPPER = new ObjectMapper();
+
/**
* 分类错误类型(用于分级重试和上层 Node 决策)
*/
@@ -384,7 +416,27 @@ public class NodeStreamingChatHelper {
|| msg.contains("请求体中的 input tokens 总数超出了模型允许")) {
return ErrorType.PROMPT_TOO_LONG;
}
- // Auth errors
+ // Auth errors — keys, certs, DNS, TLS infrastructure. These will not
+ // self-heal on retry (a bad API key / expired cert / wrong host won't
+ // suddenly become valid), so classify as AUTH_ERROR to terminate the
+ // retry loop and hand off to the fallback chain.
+ // Infrastructure-level permanent failures checked first:
+ // DNS resolution (UnknownHostException) — misconfigured endpoint
+ // TLS certificate (CertificateException, SSLPeerUnverifiedException,
+ // pkix path building failed, certificate verify failed) — expired
+ // or untrusted certs that cannot recover without human intervention
+ if (msg.contains("UnknownHostException")
+ || msg.contains("CertificateException")
+ || msg.contains("SSLPeerUnverifiedException")
+ // Java's ValidatorException emits "PKIX path building failed" with an
+ // uppercase PKIX, and the error chain is not lower-cased — the pattern
+ // must match the real casing, otherwise the fatal cert failure falls
+ // through to the retryable SERVER_ERROR bucket and is retried in vain.
+ || msg.contains("PKIX path building failed")
+ || msg.contains("certificate verify failed")
+ || msg.contains("certificate_unknown")) {
+ return ErrorType.AUTH_ERROR;
+ }
if (msg.contains("401") || msg.contains("Unauthorized") || msg.contains("Invalid API Key")
|| msg.contains("authentication") || msg.contains("AuthenticationError")) {
return ErrorType.AUTH_ERROR;
@@ -404,11 +456,17 @@ public class NodeStreamingChatHelper {
// a different provider may have credits, so we should fall back instead of
// terminating the call. Both OpenAI ("insufficient_quota") and Anthropic
// ("credit balance is too low") use these phrases in 402-class responses.
+ // Chinese provider patterns (Zhipu 1113, DashScope, general) — same hard
+ // failure semantics: retrying the same provider won't refill the balance.
if (msg.contains("402") || msg.contains("insufficient_quota")
|| msg.contains("credit balance is too low")
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|| msg.contains("You exceeded your current quota")
- || msg.contains("quota exceeded") || msg.contains("Quota exceeded")) {
+ || msg.contains("quota exceeded") || msg.contains("Quota exceeded")
+ || msg.contains("余额不足") || msg.contains("请充值")
+ || msg.contains("\"code\":\"1113\"") || msg.contains("\"code\":1113")
+ || msg.contains("AccountBalanceNotEnough")
+ || msg.contains("balance not enough")) {
return ErrorType.BILLING;
}
// RFC-009 P3.2: MODEL_NOT_FOUND — provider rejects the requested model id.
@@ -436,19 +494,14 @@ public class NodeStreamingChatHelper {
|| msg.contains("InvalidEndpointOrModel")) {
return ErrorType.MODEL_NOT_FOUND;
}
- // Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable.
- // DashScope's remaining "InvalidParameter" responses are request-shape bugs, e.g. a reserved
- // or illegal tool name ("Tool names are not allowed to be [search]") or an unsupported
- // parameter. These fail identically on every provider, so classifying them as CLIENT_ERROR
- // (rather than MODEL_NOT_FOUND) keeps the model in the failover pool and surfaces the real
- // cause instead of a misleading "model not available" message.
- if (msg.contains("400") || msg.contains("Bad Request")
- || msg.contains("invalid_request_error") || msg.contains("unsupported")
- || msg.contains("Tool names are not allowed")
- || msg.contains("InvalidParameter")) {
- return ErrorType.CLIENT_ERROR;
- }
// Server errors and transient TLS / socket-level network hiccups.
+ // MUST be checked BEFORE CLIENT_ERROR. 5xx patterns (502/503/504) are
+ // transient gateway failures that self-heal on retry. If the error chain
+ // carries BOTH 5xx and 4xx-like keywords (a proxy 502 whose response body
+ // happens to say "bad request"), the 5xx is the root cause and should win
+ // — retrying a true 400 wastes seconds, but NOT retrying a transient 502
+ // loses the user's entire conversation turn. MAX_TOTAL_DURATION_MS
+ // provides the ultimate safety net against unbounded retry.
// Without the TLS-specific patterns, a single SSL fatal alert
// (e.g. bad_record_mac during long-running streams) falls through to
// UNKNOWN — non-retryable — so one transient handshake glitch surfaces
@@ -477,9 +530,27 @@ public class NodeStreamingChatHelper {
// in the response body when their backend is under high load or the
// upstream connection to the model server is disrupted. This is a
// transient server-side failure — classify as retryable.
- || msg.contains("network connection error")) {
+ || msg.contains("network connection error")
+ // AI gateway / reverse-proxy rewrites: upstream 5xx (502/503/504)
+ // surfaced as HTTP 400 with a body that describes the upstream
+ // outage. These are transient server-side failures — retryable.
+ || msg.contains("temporarily unavailable")
+ || msg.contains("service unavailable")
+ || msg.contains("model is overloaded")) {
return ErrorType.SERVER_ERROR;
}
+ // Client errors (400 Bad Request — unsupported format, invalid params, etc.) — NOT retryable.
+ // DashScope's remaining "InvalidParameter" responses are request-shape bugs, e.g. a reserved
+ // or illegal tool name ("Tool names are not allowed to be [search]") or an unsupported
+ // parameter. These fail identically on every provider, so classifying them as CLIENT_ERROR
+ // (rather than MODEL_NOT_FOUND) keeps the model in the failover pool and surfaces the real
+ // cause instead of a misleading "model not available" message.
+ if (msg.contains("400") || msg.contains("Bad Request")
+ || msg.contains("invalid_request_error") || msg.contains("unsupported")
+ || msg.contains("Tool names are not allowed")
+ || msg.contains("InvalidParameter")) {
+ return ErrorType.CLIENT_ERROR;
+ }
return ErrorType.UNKNOWN;
}
@@ -550,6 +621,15 @@ public class NodeStreamingChatHelper {
// 主模型重试循环
StreamResult lastResult = null;
if (!primarySkipped) for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ // Time budget check: prevent retries from stalling a single
+ // conversation turn indefinitely (e.g., a provider that stays
+ // at 503 for minutes). Aligned with Wiki's maxTotalDurationMs.
+ long elapsedMs = System.currentTimeMillis() - callStartMs;
+ if (elapsedMs >= maxTotalDurationMs) {
+ log.warn("[{}] Primary retry time budget exhausted ({}ms), handing off to fallback chain",
+ phase, elapsedMs);
+ break;
+ }
llmCallCount++;
if (attempt > 0) retryCount++;
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
@@ -601,11 +681,18 @@ public class NodeStreamingChatHelper {
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
return lastResult; // 已经重试过了
}
- // RFC-009: EMPTY_RESPONSE — break the primary-retry loop and fall through to
- // the fallback chain. Retrying the same model that returned nothing is rarely
- // productive; a different provider has a better chance of succeeding.
+ // EMPTY_RESPONSE — transient gateway blip often resolves on same-model
+ // retry (e.g., proxy timeout returns HTTP 200 with empty body).
+ // Retry up to MAX_RETRIES_EMPTY_RESPONSE before handing off to the
+ // fallback chain. A different provider has a better chance of
+ // succeeding if the same model repeatedly returns nothing.
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
- log.warn("[{}] Primary returned empty response — skipping same-model retries, handing off to fallback chain", phase);
+ if (attempt < MAX_RETRIES_EMPTY_RESPONSE) {
+ log.warn("[{}] Primary returned empty response (attempt {}/{}), retrying same model...",
+ phase, attempt + 1, MAX_RETRIES_EMPTY_RESPONSE + 1);
+ continue;
+ }
+ log.warn("[{}] Primary exhausted empty-response retries — handing off to fallback chain", phase);
recordPrimary(false);
break;
}
@@ -616,25 +703,33 @@ public class NodeStreamingChatHelper {
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
return lastResult;
}
- // RATE_LIMIT / SERVER_ERROR past their retry budget are provider-level
- // failures: the same model will not recover within this turn, but a
- // different provider can. Break to the fallback chain instead of
- // returning — recordPrimary(false) runs once at the post-loop provider
- // health check below, and if every fallback also fails the chain
- // walker re-surfaces this same error to the caller.
+ // RATE_LIMIT / SERVER_ERROR / UNKNOWN past their retry budget are
+ // provider-level failures: the same model will not recover within
+ // this turn, but a different provider can. Break to the fallback
+ // chain instead of returning — recordPrimary(false) runs once at
+ // the post-loop provider health check below, and if every fallback
+ // also fails the chain walker re-surfaces this same error to the
+ // caller.
+ // UNKNOWN errors are included defensively: an error we can't
+ // classify may be a transient (mis-classified by our keyword
+ // patterns) or a fatal (truly new error shape). Retrying with a
+ // smaller budget (MAX_RETRIES_UNKNOWN=5 vs MAX_RETRIES=10) is
+ // safer than immediate termination — MAX_TOTAL_DURATION_MS provides
+ // the ultimate safety net.
if (lastResult.errorType() == ErrorType.RATE_LIMIT
- || lastResult.errorType() == ErrorType.SERVER_ERROR) {
+ || lastResult.errorType() == ErrorType.SERVER_ERROR
+ || lastResult.errorType() == ErrorType.UNKNOWN) {
log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain",
phase, lastResult.errorType());
break;
}
- // Any other non-null errored result (e.g. UNKNOWN) that doStreamCall
- // chose NOT to retry must exit — otherwise we silently spin through
- // attempts and waste seconds per turn on unrecoverable errors like
- // DashScope's "url error" / unknown model.
+ // Any truly unhandled error type — safety net. Prefer falling back
+ // over terminating the entire call. If this branch is ever hit in
+ // production, the type should be added explicitly above.
recordPrimary(false);
- logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
- return lastResult;
+ log.warn("[{}] Primary returned unhandled error type={} — handing off to fallback chain",
+ phase, lastResult.errorType());
+ break;
}
// lastResult == null 表示需要重试
}
@@ -813,10 +908,10 @@ public class NodeStreamingChatHelper {
String conversationId, String phase,
boolean broadcast, int attempt) {
if (attempt > 0) {
- long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
+ long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
// 加入 jitter 防止雷群效应
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
- delay = Math.min(delay, BACKOFF_CAP_MS);
+ delay = Math.min(delay, backoffCapMs);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
phase, attempt, MAX_RETRIES, delay, conversationId);
// 广播给前端:用户可见的重试倒计时
@@ -1155,12 +1250,20 @@ public class NodeStreamingChatHelper {
conversationId, phase, errorType);
}
- // Rate limit / Server error: retryable, but with different budgets.
+ // Rate limit / Server error / Unknown: retryable, but with different budgets.
// RATE_LIMIT: cap at 2 retries then failover (RFC 06 D-2).
// SERVER_ERROR: keep full MAX_RETRIES — upstream flaps often self-heal.
- if (errorType == ErrorType.RATE_LIMIT || errorType == ErrorType.SERVER_ERROR) {
- int effectiveMaxRetries = (errorType == ErrorType.RATE_LIMIT)
- ? MAX_RETRIES_RATE_LIMIT : MAX_RETRIES;
+ // UNKNOWN: conservative cap (5 vs 10). Defensive: retry what we can't
+ // classify, but with a smaller budget to avoid masking truly fatal
+ // errors. MAX_TOTAL_DURATION_MS provides the ultimate safety net.
+ if (errorType == ErrorType.RATE_LIMIT
+ || errorType == ErrorType.SERVER_ERROR
+ || errorType == ErrorType.UNKNOWN) {
+ int effectiveMaxRetries = switch (errorType) {
+ case RATE_LIMIT -> MAX_RETRIES_RATE_LIMIT;
+ case UNKNOWN -> MAX_RETRIES_UNKNOWN;
+ default -> MAX_RETRIES;
+ };
if (attempt < effectiveMaxRetries) {
log.warn("[{}] Retryable error (attempt {}/{}, type={}): {}",
phase, attempt, effectiveMaxRetries, errorType, error.getMessage());
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
index f832af01..71363c40 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java
@@ -184,6 +184,44 @@ public class ToolExecutionExecutor {
return StructuredTruncator.truncate(result, headLen, tailLen, marker);
}
+ /**
+ * Appended to the cross-platform shopping recommendation tool's result so the
+ * model reliably renders products as chat cards instead of a markdown table.
+ * The tool's MCP schema is globally callable, so a model can invoke it without
+ * ever loading the skill's instructions — this puts the rendering contract
+ * where the model always sees it: in the tool result itself.
+ */
+ private static final String PRODUCT_CARD_RENDER_DIRECTIVE =
+ "\n\n[Rendering directive] Render these recommendations for the user as a"
+ + " fenced code block whose language tag is exactly `product-cards`, containing a"
+ + " JSON array. Each element: {name, url, imageUrl, price, originalPrice, lowestPrice,"
+ + " platformLabel, shopName, purchaseAdvice}. Copy `url` and `imageUrl` verbatim from"
+ + " the result above (never invent or alter them). The chat UI turns this block into"
+ + " clickable product cards with a buy button. Do NOT use a markdown table or inline"
+ + " image markdown for these products. You may add a short intro sentence and purchase"
+ + " tips around the block.";
+
+ /**
+ * Returns {@code true} when the tool is the cross-platform shopping
+ * recommendation tool and its result actually carries product records
+ * (so timeouts / empty results fall through to the model's own fallback).
+ */
+ static boolean shouldAppendProductCardDirective(String toolName, String result) {
+ if (toolName == null || result == null) return false;
+ if (!toolName.contains("ckjia_shopping_recom")) return false;
+ return result.contains("recommendations")
+ || result.contains("imageUrl")
+ || result.contains("priceTag")
+ || result.contains("markdownLink");
+ }
+
+ /** Appends {@link #PRODUCT_CARD_RENDER_DIRECTIVE} when applicable, else returns the result unchanged. */
+ static String withProductCardDirective(String toolName, String result) {
+ return shouldAppendProductCardDirective(toolName, result)
+ ? result + PRODUCT_CARD_RENDER_DIRECTIVE
+ : result;
+ }
+
private final Map toolCallbackMap;
/**
* Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function`
@@ -675,8 +713,10 @@ public class ToolExecutionExecutor {
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
+ // Append the card-rendering directive to the LLM-facing response only,
+ // leaving the broadcast tool-result panel unchanged.
return new ToolResponseMessage.ToolResponse(
- toolCall.id(), toolName, result != null ? result : "");
+ toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
} catch (Exception e) {
log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage());
String safeError = isReturnDirect(callback)
@@ -905,8 +945,10 @@ public class ToolExecutionExecutor {
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data());
streamTracker.updateRunningTool(pc.conversationId, null);
}
+ // Append the card-rendering directive to the LLM-facing response only,
+ // leaving the broadcast tool-result panel unchanged.
return new ToolResponseMessage.ToolResponse(
- pc.toolCall.id(), toolName, result != null ? result : "");
+ pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
} catch (Exception e) {
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
// RFC-052: for returnDirect tools, even the error message is
@@ -946,7 +988,10 @@ public class ToolExecutionExecutor {
ToolInvocationContext guardCtx = ToolInvocationContext.of(
toolName, java.util.Map.of(), arguments,
conversationId, agentId,
- /*channelType*/ null, requesterId, workspaceId);
+ /*channelType*/ null, requesterId, workspaceId)
+ // Carry the active workspace base path so a guardian can enforce
+ // the filesystem boundary before approval (issue #313).
+ .withWorkspaceBasePath(origin != null ? origin.workspaceBasePath() : null);
if (toolGuardService != null) {
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java
index 6792e0a6..7ddec09d 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java
@@ -8,6 +8,7 @@ import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.FinishReason;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
+import vip.mate.common.text.MarkdownNormalizer;
import vip.mate.tool.document.GeneratedFileCache;
import java.util.List;
@@ -39,12 +40,25 @@ public class FinalAnswerNode implements NodeAction {
*/
private final GeneratedFileCache generatedFileCache;
+ /**
+ * Kill-switch for the deterministic Markdown cleanup applied to the answer
+ * body. {@code true} (default) runs {@link MarkdownNormalizer}; set to
+ * {@code false} to surface model output verbatim if a normalization edge
+ * case ever mangles a legitimate answer in production.
+ */
+ private final boolean markdownNormalizeEnabled;
+
public FinalAnswerNode() {
- this(null);
+ this(null, true);
}
public FinalAnswerNode(GeneratedFileCache generatedFileCache) {
+ this(generatedFileCache, true);
+ }
+
+ public FinalAnswerNode(GeneratedFileCache generatedFileCache, boolean markdownNormalizeEnabled) {
this.generatedFileCache = generatedFileCache;
+ this.markdownNormalizeEnabled = markdownNormalizeEnabled;
}
@Override
@@ -161,6 +175,7 @@ public class FinalAnswerNode implements NodeAction {
// validation so the validator sees the user-visible warning rather
// than treating the fake link as a "reference".
finalAnswer = scrubFakeUrls(finalAnswer);
+ finalAnswer = accessor.sourceEvidenceLedger().appendWikiSourceTable(finalAnswer);
SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(finalAnswer);
if (finishReason == FinishReason.NORMAL && !validation.valid()) {
@@ -170,6 +185,17 @@ public class FinalAnswerNode implements NodeAction {
validation.unsupportedReferences());
}
+ // Deterministic Markdown cleanup on the model-generated answer body. LLMs
+ // routinely emit malformed Markdown (missing heading spaces, glued `---`,
+ // unaligned table pipes) that prompt rules fail to prevent; this fixes the
+ // mechanical defects before the answer is persisted / sent to channels.
+ // Verbatim tool output (RETURN_DIRECT) and approval-wait paths return early
+ // above and are intentionally left untouched. Gated so operators can turn
+ // the rewrite off (mate.agent.markdown-normalize-enabled=false).
+ if (markdownNormalizeEnabled) {
+ finalAnswer = MarkdownNormalizer.normalize(finalAnswer);
+ }
+
// Build the event list. Always carries the finish_reason event so
// downstream consumers (memory gate, channel accumulator, message
// metadata persistence) see a machine-readable status. When the
@@ -211,9 +237,9 @@ public class FinalAnswerNode implements NodeAction {
}
private static String appendEvidenceWarning(String answer, List unsupportedReferences) {
- return answer + "\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:"
+ return answer + "\n\n[证据不足] 以下引用未出现在本轮已读取/搜索到的工具证据中,或缺少有效来源标注:"
+ String.join(", ", unsupportedReferences)
- + "。请继续读取相关文件后再下结论。";
+ + "。请继续检索/读取相关证据后再下结论。";
}
/**
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java
index 2f98eb9c..042b35fc 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java
@@ -78,7 +78,12 @@ public class GoalEvaluationNode implements NodeAction {
MateClawStateAccessor accessor = new MateClawStateAccessor(state);
- Optional