+ onUpdaterState: (callback: (state: UpdaterState) => void) => () => void
+}
+
+interface Window {
+ mateClawAPI: MateClawAPI
+}
diff --git a/mateclaw-desktop/src/main.ts b/mateclaw-desktop/src/main.ts
new file mode 100644
index 00000000..01433bca
--- /dev/null
+++ b/mateclaw-desktop/src/main.ts
@@ -0,0 +1,4 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+
+createApp(App).mount('#app')
diff --git a/mateclaw-desktop/tsconfig.json b/mateclaw-desktop/tsconfig.json
new file mode 100644
index 00000000..8c753115
--- /dev/null
+++ b/mateclaw-desktop/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "resolveJsonModule": true,
+ "noFallthroughCasesInSwitch": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "electron/**/*.ts"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/mateclaw-desktop/tsconfig.node.json b/mateclaw-desktop/tsconfig.node.json
new file mode 100644
index 00000000..faab2271
--- /dev/null
+++ b/mateclaw-desktop/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "composite": true,
+ "skipLibCheck": true,
+ "noEmit": false
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/mateclaw-desktop/tsconfig.node.tsbuildinfo b/mateclaw-desktop/tsconfig.node.tsbuildinfo
new file mode 100644
index 00000000..188364d4
--- /dev/null
+++ b/mateclaw-desktop/tsconfig.node.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2020.full.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/modulerunnertransport.d-dj_me5sf.d.ts","./node_modules/vite/dist/node/module-runner.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","../../../../node_modules/lightningcss/node/ast.d.ts","../../../../node_modules/lightningcss/node/targets.d.ts","../../../../node_modules/lightningcss/node/index.d.ts","./node_modules/vite/types/internal/lightningcssoptions.d.ts","./node_modules/vite/types/internal/csspreprocessoroptions.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@vue/shared/dist/shared.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@vue/compiler-core/dist/compiler-core.d.ts","./node_modules/magic-string/dist/magic-string.es.d.mts","./node_modules/typescript/lib/typescript.d.ts","./node_modules/@vue/compiler-sfc/dist/compiler-sfc.d.ts","./node_modules/vue/compiler-sfc/index.d.mts","./node_modules/@vitejs/plugin-vue/dist/index.d.mts","./node_modules/vite-plugin-electron/dist/utils.d.ts","./node_modules/vite-plugin-electron/dist/index.d.ts","./node_modules/vite-plugin-electron-renderer/dist/index.d.ts","./vite.config.ts","./node_modules/keyv/src/index.d.ts","./node_modules/@types/http-cache-semantics/index.d.ts","./node_modules/@types/responselike/index.d.ts","./node_modules/@types/cacheable-request/index.d.ts","./node_modules/@types/ms/index.d.ts","./node_modules/@types/debug/index.d.ts","./node_modules/@types/fs-extra/index.d.ts","./node_modules/@types/keyv/index.d.ts","./node_modules/xmlbuilder/typings/index.d.ts","./node_modules/@types/plist/index.d.ts","./node_modules/@types/verror/index.d.ts","./node_modules/@types/yauzl/index.d.ts"],"fileIdsList":[[57,104,190],[57,104],[57,104,115,118,145,152,203,204,205],[57,104,207],[57,104,116,152],[57,104,115,152],[57,101,104],[57,103,104],[104],[57,104,109,137],[57,104,105,110,115,123,134,145],[57,104,105,106,115,123],[52,53,54,57,104],[57,104,107,146],[57,104,108,109,116,124],[57,104,109,134,142],[57,104,110,112,115,123],[57,103,104,111],[57,104,112,113],[57,104,114,115],[57,103,104,115],[57,104,115,116,117,134,145],[57,104,115,116,117,130,134,137],[57,104,112,115,118,123,134,145],[57,104,115,116,118,119,123,134,142,145],[57,104,118,120,134,142,145],[55,56,57,58,59,60,61,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],[57,104,115,121],[57,104,122,145,150],[57,104,112,115,123,134],[57,104,124],[57,104,125],[57,103,104,126],[57,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],[57,104,128],[57,104,129],[57,104,115,130,131],[57,104,130,132,146,148],[57,104,115,134,135,137],[57,104,136,137],[57,104,134,135],[57,104,137],[57,104,138],[57,101,104,134,139],[57,104,115,140,141],[57,104,140,141],[57,104,109,123,134,142],[57,104,143],[57,104,123,144],[57,104,118,129,145],[57,104,109,146],[57,104,134,147],[57,104,122,148],[57,104,149],[57,99,104],[57,99,104,115,117,126,134,137,145,148,150],[57,104,134,151],[57,104,152,211],[57,104,118,134,152],[57,104,115,134,152],[57,104,189,197],[57,104,190,191,192],[57,104,181,190,192,193,194,195],[57,104,115],[57,104,177],[57,104,175,177],[57,104,166,174,175,176,178,180],[57,104,164],[57,104,167,172,177,180],[57,104,163,180],[57,104,167,168,171,172,173,180],[57,104,167,168,169,171,172,180],[57,104,164,165,166,167,168,172,173,174,176,177,178,180],[57,104,180],[57,104,162,164,165,166,167,168,169,171,172,173,174,175,176,177,178,179],[57,104,162,180],[57,104,167,169,170,172,173,180],[57,104,171,180],[57,104,172,173,177,180],[57,104,165,175],[57,104,154,188,189],[57,104,153,154],[57,71,75,104,145],[57,71,104,134,145],[57,66,104],[57,68,71,104,142,145],[57,104,123,142],[57,104,152],[57,66,104,152],[57,68,71,104,123,145],[57,63,64,67,70,104,115,134,145],[57,71,78,104],[57,63,69,104],[57,71,92,93,104],[57,67,71,104,137,145,152],[57,92,104,152],[57,65,66,104,152],[57,71,104],[57,65,66,67,68,69,70,71,72,73,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,93,94,95,96,97,98,104],[57,71,86,104],[57,71,78,79,104],[57,69,71,79,80,104],[57,70,104],[57,63,66,71,104],[57,71,75,79,80,104],[57,75,104],[57,69,71,74,104,145],[57,63,68,71,78,104],[57,104,134],[57,66,71,92,104,150,152],[57,104,161,189],[57,104,105,152,154,188,189,199],[57,104,189,200],[57,104,115,116,118,119,120,123,134,142,145,151,152,154,155,156,157,159,160,161,181,185,186,187,188,189],[57,104,156,157,158,159],[57,104,156],[57,104,157],[57,104,184],[57,104,154,189],[57,104,196],[57,104,125,189,198,200,201],[57,104,182,183]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7a3c8b952931daebdfc7a2897c53c0a1c73624593fa070e46bd537e64dcd20a","affectsGlobalScope":true,"impliedFormat":1},{"version":"80e18897e5884b6723488d4f5652167e7bb5024f946743134ecc4aa4ee731f89","affectsGlobalScope":true,"impliedFormat":1},{"version":"cd034f499c6cdca722b60c04b5b1b78e058487a7085a8e0d6fb50809947ee573","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"1305d1e76ca44e30fb8b2b8075fa522b83f60c0bcf5d4326a9d2cf79b53724f8","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"714435130b9015fae551788df2a88038471a5a11eb471f27c4ede86552842bc9","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"27fdb0da0daf3b337c5530c5f266efe046a6ceb606e395b346974e4360c36419","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"47ab634529c5955b6ad793474ae188fce3e6163e3a3fb5edd7e0e48f14435333","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"fad4e3c207fe23922d0b2d06b01acbfb9714c4f2685cf80fd384c8a100c82fd0","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"a7ca8df4f2931bef2aa4118078584d84a0b16539598eaadf7dce9104dfaa381c","impliedFormat":1},{"version":"11443a1dcfaaa404c68d53368b5b818712b95dd19f188cab1669c39bee8b84b3","impliedFormat":1},{"version":"36977c14a7f7bfc8c0426ae4343875689949fb699f3f84ecbe5b300ebf9a2c55","impliedFormat":1},{"version":"035d0934d304483f07148427a5bd5b98ac265dae914a6b49749fe23fbd893ec7","impliedFormat":99},{"version":"e2ed5b81cbed3a511b21a18ab2539e79ac1f4bc1d1d28f8d35d8104caa3b429f","impliedFormat":99},{"version":"161c8e0690c46021506e32fda85956d785b70f309ae97011fd27374c065cac9b","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"7965dc3c7648e2a7a586d11781cabb43d4859920716bc2fdc523da912b06570d","impliedFormat":1},{"version":"90c2bd9a3e72fe08b8fa5982e78cb8dc855a1157b26e11e37a793283c52bf64b","impliedFormat":1},{"version":"a8122fe390a2a987079e06c573b1471296114677923c1c094c24a53ddd7344a2","impliedFormat":1},{"version":"70c2cb19c0c42061a39351156653aa0cf5ba1ecdc8a07424dd38e3a1f1e3c7f4","impliedFormat":1},{"version":"a8fb10fd8c7bc7d9b8f546d4d186d1027f8a9002a639bec689b5000dab68e35c","impliedFormat":1},{"version":"c9b467ea59b86bd27714a879b9ad43c16f186012a26d0f7110b1322025ceaa83","impliedFormat":1},{"version":"57ea19c2e6ba094d8087c721bac30ff1c681081dbd8b167ac068590ef633e7a5","impliedFormat":1},{"version":"cba81ec9ae7bc31a4dc56f33c054131e037649d6b9a2cfa245124c67e23e4721","impliedFormat":1},{"version":"ad193f61ba708e01218496f093c23626aa3808c296844a99189be7108a9c8343","impliedFormat":1},{"version":"a0544b3c8b70b2f319a99ea380b55ab5394ede9188cdee452a5d0ce264f258b2","impliedFormat":1},{"version":"8c654c17c334c7c168c1c36e5336896dc2c892de940886c1639bebd9fc7b9be4","impliedFormat":1},{"version":"6a4da742485d5c2eb6bcb322ae96993999ffecbd5660b0219a5f5678d8225bb0","impliedFormat":1},{"version":"c65ca21d7002bdb431f9ab3c7a6e765a489aa5196e7e0ef00aed55b1294df599","impliedFormat":1},{"version":"c8fc655c2c4bafc155ceee01c84ab3d6c03192ced5d3f2de82e20f3d1bd7f9fa","impliedFormat":1},{"version":"be5a7ff3b47f7e553565e9483bdcadb0ca2040ac9e5ec7b81c7e115a81059882","impliedFormat":1},{"version":"1a93f36ecdb60a95e3a3621b561763e2952da81962fae217ab5441ac1d77ffc5","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"f7eebe1b25040d805aefe8971310b805cd49b8602ec206d25b38dc48c542f165","impliedFormat":1},{"version":"a18642ddf216f162052a16cba0944892c4c4c977d3306a87cb673d46abbb0cbf","impliedFormat":1},{"version":"509f8efdfc5f9f6b52284170e8d7413552f02d79518d1db691ee15acc0088676","impliedFormat":1},{"version":"4ec16d7a4e366c06a4573d299e15fe6207fc080f41beac5da06f4af33ea9761e","impliedFormat":1},{"version":"7870becb94cbc11d2d01b77c4422589adcba4d8e59f726246d40cd0d129784d8","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"f70b8328a15ca1d10b1436b691e134a49bc30dcf3183a69bfaa7ba77e1b78ecd","impliedFormat":1},{"version":"683b035f752e318d02e303894e767a1ac16ac4493baa2b593195d7976e6b7310","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"f468b74459f1ad4473b36a36d49f2b255f3c6b5d536c81239c2b2971df089eaf","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"524a409ad72186b7f6cb16898c349465cfa876f641d6cb6137b3123d5cfca619","impliedFormat":1},{"version":"2be2227c3810dfd84e46674fd33b8d09a4a28ad9cb633ed536effd411665ea1e","impliedFormat":99},{"version":"e134052a6b1ded61693b4037f615dc72f14e2881e79c1ddbff6c514c8a516b05","impliedFormat":1},{"version":"957a44f864ab3c182edc747428e8eec1765257deee7fac86c1147eeac897d832","impliedFormat":1},{"version":"3feec212c0aeb91e5a6e62caaf9f128954590210f8c302910ea377c088f6b61a","impliedFormat":99},{"version":"bbdfaf7d9b20534c5df1e1b937a20f17ca049d603a2afe072983bf7aff2279f5","impliedFormat":99},{"version":"0890467498b67e20cec24aaa50bebc232dc4f588a982e60dc6bb07b6e797da52","impliedFormat":1},{"version":"928e3aa1a5dab12a194d90c71959d1251917515554f45793f98d06ab731f3fbf","impliedFormat":1},{"version":"c400678110f688feba4d6d3f93269fca02834bb3d6a1ecd99b28b3f69f7d23f4","impliedFormat":1},"3fbb27229bdcfcb4241a0742f967fb5eb7e753bd8ee74d0fa5a8dc90fcb7b20f",{"version":"42baf4ca38c38deaf411ea73f37bc39ff56c6e5c761a968b64ac1b25c92b5cd8","impliedFormat":1},{"version":"4f6ae308c5f2901f2988c817e1511520619e9025b9b12cc7cce2ab2e6ffed78a","impliedFormat":1},{"version":"8718fa41d7cf4aa91de4e8f164c90f88e0bf343aa92a1b9b725a9c675c64e16b","impliedFormat":1},{"version":"f992cd6cc0bcbaa4e6c810468c90f2d8595f8c6c3cf050c806397d3de8585562","impliedFormat":1},{"version":"fb893a0dfc3c9fb0f9ca93d0648694dd95f33cbad2c0f2c629f842981dfd4e2e","impliedFormat":1},{"version":"89e326922cadcc2331d7e851011cf9f0456a681aaf3c95b48b81f8d80e8cdfba","impliedFormat":1},{"version":"ed19da84b7dbf00952ad0b98ce5c194f1903bcf7c94d8103e8e0d63b271543ae","impliedFormat":1},{"version":"fec943fdb3275eb6e006b35e04a8e2e99e9adf3f4b969ddf15315ac7575a93e4","impliedFormat":1},{"version":"10a60d0cc51552184ceb31c27ef547eb365b918b0927b155176be3c3d5cba82c","impliedFormat":1},{"version":"1a86aff0e5cf0da881c826ada253aa5256ff0d53c2123c62cd7851559eaee9b9","impliedFormat":1},{"version":"62ba45a86b9a31eb84ea03ae0b9e800a507d980c1f38dcec6528f10078cfdedd","impliedFormat":1},{"version":"74d5a87c3616cd5d8691059d531504403aa857e09cbaecb1c64dfb9ace0db185","impliedFormat":1}],"root":[202],"options":{"allowImportingTsExtensions":true,"composite":true,"module":99,"skipLibCheck":true,"target":7},"referencedMap":[[192,1],[190,2],[206,3],[208,4],[153,2],[209,5],[204,2],[210,6],[207,2],[101,7],[102,7],[103,8],[57,9],[104,10],[105,11],[106,12],[52,2],[55,13],[53,2],[54,2],[107,14],[108,15],[109,16],[110,17],[111,18],[112,19],[113,19],[114,20],[115,21],[116,22],[117,23],[58,2],[56,2],[118,24],[119,25],[120,26],[152,27],[121,28],[122,29],[123,30],[124,31],[125,32],[126,33],[127,34],[128,35],[129,36],[130,37],[131,37],[132,38],[133,2],[134,39],[136,40],[135,41],[137,42],[138,43],[139,44],[140,45],[141,46],[142,47],[143,48],[144,49],[145,50],[146,51],[147,52],[148,53],[149,54],[59,2],[60,2],[61,2],[100,55],[150,56],[151,57],[212,58],[205,59],[213,2],[214,60],[198,61],[193,62],[196,63],[191,2],[62,2],[161,2],[203,64],[194,2],[178,65],[176,66],[177,67],[165,68],[166,66],[173,69],[164,70],[169,71],[179,2],[170,72],[175,73],[181,74],[180,75],[163,76],[171,77],[172,78],[167,79],[174,65],[168,80],[155,81],[154,82],[162,2],[49,2],[50,2],[10,2],[8,2],[9,2],[14,2],[13,2],[2,2],[15,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[3,2],[23,2],[24,2],[4,2],[25,2],[29,2],[26,2],[27,2],[28,2],[30,2],[31,2],[32,2],[5,2],[33,2],[34,2],[35,2],[36,2],[6,2],[40,2],[37,2],[38,2],[39,2],[41,2],[7,2],[42,2],[51,2],[47,2],[48,2],[43,2],[44,2],[45,2],[46,2],[1,2],[12,2],[11,2],[195,2],[78,83],[88,84],[77,83],[98,85],[69,86],[68,87],[97,88],[91,89],[96,90],[71,91],[85,92],[70,93],[94,94],[66,95],[65,88],[95,96],[67,97],[72,98],[73,2],[76,98],[63,2],[99,99],[89,100],[80,101],[81,102],[83,103],[79,104],[82,105],[92,88],[74,106],[75,107],[84,108],[64,109],[87,100],[86,98],[90,2],[93,110],[201,111],[200,112],[199,113],[189,114],[160,115],[159,116],[157,116],[156,2],[158,117],[187,2],[186,2],[185,118],[188,119],[197,120],[211,109],[202,121],[182,2],[184,122],[183,2]],"affectedFilesPendingEmit":[[202,17]],"emitSignatures":[202],"version":"5.9.3"}
\ No newline at end of file
diff --git a/mateclaw-desktop/vite.config.ts b/mateclaw-desktop/vite.config.ts
new file mode 100644
index 00000000..5dd690f7
--- /dev/null
+++ b/mateclaw-desktop/vite.config.ts
@@ -0,0 +1,72 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import electron from 'vite-plugin-electron'
+import renderer from 'vite-plugin-electron-renderer'
+import { resolve } from 'path'
+import { brandingPlugin } from './scripts/branding.cjs'
+
+export default defineConfig(({ command }) => {
+ const isServe = command === 'serve'
+ const isBuild = command === 'build'
+
+ // Shared branding plugin instance — applied to the renderer build as well
+ // as the electron main/preload builds so brand strings are replaced
+ // everywhere without touching source code.
+ const brand = brandingPlugin()
+
+ return {
+ plugins: [
+ vue(),
+ // White-label branding: replaces "MateClaw" with the configured brand
+ // name at build time. Source code stays untouched. Configure via
+ // branding.config.json or BRAND_* env vars.
+ brand,
+ electron([
+ {
+ entry: 'electron/main/index.ts',
+ onstart(args) {
+ args.startup()
+ },
+ vite: {
+ plugins: [brand],
+ build: {
+ sourcemap: isServe,
+ minify: isBuild,
+ outDir: 'dist-electron/main',
+ rollupOptions: {
+ external: ['electron', 'electron-updater'],
+ },
+ },
+ },
+ },
+ {
+ entry: 'electron/preload/index.ts',
+ onstart(args) {
+ args.reload()
+ },
+ vite: {
+ plugins: [brand],
+ build: {
+ sourcemap: isServe ? 'inline' : undefined,
+ minify: isBuild,
+ outDir: 'dist-electron/preload',
+ rollupOptions: {
+ external: ['electron'],
+ },
+ },
+ },
+ },
+ ]),
+ renderer(),
+ ],
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+ }
+})
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java
index 5eb50c5e..58f5f762 100644
--- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginContext.java
@@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.plugin.api.channel.PluginChannelAdapter;
import vip.mate.plugin.api.memory.PluginMemoryProvider;
+import vip.mate.plugin.api.search.PluginSearchProvider;
import java.util.function.Supplier;
@@ -60,6 +61,19 @@ public interface PluginContext {
*/
void registerMemoryProvider(PluginMemoryProvider provider);
+ /**
+ * Register a web-search provider that joins the platform's search provider
+ * chain used by the {@code web_search} tool.
+ *
+ * The provider id must be globally unique — registration fails with a
+ * {@link PluginException} if it clashes with a built-in provider
+ * (serper / tavily / searxng / duckduckgo) or another plugin's provider.
+ *
+ * @param provider the search provider
+ * @throws PluginException if the id is blank or already taken
+ */
+ void registerSearchProvider(PluginSearchProvider provider);
+
/**
* Read a configuration value from the plugin's config.
*
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java
index e6287ec1..e2de252e 100644
--- a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/PluginType.java
@@ -17,5 +17,8 @@ public enum PluginType {
CHANNEL,
/** Register new memory providers */
- MEMORY
+ MEMORY,
+
+ /** Register new web-search providers for the web_search tool */
+ SEARCH
}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java
new file mode 100644
index 00000000..ba67e2b2
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchProvider.java
@@ -0,0 +1,53 @@
+package vip.mate.plugin.api.search;
+
+import java.util.List;
+
+/**
+ * SPI for plugin-provided web-search providers.
+ *
+ * Implementations are registered via {@code PluginContext#registerSearchProvider}
+ * and appear in the platform's search provider chain alongside the built-in
+ * providers (serper / tavily / searxng / duckduckgo).
+ *
+ * Configuration (API keys, base URLs, ...) is NOT passed in — plugins read their
+ * own config declared in {@code mateclaw-plugin.json} via
+ * {@code PluginContext#getConfig(String, Class)}.
+ *
+ * @author MateClaw Team
+ */
+public interface PluginSearchProvider {
+
+ /** Globally unique provider id, e.g. "my-search". Must not clash with built-in ids. */
+ String id();
+
+ /** Human-readable display name. */
+ String label();
+
+ /** Whether this provider needs a credential (affects auto-detect priority). */
+ default boolean requiresCredential() {
+ return true;
+ }
+
+ /**
+ * Auto-detect ordering (ascending). Built-in providers occupy 50-400;
+ * plugin providers default to 500 (after built-ins) but may override.
+ */
+ default int autoDetectOrder() {
+ return 500;
+ }
+
+ /**
+ * Whether the provider is currently usable — typically: required config present.
+ * Called on every provider resolution; keep it cheap (no network I/O).
+ */
+ boolean isAvailable();
+
+ /**
+ * Execute the search.
+ *
+ * @param query the query (never null)
+ * @return results; empty list if nothing found. Must not return null.
+ * Throw on failure — the platform falls back to the next provider.
+ */
+ List search(PluginSearchQuery query);
+}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java
new file mode 100644
index 00000000..68a84fd0
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchQuery.java
@@ -0,0 +1,22 @@
+package vip.mate.plugin.api.search;
+
+/**
+ * Search query passed from the platform to a plugin search provider.
+ *
+ * Self-contained SDK type — must not depend on any mateclaw-server class,
+ * because plugin JARs are compiled only against mateclaw-plugin-api.
+ *
+ * @param query search keywords (never null/blank)
+ * @param freshness time-range filter: day / week / month / year (nullable)
+ * @param language language preference, e.g. zh-CN / en (nullable)
+ * @param count max results 1-10, already clamped by the platform (never null)
+ *
+ * @author MateClaw Team
+ */
+public record PluginSearchQuery(
+ String query,
+ String freshness,
+ String language,
+ Integer count
+) {
+}
diff --git a/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java
new file mode 100644
index 00000000..b10749d3
--- /dev/null
+++ b/mateclaw-plugin-api/src/main/java/vip/mate/plugin/api/search/PluginSearchResult.java
@@ -0,0 +1,24 @@
+package vip.mate.plugin.api.search;
+
+/**
+ * A single search result returned by a plugin search provider.
+ *
+ * Self-contained SDK type — mirrors the platform's internal SearchResult
+ * (title/url/snippet/source/date) without depending on server classes.
+ *
+ * @param title result title
+ * @param url result link
+ * @param snippet short excerpt
+ * @param source source domain, e.g. "reuters.com" (nullable)
+ * @param date published date as raw string (nullable)
+ *
+ * @author MateClaw Team
+ */
+public record PluginSearchResult(
+ String title,
+ String url,
+ String snippet,
+ String source,
+ String date
+) {
+}
diff --git a/mateclaw-plugin-search-sample/pom.xml b/mateclaw-plugin-search-sample/pom.xml
new file mode 100644
index 00000000..6959beee
--- /dev/null
+++ b/mateclaw-plugin-search-sample/pom.xml
@@ -0,0 +1,50 @@
+
+
+ 4.0.0
+
+
+ vip.mate
+ mateclaw
+ ${revision}
+ ../pom.xml
+
+
+ mateclaw-plugin-search-sample
+ jar
+
+ MateClaw Search Provider Sample Plugin
+ Sample plugin registering a custom web-search provider via the MateClaw Plugin SDK
+
+
+
+
+ vip.mate
+ mateclaw-plugin-api
+ provided
+
+
+
+
+ org.springframework.ai
+ spring-ai-model
+ provided
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ provided
+
+
+
+
+ org.slf4j
+ slf4j-api
+ provided
+
+
+
diff --git a/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java b/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java
new file mode 100644
index 00000000..306e4918
--- /dev/null
+++ b/mateclaw-plugin-search-sample/src/main/java/vip/mate/plugin/sample/search/SimpleSearchPlugin.java
@@ -0,0 +1,124 @@
+package vip.mate.plugin.sample.search;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import vip.mate.plugin.api.MateClawPlugin;
+import vip.mate.plugin.api.PluginContext;
+import vip.mate.plugin.api.search.PluginSearchProvider;
+import vip.mate.plugin.api.search.PluginSearchQuery;
+import vip.mate.plugin.api.search.PluginSearchResult;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Sample plugin demonstrating {@code PluginType.SEARCH}: registers a search
+ * provider that queries a configurable JSON endpoint. Expected response shape:
+ * {@code {"results":[{"title":"...","url":"...","snippet":"..."}]}}
+ *
+ * @author MateClaw Team
+ */
+public class SimpleSearchPlugin implements MateClawPlugin {
+
+ private Logger log;
+
+ @Override
+ public void onLoad(PluginContext context) {
+ this.log = context.getLogger();
+ context.registerSearchProvider(new DemoSearchProvider(context));
+ log.info("SimpleSearchPlugin loaded, search provider registered");
+ }
+
+ @Override
+ public void onEnable() {
+ if (log != null) log.info("SimpleSearchPlugin enabled");
+ }
+
+ @Override
+ public void onDisable() {
+ if (log != null) log.info("SimpleSearchPlugin disabled");
+ }
+
+ static class DemoSearchProvider implements PluginSearchProvider {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(15);
+
+ private final PluginContext context;
+ private final HttpClient http = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ DemoSearchProvider(PluginContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public String id() {
+ return "demo-search";
+ }
+
+ @Override
+ public String label() {
+ return "Demo Search";
+ }
+
+ @Override
+ public boolean isAvailable() {
+ String baseUrl = context.getConfig("baseUrl", String.class);
+ return baseUrl != null && !baseUrl.isBlank();
+ }
+
+ @Override
+ public List search(PluginSearchQuery query) {
+ String baseUrl = context.getConfig("baseUrl", String.class);
+ String apiKey = context.getConfig("apiKey", String.class);
+
+ // Minimal demo: only q/count are wired. query.freshness() and query.language()
+ // are also available — see the built-in SearXNGSearchProvider for how to map them.
+ String url = baseUrl + (baseUrl.contains("?") ? "&" : "?")
+ + "q=" + URLEncoder.encode(query.query(), StandardCharsets.UTF_8)
+ + "&count=" + query.count();
+
+ HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
+ .timeout(TIMEOUT)
+ .GET();
+ if (apiKey != null && !apiKey.isBlank()) {
+ req.header("Authorization", "Bearer " + apiKey);
+ }
+
+ try {
+ HttpResponse resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
+ if (resp.statusCode() != 200) {
+ throw new IllegalStateException("Search endpoint returned HTTP " + resp.statusCode());
+ }
+ return parse(resp.body());
+ } catch (IllegalStateException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IllegalStateException("Search request failed: " + e.getMessage(), e);
+ }
+ }
+
+ private List parse(String body) throws JsonProcessingException {
+ List results = new ArrayList<>();
+ JsonNode items = objectMapper.readTree(body).path("results");
+ for (JsonNode item : items) {
+ results.add(new PluginSearchResult(
+ item.path("title").asText(null),
+ item.path("url").asText(null),
+ item.path("snippet").asText(null),
+ null,
+ null));
+ }
+ return results;
+ }
+ }
+}
diff --git a/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json b/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json
new file mode 100644
index 00000000..221ca6bd
--- /dev/null
+++ b/mateclaw-plugin-search-sample/src/main/resources/mateclaw-plugin.json
@@ -0,0 +1,24 @@
+{
+ "name": "mateclaw-plugin-search-demo",
+ "version": "1.0.0",
+ "type": "search",
+ "displayName": "Demo Search Provider",
+ "description": "Registers a custom web-search provider backed by a configurable JSON search endpoint.",
+ "entrypoint": "vip.mate.plugin.sample.search.SimpleSearchPlugin",
+ "minPlatformVersion": "1.1.0",
+ "author": "MateClaw Team",
+ "config": {
+ "baseUrl": {
+ "type": "string",
+ "required": true,
+ "secret": false,
+ "description": "Search endpoint returning {\"results\":[{\"title\",\"url\",\"snippet\"}]}"
+ },
+ "apiKey": {
+ "type": "string",
+ "required": false,
+ "secret": true,
+ "description": "Optional bearer token sent as Authorization header"
+ }
+ }
+}
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 d42f2cad..a6a3d058 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -38,7 +38,12 @@ import vip.mate.llm.chatmodel.ReasoningEffortResolver;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
+import vip.mate.agent.context.PrefixBudgetPlan;
+import vip.mate.agent.context.PrefixBudgetPlanner;
+import vip.mate.agent.context.TokenEstimator;
import vip.mate.llm.model.ModelProviderEntity;
+import vip.mate.llm.probe.ModelContextWindowResolver;
+import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.llm.routing.ProviderRouter;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
@@ -47,6 +52,7 @@ import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.skill.service.SkillService;
import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.ToolRegistry;
+import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.tool.guard.service.ToolGuardService;
@@ -96,6 +102,9 @@ public class AgentGraphBuilder {
private final ConversationService conversationService;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
+ private final ModelContextWindowResolver contextWindowResolver;
+ private final PrefixBudgetPlanner prefixBudgetPlanner;
+ private final ToolUsageRecencyTracker toolUsageRecencyTracker;
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter;
private final PlanningService planningService;
@@ -353,6 +362,12 @@ public class AgentGraphBuilder {
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
+ // Effective context window: explicit config > local-server probe > null
+ // (downstream keeps its global-default fallback). Without probing, a
+ // local 8k/16k model with maxInputTokens unset budgets against the
+ // 128k global default and the first oversized request fails outright.
+ Integer effectiveMaxInputTokens = contextWindowResolver.resolveMaxInputTokens(provider, runtimeModel);
+
// 内置搜索检测(DashScope / Kimi),但不再移除 WebSearchTool — 两者协同而非互斥
boolean builtinSearchEnabled = false;
Map providerKwargs = modelProviderService.readProviderGenerateKwargs(provider);
@@ -387,23 +402,45 @@ public class AgentGraphBuilder {
}
}
- String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled);
+ // Prefix injection budget: optional blocks (memory / wiki / skill
+ // catalog / extension catalog / ledger) share a token budget scaled
+ // to the model's effective window. The agent's own prompt and the
+ // tool schemas are never truncated — they are subtracted from the
+ // budget so the optional blocks absorb the squeeze.
+ int basePromptTokens = TokenEstimator.estimateTokens(entity.getSystemPrompt());
+ int toolSchemaTokens = TokenEstimator.estimateToolsTokens(toolSet.callbacks());
+ PrefixBudgetPlan prefixBudgetPlan = prefixBudgetPlanner.plan(
+ effectiveMaxInputTokens, basePromptTokens, toolSchemaTokens);
+ if (basePromptTokens > prefixBudgetPlan.effectiveMaxTokens() / 2) {
+ log.warn("Agent {} 的身份 prompt 约 {} tokens,已超过模型有效窗口 {} 的一半——"
+ + "系统不会截断用户自写的身份 prompt,请自行精简,否则小上下文模型可能无法响应",
+ entity.getId(), basePromptTokens, prefixBudgetPlan.effectiveMaxTokens());
+ }
+
+ String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled, prefixBudgetPlan.memoryTokens());
// Runtime skill-catalog renderer — captures this agent's bound skills,
// effective tool allowlist, model window and workspace; invoked each
// turn by the reasoning / step-execution nodes with the skills loaded
// so far this run so load_skill pins float to the top of the catalog.
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
- entity, boundTools, runtimeModel.getMaxInputTokens());
+ entity, boundTools, effectiveMaxInputTokens);
// Extension-tool catalog — only for ReAct. The dynamic tool split runs
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
// action node to record enable_tool), so baking the catalog there would
// describe an enable_tool flow that can never take effect.
+ // Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute
+ // would remove it with no enable_tool path to recover it.
boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
+ Set autoDemotedTools = Set.of();
if (!isPlanExecute) {
+ if (prefixBudgetPlan.enabled()) {
+ autoDemotedTools = toolDisclosureService.computeAutoDemotions(
+ toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
+ }
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
- toolSet, runtimeModel.getMaxInputTokens());
+ toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
}
@@ -423,7 +460,8 @@ public class AgentGraphBuilder {
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
} else {
- agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
+ agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer,
+ prefixBudgetPlan, autoDemotedTools);
// StateGraph 路径下工具调用由 ActionNode 控制,始终启用
toolCallingEnabled = true;
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
@@ -451,7 +489,7 @@ public class AgentGraphBuilder {
agent.userLocale = resolveLocale();
agent.temperature = runtimeModel.getTemperature();
agent.maxTokens = runtimeModel.getMaxTokens();
- agent.maxInputTokens = runtimeModel.getMaxInputTokens();
+ agent.maxInputTokens = effectiveMaxInputTokens;
agent.topP = runtimeModel.getTopP();
agent.toolCallingEnabled = toolCallingEnabled;
@@ -510,11 +548,17 @@ public class AgentGraphBuilder {
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
+ return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, skillCatalogRenderer, null, Set.of());
+ }
+
+ StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
+ int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer,
+ PrefixBudgetPlan prefixBudgetPlan, Set autoDemotedTools) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
- runtimeModel, agentId, skillCatalogRenderer);
+ runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager, toolSet);
}
@@ -565,6 +609,14 @@ public class AgentGraphBuilder {
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
+ if (primaryModelConfig != null) {
+ // Feed "prompt too long" rejections back into the window resolver
+ // so the next turn budgets against the server-reported limit.
+ streamingHelper.setContextLimitObserver(errorMessage ->
+ contextWindowResolver.noteContextLimitError(
+ primaryModelConfig.getProvider(),
+ primaryModelConfig.getModelName(), errorMessage));
+ }
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@@ -573,6 +625,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
+ executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@@ -647,6 +700,9 @@ public class AgentGraphBuilder {
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
@@ -829,12 +885,28 @@ public class AgentGraphBuilder {
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
+ return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
+ primaryModelConfig, agentId, skillCatalogRenderer, null, Set.of());
+ }
+
+ CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
+ String reasoningEffort, ModelConfigEntity primaryModelConfig,
+ Long agentId, SkillCatalogRenderer skillCatalogRenderer,
+ PrefixBudgetPlan prefixBudgetPlan, Set autoDemotedTools) {
try {
List fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
+ if (primaryModelConfig != null) {
+ // Feed "prompt too long" rejections back into the window resolver
+ // so the next turn budgets against the server-reported limit.
+ streamingHelper.setContextLimitObserver(errorMessage ->
+ contextWindowResolver.noteContextLimitError(
+ primaryModelConfig.getProvider(),
+ primaryModelConfig.getModelName(), errorMessage));
+ }
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@@ -843,6 +915,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
+ executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@@ -853,10 +926,21 @@ public class AgentGraphBuilder {
// capability from reasoningEffort == null.
boolean supportsReasoningEffort = primaryModelConfig != null
&& ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort();
+ // Honor the model's configured output cap. Passing 0 here made the
+ // node fall back to its 16384 default, so the user-configured
+ // maxTokens never took effect and strict local servers (vLLM's
+ // max_model_len pre-check) rejected the request outright.
+ int configuredMaxOutputTokens = (primaryModelConfig != null
+ && primaryModelConfig.getMaxTokens() != null
+ && primaryModelConfig.getMaxTokens() > 0)
+ ? primaryModelConfig.getMaxTokens() : 0;
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
supportsReasoningEffort,
- streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService,
+ streamingHelper, conversationWindowManager, streamTracker,
+ configuredMaxOutputTokens, wikiContextService,
skillCatalogRenderer, toolDisclosureService, progressLedgerService);
+ reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan);
+ reasoningNode.setAutoDemotedTools(autoDemotedTools);
ActionNode actionNode = new ActionNode(executor, streamTracker);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
@@ -935,6 +1019,9 @@ public class AgentGraphBuilder {
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
+ .addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
@@ -1184,68 +1271,101 @@ public class AgentGraphBuilder {
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
- // RFC-009 PR-3: bias by agent preferences (if any). Listed providers win
- // their declared order; everything else keeps the global priority order.
- List preferred = agentId == null
- ? java.util.Collections.emptyList()
- : agentBindingService.getPreferredProviderIds(agentId);
- if (!preferred.isEmpty()) {
- providers = reorderByPreferences(providers, preferred);
- log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
- }
-
- // RFC-090 §9.2 调整 C — second-pass reorder: lift providers
- // that satisfy the bound-skill capability set (vision / video /
- // audio) ahead of those that don't. Stable otherwise so the
- // user-preferred order still wins among capable providers.
+ // RFC-090 §9.2 调整 C — lift providers that satisfy the bound-skill
+ // capability set (vision / video / audio) ahead of those that don't.
+ // Run before planning so the non-preferred tail inherits this order;
+ // the explicit preferred-model head keeps the user's declared order.
try {
providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers));
} catch (Exception e) {
log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage());
}
+ // Preferred-model chain: explicit (provider, model) entries lead in the
+ // user's order — the same provider may repeat with different models —
+ // then every non-preferred provider follows with its default model.
+ List preferred = agentId == null
+ ? java.util.Collections.emptyList()
+ : agentBindingService.getPreferredProviderModels(agentId);
+ List globalProviderIds = providers.stream()
+ .map(ModelProviderEntity::getProviderId)
+ .toList();
+ List plan = planFallbackOrder(preferred, globalProviderIds);
+ if (!preferred.isEmpty()) {
+ log.debug("[LlmFailover] agent={} preferred-model chain={} -> plan={}", agentId, preferred, plan);
+ }
+
+ // Dedup by exact (provider, model) — seeded with the primary so we never
+ // rebuild the primary call, but OTHER models of the primary provider are
+ // still legitimate fallback entries.
List chain = new ArrayList<>();
- for (ModelProviderEntity p : providers) {
- // Don't put the primary provider's row into the fallback chain — same-instance
- // skipping is also done in the runtime walker, but excluding here saves building
- // a duplicate ChatModel at agent-build time.
- if (primaryProviderId != null && primaryProviderId.equals(p.getProviderId())) {
- log.debug("[LlmFailover] skipping primary provider {} in fallback chain", primaryProviderId);
- continue;
- }
- // RFC-009 Phase 4: skip providers known-bad at build time. The runtime walker in
- // NodeStreamingChatHelper re-checks pool membership per request, so a provider
- // that re-enters the pool later still gets used (the graph is rebuilt on
+ Set seen = new java.util.HashSet<>();
+ if (primaryProviderId != null && primaryModelName != null) {
+ seen.add(primaryProviderId + "::" + primaryModelName);
+ }
+ for (ProviderModelRef ref : plan) {
+ String pid = ref.providerId();
+ // RFC-009 Phase 4: skip providers known-bad at build time. The runtime
+ // walker re-checks pool membership per request, so a provider that
+ // re-enters the pool later still gets used (graph rebuilt on
// ModelConfigChangedEvent).
- if (providerPool != null && !providerPool.contains(p.getProviderId())) {
- log.debug("[LlmFailover] skipping provider {} — not in available pool",
- p.getProviderId());
+ if (providerPool != null && !providerPool.contains(pid)) {
+ log.debug("[LlmFailover] skipping provider {} — not in available pool", pid);
continue;
}
- ModelConfigEntity fallbackConfig = pickFallbackModel(p.getProviderId());
+ ModelConfigEntity fallbackConfig = resolveChainModel(ref);
if (fallbackConfig == null) {
- log.debug("[LlmFailover] skipping provider {} — no enabled chat model",
- p.getProviderId());
+ log.debug("[LlmFailover] skipping {} — no usable chat model", pid);
continue;
}
- if (primaryModelName != null && primaryModelName.equals(fallbackConfig.getModelName())) {
- // Same model name picked for a different provider — exact same call, skip.
+ String key = pid + "::" + fallbackConfig.getModelName();
+ if (!seen.add(key)) {
+ // Exact (provider, model) already queued or equal to the primary.
continue;
}
try {
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
- chain.add(new vip.mate.llm.failover.FallbackEntry(p.getProviderId(), m));
- log.info("[LlmFailover] chain[{}] = {}/{} (priority={})",
- chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
- p.getFallbackPriority());
+ chain.add(new vip.mate.llm.failover.FallbackEntry(pid, m));
+ log.info("[LlmFailover] chain[{}] = {}/{}", chain.size(), pid, fallbackConfig.getModelName());
} catch (Exception e) {
- log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}",
- p.getProviderId(), e.getMessage());
+ log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", pid, e.getMessage());
}
}
return chain;
}
+ /**
+ * Resolve a planned chain entry to a concrete chat model. A pinned model
+ * ({@code modelId != null}) is used when it still exists and is enabled;
+ * otherwise we fall back to the provider's default chat model so a deleted
+ * or disabled pin keeps the provider in the chain.
+ */
+ private ModelConfigEntity resolveChainModel(ProviderModelRef ref) {
+ if (ref.modelId() != null) {
+ try {
+ ModelConfigEntity m = modelConfigService.getModel(ref.modelId());
+ // Honour the pin only when it is a usable chat model that actually
+ // belongs to this entry's provider. The FallbackEntry is keyed by
+ // ref.providerId() for cooldown/pool, so a model from a different
+ // provider would mis-key the chain; an embedding model would never
+ // serve as a chat fallback. Either case falls back to the
+ // provider's default chat model.
+ if (m != null && Boolean.TRUE.equals(m.getEnabled())
+ && ref.providerId().equals(m.getProvider())
+ && (m.getModelType() == null || "chat".equals(m.getModelType()))) {
+ return m;
+ }
+ log.info("[LlmFailover] pinned model {} for provider {} not usable "
+ + "(disabled / wrong provider / non-chat), using provider default",
+ ref.modelId(), ref.providerId());
+ } catch (Exception e) {
+ log.info("[LlmFailover] pinned model {} for provider {} unresolved ({}), using provider default",
+ ref.modelId(), ref.providerId(), e.getMessage());
+ }
+ }
+ return pickFallbackModel(ref.providerId());
+ }
+
/**
* Pick a chat model to use as a fallback for the given provider:
*
@@ -1275,33 +1395,40 @@ public class AgentGraphBuilder {
}
/**
- * Reorder a provider list by an agent's preference list. Listed provider
- * ids come first in their preference order; any provider not in the
- * preference list keeps its original position relative to other unlisted
- * providers (stable partition). Preference entries that don't match any
- * actual provider are silently dropped.
+ * Plan the fallback order as a list of (provider, model) refs.
+ *
+ * Head: the agent's explicit preference entries in declared order,
+ * model-granular — the same provider may appear more than once with
+ * different models. Exact (provider, model) duplicates are dropped.
+ *
+ *
Tail: every provider not named in the preferences, in the supplied
+ * global order, each using its default model ({@code modelId == null}).
+ *
+ *
Preference entries with a blank provider id are ignored. Package-private
+ * for unit testing — see {@code AgentGraphBuilderPreferenceTest}.
*/
- /** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */
- static List reorderByPreferences(List providers,
- List preferredOrder) {
- Map byId = new java.util.LinkedHashMap<>();
- for (ModelProviderEntity p : providers) {
- byId.put(p.getProviderId(), p);
- }
- List reordered = new ArrayList<>(providers.size());
- Set placed = new java.util.HashSet<>();
- for (String prefId : preferredOrder) {
- ModelProviderEntity p = byId.get(prefId);
- if (p != null && placed.add(prefId)) {
- reordered.add(p);
+ static List planFallbackOrder(List preferred,
+ List globalProviderIds) {
+ List plan = new ArrayList<>();
+ Set headEntryKeys = new java.util.HashSet<>();
+ Set headProviderIds = new java.util.HashSet<>();
+ if (preferred != null) {
+ for (ProviderModelRef ref : preferred) {
+ if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
+ String key = ref.providerId() + "::" + (ref.modelId() == null ? "" : ref.modelId());
+ if (!headEntryKeys.add(key)) continue; // exact (provider, model) dup
+ plan.add(ref);
+ headProviderIds.add(ref.providerId());
}
}
- for (ModelProviderEntity p : providers) {
- if (placed.add(p.getProviderId())) {
- reordered.add(p);
+ if (globalProviderIds != null) {
+ for (String pid : globalProviderIds) {
+ if (pid == null || pid.isBlank()) continue;
+ if (headProviderIds.contains(pid)) continue; // already led by an explicit entry
+ plan.add(new ProviderModelRef(pid, null));
}
}
- return reordered;
+ return plan;
}
/**
@@ -1327,7 +1454,7 @@ public class AgentGraphBuilder {
* @throws IllegalArgumentException when an absolute override escapes the
* workspace root
*/
- static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
+ public static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
if (!hasOverride) {
@@ -1347,6 +1474,15 @@ public class AgentGraphBuilder {
return agentOverride;
}
if (hasWorkspace) {
+ // Relative override resolves under the workspace root; reject any value
+ // that escapes it via "../" so attachment/media/tool I/O stays contained.
+ Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize();
+ Path resolved = wsRoot.resolve(agentOverride).normalize();
+ if (!resolved.startsWith(wsRoot)) {
+ throw new IllegalArgumentException(
+ "Agent workspaceBasePath override must stay inside the workspace root: "
+ + resolved + " escapes " + wsRoot);
+ }
return Paths.get(workspaceBase).resolve(agentOverride).toString();
}
return agentOverride;
@@ -1393,6 +1529,10 @@ public class AgentGraphBuilder {
""";
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
+ return buildEnhancedPrompt(entity, builtinSearchEnabled, Integer.MAX_VALUE);
+ }
+
+ private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled, int memoryBudgetTokens) {
// The agent's own systemPrompt encodes its identity (role / goal /
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
// PROFILE.md, MEMORY.md, ...) augments that identity with durable
@@ -1401,7 +1541,7 @@ public class AgentGraphBuilder {
// dropped the identity prompt, so editor-side identity changes never
// reached runtime if the agent had any workspace files.
String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : "";
- String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId());
+ String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId(), memoryBudgetTokens);
StringBuilder basePromptBuilder = new StringBuilder();
if (!identityPrompt.isEmpty()) {
basePromptBuilder.append(identityPrompt);
@@ -1480,10 +1620,11 @@ public class AgentGraphBuilder {
adopting a KB article as the user's project.
## Session Search
- - `session_search(agentId, currentConversationId, mode, query, limit)` — search conversation history
+ - `session_search(agentId, mode, query, limit)` — search conversation history
- mode="recent": list recent conversations (titles, times, message counts)
- mode="search": keyword full-text search across past messages
- Use this to recall previous discussions, look up past decisions, or find context from earlier conversations
+ - Only completed sessions (not currently running) are included in results
## Tool Usage Guidelines
When you have available tools, use them to access local system information, files, or execute commands.
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 344b8640..f3fe7208 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java
@@ -1218,10 +1218,14 @@ public abstract class BaseAgent {
}
/**
- * 解析图片文件的绝对路径。
+ * Resolve the absolute path of an image file.
*
- * 上传文件存储在 data/chat-uploads/ 下,是相对于 Spring Boot 工作目录的路径。
- * MCP 工具的工作目录可能不同,所以这里直接解析为绝对路径。
+ * The storage location of uploaded files is resolved by
+ * {@code ChatUploadLocationResolver} in priority order: the Agent's
+ * workspaceBasePath → the Workspace's basePath → a configurable default
+ * directory ({@code mateclaw.chat.upload.base-dir}, default
+ * {@code data/chat-uploads}). An MCP tool's working directory may differ,
+ * so this resolves directly to an absolute path.
*/
/**
* 构建当前用户消息的 UserMessage(含 multimodal 图片注入)。
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 fccea4a5..71f55799 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
@@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentProviderPreference;
+import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.model.AgentWikiKbBinding;
@@ -122,18 +123,18 @@ public class AgentBindingController {
return R.ok(bindingService.listProviderPreferences(agentId));
}
- @Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
+ @Operation(summary = "批量设置 Agent 的偏好模型链(供应商 + 模型,替换模式)")
@PutMapping("/provider-preferences")
@RequireWorkspaceRole("member")
public R setProviderPreferences(
@PathVariable Long agentId,
- @RequestBody List providerIds,
+ @RequestBody List preferences,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
- bindingService.setProviderPreferences(agentId, providerIds);
+ bindingService.setProviderModelPreferences(agentId, preferences);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
- "providers=" + providerIds.size(), null);
+ "entries=" + (preferences == null ? 0 : preferences.size()), null);
return R.ok();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java
index 81eeebe7..ad3e0dc4 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentProviderPreference.java
@@ -29,6 +29,16 @@ public class AgentProviderPreference {
/** Provider id (matches {@code mate_model_provider.provider_id}). */
private String providerId;
+ /**
+ * Specific chat model to pin for this entry (matches
+ * {@code mate_model_config.id}). {@code null} means "use the provider's
+ * default chat model" — backward compatible with provider-only
+ * preferences. With this column the same {@code providerId} may appear
+ * in multiple rows, each pinning a different model, forming a per-agent
+ * preferred-model chain.
+ */
+ private Long modelId;
+
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
private Integer sortOrder;
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListener.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListener.java
new file mode 100644
index 00000000..048a8c1e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingMcpRemovalListener.java
@@ -0,0 +1,71 @@
+package vip.mate.agent.binding.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import vip.mate.agent.binding.model.AgentToolBinding;
+import vip.mate.agent.binding.repository.AgentToolBindingMapper;
+import vip.mate.tool.mcp.event.McpServerRemovedEvent;
+import vip.mate.tool.mcp.runtime.McpToolNameResolver;
+
+import java.util.List;
+
+/**
+ * Drops {@code mate_agent_tool} rows that pointed at a now-removed MCP server's
+ * tools (issue #127, MCP half).
+ *
+ * MCP tool bindings are stored under the resolved name
+ * {@code mcp___}. Deleting the server used to leave these
+ * rows behind: the agent edit page kept showing the bindings and the user could
+ * not clear them (the tools no longer exist in the live set, so the picker can't
+ * render a row to uncheck). This mirrors the agent-skill cleanup for removed
+ * skills.
+ *
+ * Matching is done with an exact Java prefix rather than a SQL {@code LIKE}:
+ * the literal underscores in {@code mcp__} are wildcards in {@code LIKE},
+ * so {@code mcp_123_%} would also match server {@code 1234}'s tools. The coarse
+ * query narrows to MCP bindings; the precise {@code startsWith} avoids deleting a
+ * sibling server's rows.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AgentBindingMcpRemovalListener {
+
+ private final AgentToolBindingMapper toolBindingMapper;
+
+ @EventListener
+ public void onMcpServerRemoved(McpServerRemovedEvent event) {
+ if (event == null || event.serverId() == null) {
+ return;
+ }
+ String serverPrefix = McpToolNameResolver.PREFIX + event.serverId() + "_";
+
+ // Coarse-filter to MCP bindings in SQL, then match the exact server
+ // prefix in Java to avoid the LIKE-underscore-wildcard false match.
+ List candidates = toolBindingMapper.selectList(
+ new LambdaQueryWrapper()
+ .likeRight(AgentToolBinding::getToolName, McpToolNameResolver.PREFIX));
+ List orphanIds = candidates.stream()
+ .filter(b -> belongsToServer(b.getToolName(), serverPrefix))
+ .map(AgentToolBinding::getId)
+ .toList();
+ if (orphanIds.isEmpty()) {
+ return;
+ }
+ int dropped = toolBindingMapper.delete(
+ new LambdaQueryWrapper()
+ .in(AgentToolBinding::getId, orphanIds));
+ if (dropped > 0) {
+ log.info("Cleaned {} agent-tool binding row(s) for removed MCP server {} (id={})",
+ dropped, event.serverName(), event.serverId());
+ }
+ }
+
+ /** Exact prefix test: {@code mcp_123_x} belongs to server 123, {@code mcp_1234_x} does not. */
+ static boolean belongsToServer(String toolName, String serverPrefix) {
+ return toolName != null && toolName.startsWith(serverPrefix);
+ }
+}
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 68ebb40f..f7e0dec3 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
@@ -18,6 +18,7 @@ import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.routing.AgentBindingResolver;
+import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.lifecycle.BlockedByBindingRow;
@@ -935,30 +936,33 @@ public class AgentBindingService implements AgentBindingResolver {
* fallback chain order per agent.
*/
@Override
- public List getPreferredProviderIds(Long agentId) {
+ public List getPreferredProviderModels(Long agentId) {
if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream()
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
- .map(AgentProviderPreference::getProviderId)
+ .map(p -> new ProviderModelRef(p.getProviderId(), p.getModelId()))
.collect(Collectors.toList());
}
/**
- * Replace the full preference list for an agent. {@code providerIds}
- * is the new ordered preference (index 0 = highest preference).
- * Empty / null list clears all preferences for the agent.
+ * Replace the full preference list for an agent with (provider, model)
+ * entries. {@code refs} is the new ordered preference (index 0 = highest);
+ * a {@code modelId} of {@code null} pins the provider's default model. The
+ * same provider may appear multiple times with different models, forming a
+ * preferred-model chain. Empty / null list clears all preferences.
*/
- public void setProviderPreferences(Long agentId, List providerIds) {
+ public void setProviderModelPreferences(Long agentId, List refs) {
providerPreferenceMapper.delete(
new LambdaQueryWrapper()
.eq(AgentProviderPreference::getAgentId, agentId));
- if (providerIds == null) return;
+ if (refs == null) return;
int order = 0;
- for (String providerId : providerIds) {
- if (providerId == null || providerId.isBlank()) continue;
+ for (ProviderModelRef ref : refs) {
+ if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
AgentProviderPreference row = new AgentProviderPreference();
row.setAgentId(agentId);
- row.setProviderId(providerId.trim());
+ row.setProviderId(ref.providerId().trim());
+ row.setModelId(ref.modelId());
row.setSortOrder(order++);
row.setEnabled(true);
providerPreferenceMapper.insert(row);
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 5e2e3a17..46118d6a 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
@@ -66,7 +66,16 @@ public record ChatOrigin(
* 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
+ @Nullable String baseUrl,
+ /**
+ * Immutable numeric id of the MateClaw user behind this request, when the
+ * requester is an authenticated account (JWT/PAT login via the
+ * web console). Null for non-account origins — webchat visitors, IM
+ * senders, cron — which carry no MateClaw user row. On-behalf-of identity
+ * forwarding uses this to tell "MateClaw authenticated this user" apart
+ * from "this is an external/anonymous identifier" (RFC: identity typing).
+ */
+ @Nullable Long requesterUserId
) {
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
@@ -74,7 +83,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, null);
+ new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
// ---------------- Factories per entry point ----------------
@@ -90,9 +99,25 @@ public record ChatOrigin(
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable String baseUrl) {
+ return web(conversationId, requesterId, workspaceId, workspaceBasePath, baseUrl, null);
+ }
+
+ /**
+ * Web-console origin that also carries the authenticated user's immutable
+ * numeric id. Use this overload from the authenticated web entry point so
+ * on-behalf-of identity forwarding can assert "MateClaw authenticated this
+ * user" rather than an external/anonymous identifier.
+ */
+ public static ChatOrigin web(@Nullable String conversationId,
+ @Nullable String requesterId,
+ @Nullable Long workspaceId,
+ @Nullable String workspaceBasePath,
+ @Nullable String baseUrl,
+ @Nullable Long requesterUserId) {
return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "",
- workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
+ workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
+ requesterUserId);
}
public static ChatOrigin cron(@Nullable String conversationId,
@@ -101,7 +126,7 @@ public record ChatOrigin(
@Nullable Long channelId,
@Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system",
- workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
+ workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
}
// ---------------- Wither-style updates ----------------
@@ -109,27 +134,27 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
- senderName, channelType, chatId, baseUrl);
+ senderName, channelType, chatId, baseUrl, requesterUserId);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
- senderName, channelType, chatId, baseUrl);
+ senderName, channelType, chatId, baseUrl, requesterUserId);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
- senderName, channelType, chatId, baseUrl);
+ senderName, channelType, chatId, baseUrl, requesterUserId);
}
/** 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);
+ senderName, channelType, chatId, newBaseUrl, requesterUserId);
}
/**
@@ -143,7 +168,7 @@ public record ChatOrigin(
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
- newSenderName, newChannelType, newChatId, baseUrl);
+ newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
}
// ---------------- Spring AI ToolContext interop ----------------
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java
index 345ba438..aac41650 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java
@@ -3,6 +3,7 @@ package vip.mate.agent.context;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
@@ -118,6 +119,18 @@ public class ConversationWindowManager {
private final MemoryManager memoryManager;
private final ConversationService conversationService;
+ /**
+ * Optional — adaptive compaction trigger for small context windows.
+ * Setter-injected so the many direct test constructions keep the plain
+ * configured ratio (null → previous behavior).
+ */
+ private PrefixBudgetPlanner prefixBudgetPlanner;
+
+ @Autowired(required = false)
+ public void setPrefixBudgetPlanner(PrefixBudgetPlanner prefixBudgetPlanner) {
+ this.prefixBudgetPlanner = prefixBudgetPlanner;
+ }
+
/**
* Optional spill store, injected via setter so unit tests and the two
* existing 3-arg constructor callers in tests stay source-compatible.
@@ -251,7 +264,12 @@ public class ConversationWindowManager {
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
- int triggerThreshold = (int) (effectiveMax * properties.getCompactTriggerRatio());
+ // Small windows compact later (higher trigger ratio): summarizing at
+ // 75% of an 8k window throws away room it cannot afford to lose.
+ double triggerRatio = prefixBudgetPlanner != null
+ ? prefixBudgetPlanner.compactTriggerRatioFor(effectiveMax, properties.getCompactTriggerRatio())
+ : properties.getCompactTriggerRatio();
+ int triggerThreshold = (int) (effectiveMax * triggerRatio);
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
@@ -1009,6 +1027,33 @@ public class ConversationWindowManager {
+ "' can be called again if its result is needed.]";
}
+ /**
+ * One-line informative summary for a cleared tool result: tool name,
+ * original size, and the first line as a gist. Far more useful to the
+ * model than a bare "removed" marker — it can decide whether re-running
+ * the tool is worth it without guessing what the output was.
+ */
+ static String buildInformativeCleared(String toolName, String body) {
+ String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName;
+ int length = body == null ? 0 : body.length();
+ String gist = "";
+ if (body != null) {
+ for (String line : body.split("\n", 8)) {
+ String candidate = line.strip();
+ if (!candidate.isEmpty()) {
+ gist = candidate.length() > 80 ? candidate.substring(0, 80) + "…" : candidate;
+ break;
+ }
+ }
+ }
+ StringBuilder sb = new StringBuilder("[").append(safeName)
+ .append(" → ").append(length).append(" chars, cleared to save context");
+ if (!gist.isEmpty()) {
+ sb.append("; began: \"").append(gist).append('"');
+ }
+ return sb.append("; call the tool again if the result is still needed]").toString();
+ }
+
/**
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
* Spill-marker responses are left untouched so their on-disk pointer
@@ -1063,7 +1108,8 @@ public class ConversationWindowManager {
replaced.add(r);
continue;
}
- replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"));
+ replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
+ buildInformativeCleared(r.name(), r.responseData())));
changed = true;
}
if (changed) {
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlan.java b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlan.java
new file mode 100644
index 00000000..d0b71856
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlan.java
@@ -0,0 +1,41 @@
+package vip.mate.agent.context;
+
+/**
+ * Per-agent-build token budget for the prompt prefix's optional injection
+ * blocks. Produced once by {@link PrefixBudgetPlanner} when the agent graph
+ * is assembled (the inputs — effective window, base prompt, tool schemas —
+ * are all stable per build) and handed to each injection site.
+ *
+ *
{@code enabled == false} means budgeting is switched off: every budget
+ * field holds {@link Integer#MAX_VALUE} and consumers keep their existing
+ * absolute caps untouched.
+ */
+public record PrefixBudgetPlan(
+ boolean enabled,
+ int effectiveMaxTokens,
+ Profile profile,
+ int injectionBudgetTokens,
+ int memoryTokens,
+ int wikiTokens,
+ int skillCatalogTokens,
+ int extensionCatalogTokens,
+ int ledgerTokens,
+ int toolSchemaBudgetTokens) {
+
+ /** Window-size tier. Small windows tighten the injection ratio. */
+ public enum Profile {
+ /** Regular window — budget shares rarely bind (absolute caps are smaller). */
+ NORMAL,
+ /** Window below the compact threshold — tightened injection ratio. */
+ COMPACT,
+ /** Window below the minimal threshold — injection cut to the bone. */
+ MINIMAL
+ }
+
+ /** Budgeting disabled — unlimited budgets, previous behavior. */
+ public static PrefixBudgetPlan unlimited(int effectiveMaxTokens) {
+ return new PrefixBudgetPlan(false, effectiveMaxTokens, Profile.NORMAL,
+ Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE,
+ Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java
new file mode 100644
index 00000000..fd9ab65a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java
@@ -0,0 +1,120 @@
+package vip.mate.agent.context;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.stereotype.Component;
+import vip.mate.config.ConversationWindowProperties;
+import vip.mate.config.PrefixBudgetProperties;
+
+/**
+ * Computes the {@link PrefixBudgetPlan} for one agent build: how many tokens
+ * each optional prefix injection block (memory / wiki / skill catalog /
+ * extension catalog / progress ledger) may spend, scaled to the model's
+ * effective context window.
+ *
+ *
+ * injectionBudget = max(0, effectiveMax × ratio(profile)
+ * − basePromptTokens − toolSchemaTokens)
+ * block budget = injectionBudget × normalizedShare(block)
+ *
+ *
+ * The agent's own prompt and the tool schemas are never truncated here —
+ * they are subtracted from the injection budget so the optional blocks
+ * absorb the squeeze. On large windows the shares far exceed each block's
+ * absolute cap, so behavior is byte-identical to the pre-budget code.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@EnableConfigurationProperties(PrefixBudgetProperties.class)
+public class PrefixBudgetPlanner {
+
+ /** COMPACT-profile ceiling for the wiki relevance injection (~one page). */
+ static final int COMPACT_WIKI_TOKEN_CAP = 2000;
+
+ private final PrefixBudgetProperties properties;
+ private final ConversationWindowProperties windowProperties;
+
+ /**
+ * @param effectiveMaxInputTokens the model's effective window (explicit
+ * config or probed); null/0 falls back to
+ * the global default
+ * @param basePromptTokens estimated tokens of the agent's own
+ * identity prompt (before memory/guidance)
+ * @param toolSchemaTokens estimated tokens of the advertised tool
+ * schemas
+ */
+ public PrefixBudgetPlan plan(Integer effectiveMaxInputTokens, int basePromptTokens, int toolSchemaTokens) {
+ int effectiveMax = (effectiveMaxInputTokens != null && effectiveMaxInputTokens > 0)
+ ? effectiveMaxInputTokens : windowProperties.getDefaultMaxInputTokens();
+ if (!properties.isEnabled()) {
+ return PrefixBudgetPlan.unlimited(effectiveMax);
+ }
+
+ PrefixBudgetPlan.Profile profile = profileFor(effectiveMax);
+ double ratio = switch (profile) {
+ case NORMAL -> properties.getInjectionRatio();
+ case COMPACT -> properties.getCompactInjectionRatio();
+ case MINIMAL -> properties.getMinimalInjectionRatio();
+ };
+
+ int injectionBudget = Math.max(0,
+ (int) (effectiveMax * ratio) - Math.max(0, basePromptTokens) - Math.max(0, toolSchemaTokens));
+
+ PrefixBudgetProperties.Shares shares = properties.getShares();
+ double sum = shares.getMemory() + shares.getWiki() + shares.getSkill()
+ + shares.getExtensionCatalog() + shares.getLedger();
+ if (sum <= 0) {
+ sum = 1.0;
+ }
+
+ // Profile-specific wiki clamps: knowledge-base reference pages are the
+ // most dispensable block on a small window — the wiki tools stay
+ // callable, only the automatic pre-injection shrinks. COMPACT caps it
+ // at roughly one page; MINIMAL disables it outright.
+ int wikiTokens = (int) (injectionBudget * shares.getWiki() / sum);
+ wikiTokens = switch (profile) {
+ case NORMAL -> wikiTokens;
+ case COMPACT -> Math.min(wikiTokens, COMPACT_WIKI_TOKEN_CAP);
+ case MINIMAL -> 0;
+ };
+
+ PrefixBudgetPlan plan = new PrefixBudgetPlan(
+ true, effectiveMax, profile, injectionBudget,
+ (int) (injectionBudget * shares.getMemory() / sum),
+ wikiTokens,
+ (int) (injectionBudget * shares.getSkill() / sum),
+ (int) (injectionBudget * shares.getExtensionCatalog() / sum),
+ (int) (injectionBudget * shares.getLedger() / sum),
+ (int) (effectiveMax * properties.getToolSchemaRatio()));
+
+ if (profile != PrefixBudgetPlan.Profile.NORMAL) {
+ log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
+ + "(memory={}, wiki={}, skill={}, extCatalog={}, ledger={})",
+ effectiveMax, profile, injectionBudget,
+ plan.memoryTokens(), plan.wikiTokens(), plan.skillCatalogTokens(),
+ plan.extensionCatalogTokens(), plan.ledgerTokens());
+ }
+ return plan;
+ }
+
+ /** Compaction trigger ratio for this window size (small windows fill up before compacting). */
+ public double compactTriggerRatioFor(int effectiveMaxTokens, double defaultRatio) {
+ if (!properties.isEnabled()) {
+ return defaultRatio;
+ }
+ return profileFor(effectiveMaxTokens) == PrefixBudgetPlan.Profile.NORMAL
+ ? defaultRatio : properties.getCompactTriggerRatioOverride();
+ }
+
+ private PrefixBudgetPlan.Profile profileFor(int effectiveMax) {
+ if (effectiveMax < properties.getMinimalThresholdTokens()) {
+ return PrefixBudgetPlan.Profile.MINIMAL;
+ }
+ if (effectiveMax < properties.getCompactThresholdTokens()) {
+ return PrefixBudgetPlan.Profile.COMPACT;
+ }
+ return PrefixBudgetPlan.Profile.NORMAL;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java
new file mode 100644
index 00000000..57155dca
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/DelegatedUsageAccumulator.java
@@ -0,0 +1,95 @@
+package vip.mate.agent.delegation;
+
+import jakarta.annotation.PostConstruct;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Per-conversation accumulator for delegated sub-agent token usage.
+ *
+ * When a parent turn delegates work to sub-agents, each child runs as its own
+ * agent invocation in a separate conversation, so its token usage never lands in
+ * the parent graph's own usage counters. This accumulator lets the delegation
+ * layer record each completed child's usage keyed by the root
+ * (user-facing) conversation, so the parent turn's {@code _usage_final} emission
+ * can roll the whole sub-tree up into the turn total — surfaced live on the SSE
+ * stream and persisted on the assistant message.
+ *
+ *
No double counting across nesting: every descendant (child,
+ * grandchild, …) records against the same root conversation, because the
+ * delegation context carries the original root forward. The root agent drains
+ * the full tree exactly once at its {@code _usage_final}; intermediate agents
+ * drain their own conversation key, which holds nothing. A child agent's own
+ * usage (returned to its parent and recorded once by the parent's delegation
+ * call) is therefore counted a single time.
+ *
+ *
Exposed via a static accessor because the StateGraph agents that emit
+ * {@code _usage_final} are built per-config and are not Spring-managed beans, so
+ * they cannot receive this singleton by constructor injection.
+ */
+@Component
+public class DelegatedUsageAccumulator {
+
+ private static volatile DelegatedUsageAccumulator instance;
+
+ @PostConstruct
+ void register() {
+ instance = this;
+ }
+
+ /** Returns the singleton, or {@code null} before the context is ready. */
+ public static DelegatedUsageAccumulator getInstance() {
+ return instance;
+ }
+
+ private record Usage(AtomicLong prompt, AtomicLong completion) {
+ Usage() {
+ this(new AtomicLong(), new AtomicLong());
+ }
+ }
+
+ private final Map byConversation = new ConcurrentHashMap<>();
+
+ /** Record one completed child's usage against its root conversation. */
+ public void add(String rootConversationId, int promptTokens, int completionTokens) {
+ if (rootConversationId == null || rootConversationId.isBlank()) {
+ return;
+ }
+ if (promptTokens <= 0 && completionTokens <= 0) {
+ return;
+ }
+ Usage u = byConversation.computeIfAbsent(rootConversationId, k -> new Usage());
+ if (promptTokens > 0) {
+ u.prompt().addAndGet(promptTokens);
+ }
+ if (completionTokens > 0) {
+ u.completion().addAndGet(completionTokens);
+ }
+ }
+
+ /** Token pair carrier for a drained accumulation. */
+ public record Drained(long promptTokens, long completionTokens) {
+ public boolean isEmpty() {
+ return promptTokens <= 0 && completionTokens <= 0;
+ }
+ }
+
+ /** Atomically read and remove the accumulated delegated usage for a conversation. */
+ public Drained drain(String conversationId) {
+ if (conversationId == null) {
+ return new Drained(0, 0);
+ }
+ Usage u = byConversation.remove(conversationId);
+ return u == null ? new Drained(0, 0) : new Drained(u.prompt().get(), u.completion().get());
+ }
+
+ /** Discard any accumulation for a conversation — leak guard on error/cancel. */
+ public void clear(String conversationId) {
+ if (conversationId != null) {
+ byConversation.remove(conversationId);
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java
new file mode 100644
index 00000000..6a9920e9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/agent/delegation/SubagentRunContext.java
@@ -0,0 +1,75 @@
+package vip.mate.agent.delegation;
+
+import java.util.Set;
+
+/**
+ * Immutable snapshot of one delegation layer's runtime identity.
+ *
+ * This is the canonical value object that carries "who am I in the delegation
+ * tree" down a single child agent run: tree depth, the immediate parent
+ * conversation, the human-facing root conversation, the subagent id of the layer
+ * currently executing, and the tool deny set in force for this layer.
+ *
+ *
It exists as a first-class, named record (rather than an anonymous frame
+ * buried in a ThreadLocal stack) so the same identity can later be passed
+ * explicitly through the call chain instead of being reconstructed from
+ * thread-local state — explicit passing survives virtual-thread and reactive
+ * hops, where a thread-confined stack does not. {@link vip.mate.tool.builtin.DelegationContext}
+ * currently holds a stack of these per thread; callers that already have a
+ * context in hand should prefer threading it explicitly.
+ *
+ * @param depth 1-based tree depth; {@code 0} means the top-level
+ * (non-delegated) call.
+ * @param parentConversationId the immediate parent conversation that spawned
+ * this layer, or {@code null} at the top level.
+ * @param rootConversationId the human-facing conversation at the top of the
+ * whole delegation tree; every layer carries it
+ * unchanged so a deep child's progress events can
+ * broadcast to the stream the user is watching.
+ * @param currentSubagentId the subagent id of the layer executing now; a
+ * deeper child reads it as its own parent id to
+ * reconstruct the spawn tree.
+ * @param deniedTools tool names this layer's agent may not call;
+ * normalised to a non-null immutable set.
+ *
+ * @author MateClaw Team
+ */
+public record SubagentRunContext(
+ int depth,
+ String parentConversationId,
+ String rootConversationId,
+ String currentSubagentId,
+ Set deniedTools
+) {
+
+ /** The top-level context: not inside any delegation. */
+ public static final SubagentRunContext ROOT = new SubagentRunContext(0, null, null, null, Set.of());
+
+ public SubagentRunContext {
+ // Normalise the deny set so every read site gets a non-null immutable
+ // view without re-checking — mirrors the old accessor's null guard.
+ deniedTools = (deniedTools == null) ? Set.of() : Set.copyOf(deniedTools);
+ }
+
+ /** True when this context represents a delegated (sub-agent) layer. */
+ public boolean isDelegated() {
+ return depth > 0;
+ }
+
+ /**
+ * Build the context for the next layer spawned beneath this one. The root
+ * conversation is inherited unchanged (falling back to the child's parent
+ * conversation when this is the first delegation), and depth advances by one.
+ *
+ * @param childParentConversationId the spawning conversation for the child
+ * @param childSubagentId the subagent id assigned to the child
+ * @param childDeniedTools tool deny set for the child
+ */
+ public SubagentRunContext childFrame(String childParentConversationId,
+ String childSubagentId,
+ Set childDeniedTools) {
+ String inheritedRoot = (rootConversationId != null) ? rootConversationId : childParentConversationId;
+ return new SubagentRunContext(depth + 1, childParentConversationId,
+ inheritedRoot, childSubagentId, childDeniedTools);
+ }
+}
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 b378c137..3aacd086 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
@@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
/**
* 节点级流式 LLM 调用辅助
@@ -162,6 +163,19 @@ public class NodeStreamingChatHelper {
this.providerPool = providerPool;
}
+ /**
+ * Optional hook fired with the raw error chain whenever the PRIMARY model
+ * rejects a call for exceeding its context window. Lets the caller feed
+ * the server-reported limit back into the context-window resolver so the
+ * next turn budgets against the model's true window. Fallback-model
+ * rejections are not reported — they belong to a different model.
+ */
+ private Consumer contextLimitObserver;
+
+ public void setContextLimitObserver(Consumer observer) {
+ this.contextLimitObserver = observer;
+ }
+
private static List wrap(ChatModel m) {
// Legacy single-fallback path: providerId is unknown so health tracking
// is silently disabled for that one entry (it gets a synthetic id).
@@ -585,6 +599,15 @@ public class NodeStreamingChatHelper {
private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast) {
+ // Normalize every assistant tool call in the outgoing history to valid
+ // JSON arguments. The streaming aggregator already does this for the
+ // current turn's calls, but tool calls replayed from persisted history
+ // (e.g. an earlier MCP tool call with empty arguments, or messages
+ // stored by an older build) bypass that path. Strict providers reject
+ // the whole request with HTTP 400 when any function.arguments is not
+ // parseable JSON, so harmonize them here at the single send chokepoint.
+ prompt = normalizeToolCallArguments(prompt);
+
// 在开始 LLM 调用前检查停止标志
if (streamTracker.isStopRequested(conversationId)) {
log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId);
@@ -632,7 +655,7 @@ public class NodeStreamingChatHelper {
}
llmCallCount++;
if (attempt > 0) retryCount++;
- lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
+ lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true);
if (lastResult != null) {
// PTL: 不重试,直接返回给上层 Node 处理
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
@@ -774,7 +797,7 @@ public class NodeStreamingChatHelper {
failoverCount++;
llmCallCount++;
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
- phase + "_fallback_" + (i + 1), broadcast, 0);
+ phase + "_fallback_" + (i + 1), broadcast, 0, false);
// Accept only fully successful fallbacks. Non-successful results (auth
// error, client error, still-rate-limited) propagate to the next
// fallback instead of being surfaced as the final result.
@@ -821,7 +844,7 @@ public class NodeStreamingChatHelper {
*/
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
- boolean broadcast, int attempt) {
+ boolean broadcast, int attempt, boolean primaryCall) {
// Collapse every SystemMessage in the prompt into a single SystemMessage
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in
// server, certain strict vLLM / SGLang deployments) reject 400
@@ -874,7 +897,7 @@ public class NodeStreamingChatHelper {
}
try {
- return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt);
+ return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall);
} finally {
// Idempotent: if consumer already took the entry, discard is a no-op.
if (relayToken != null) {
@@ -906,7 +929,7 @@ public class NodeStreamingChatHelper {
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
- boolean broadcast, int attempt) {
+ boolean broadcast, int attempt, boolean primaryCall) {
if (attempt > 0) {
long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
// 加入 jitter 防止雷群效应
@@ -945,9 +968,10 @@ public class NodeStreamingChatHelper {
AtomicReference errorRef = new AtomicReference<>();
AtomicInteger promptTokens = new AtomicInteger(0);
AtomicInteger completionTokens = new AtomicInteger(0);
- // RFC-014: Anthropic prompt cache 计数(其它 provider 永远为 0)
+ // Prompt cache / reasoning counters; providers that don't report them stay 0.
AtomicInteger cacheReadTokens = new AtomicInteger(0);
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
+ AtomicInteger reasoningTokens = new AtomicInteger(0);
// thinking-only soft cap 触发后设为 true,外层轮询线程据此 dispose 订阅。
// 注意:内容流的字符级 / 句子级重复检测已整体移除(设计取舍:
@@ -1133,10 +1157,12 @@ public class NodeStreamingChatHelper {
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
completionTokens.set(usage.getCompletionTokens().intValue());
}
- // RFC-014: 反射抽取 Anthropic prompt cache 字段(DashScope/OpenAI 自然返回 0)
+ // Reflective extraction of provider-native cache / reasoning
+ // counters (Anthropic / OpenAI-compatible / DashScope).
var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage);
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
+ if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens());
}
})
.subscribe(
@@ -1188,7 +1214,8 @@ public class NodeStreamingChatHelper {
toolCallAccumulators.size(), conversationId);
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
- cacheReadTokens.get(), cacheWriteTokens.get(), phase);
+ cacheReadTokens.get(), cacheWriteTokens.get(),
+ reasoningTokens.get(), phase);
}
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
phase, conversationId);
@@ -1222,6 +1249,7 @@ public class NodeStreamingChatHelper {
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(),
+ reasoningTokens.get(),
phase, true, error.getMessage());
}
@@ -1232,6 +1260,17 @@ public class NodeStreamingChatHelper {
if (errorType == ErrorType.PROMPT_TOO_LONG) {
log.warn("[{}] Prompt too long error, returning to node for compaction: {}",
phase, error.getMessage());
+ // Teach the context-window resolver the server-reported limit so
+ // the next turn budgets against the model's true window. Raw
+ // chain (incl. response body) — the friendly text may drop the
+ // numbers. Primary model only; fallbacks are different models.
+ if (primaryCall && contextLimitObserver != null) {
+ try {
+ contextLimitObserver.accept(extractFullErrorChain(error));
+ } catch (Exception observerError) {
+ log.debug("context-limit observer failed: {}", observerError.getMessage());
+ }
+ }
return buildErrorResultWithType("Prompt 过长: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
}
@@ -1310,7 +1349,8 @@ public class NodeStreamingChatHelper {
: null;
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
- cacheReadTokens.get(), cacheWriteTokens.get(), phase,
+ cacheReadTokens.get(), cacheWriteTokens.get(),
+ reasoningTokens.get(), phase,
truncated,
truncationReason);
}
@@ -1319,7 +1359,8 @@ public class NodeStreamingChatHelper {
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List toolCallAccumulators,
int promptTok, int completionTok,
- int cacheReadTok, int cacheWriteTok, String phase) {
+ int cacheReadTok, int cacheWriteTok,
+ int reasoningTok, String phase) {
List finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString();
@@ -1342,14 +1383,14 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
- true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
+ true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok);
}
/** 组装最终 StreamResult(成功或 partial) */
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List toolCallAccumulators,
int promptTok, int completionTok,
- int cacheReadTok, int cacheWriteTok,
+ int cacheReadTok, int cacheWriteTok, int reasoningTok,
String phase, boolean partial, String errorMsg) {
List finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
@@ -1375,7 +1416,7 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
- partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
+ partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok, reasoningTok);
}
/**
@@ -1539,6 +1580,66 @@ public class NodeStreamingChatHelper {
return new Prompt(new ArrayList<>(messages.subList(0, end)), prompt.getOptions());
}
+ /**
+ * Rebuild any {@link AssistantMessage} whose tool calls carry blank or
+ * non-JSON {@code function.arguments} so the entire outgoing prompt stays
+ * acceptable to strict OpenAI-compatible providers (e.g. aliyun-codingplan,
+ * which 400s the whole request otherwise). Messages with no tool calls, or
+ * whose tool-call arguments are already valid JSON, pass through untouched —
+ * preserving content, metadata, and media. Returns the input unchanged when
+ * nothing needs fixing.
+ */
+ static Prompt normalizeToolCallArguments(Prompt prompt) {
+ if (prompt == null) {
+ return null;
+ }
+ List messages = prompt.getInstructions();
+ if (messages == null || messages.isEmpty()) {
+ return prompt;
+ }
+ List rebuilt = null;
+ for (int i = 0; i < messages.size(); i++) {
+ Message m = messages.get(i);
+ if (!(m instanceof AssistantMessage am)
+ || am.getToolCalls() == null || am.getToolCalls().isEmpty()) {
+ if (rebuilt != null) rebuilt.add(m);
+ continue;
+ }
+ List fixedCalls = null;
+ List calls = am.getToolCalls();
+ for (int j = 0; j < calls.size(); j++) {
+ AssistantMessage.ToolCall tc = calls.get(j);
+ String safe = sanitizeToolCallArguments(tc.name(), tc.arguments());
+ if (!safe.equals(tc.arguments())) {
+ if (fixedCalls == null) fixedCalls = new ArrayList<>(calls);
+ fixedCalls.set(j, new AssistantMessage.ToolCall(tc.id(), tc.type(), tc.name(), safe));
+ }
+ }
+ if (fixedCalls == null) {
+ if (rebuilt != null) rebuilt.add(m);
+ continue;
+ }
+ if (rebuilt == null) {
+ rebuilt = new ArrayList<>(messages.subList(0, i));
+ }
+ AssistantMessage.Builder builder = AssistantMessage.builder()
+ .content(am.getText() == null ? "" : am.getText())
+ .toolCalls(fixedCalls);
+ if (am.getMetadata() != null && !am.getMetadata().isEmpty()) {
+ builder.properties(am.getMetadata());
+ }
+ if (am.getMedia() != null && !am.getMedia().isEmpty()) {
+ builder.media(am.getMedia());
+ }
+ rebuilt.add(builder.build());
+ }
+ if (rebuilt == null) {
+ return prompt;
+ }
+ log.debug("[normalizeToolCallArguments] normalized non-JSON tool-call arguments in outgoing prompt");
+ return new Prompt(rebuilt, prompt.getOptions());
+ }
+
private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
if (streamTracker != null && conversationId != null) {
@@ -1753,17 +1854,19 @@ public class NodeStreamingChatHelper {
ErrorType errorType,
/** 用户主动停止(stopRequested)导致的提前返回 */
boolean stopped,
- /** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0) */
+ /** Prompt cache 命中 tokens(provider 未上报时为 0) */
int cacheReadTokens,
- /** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0) */
- int cacheWriteTokens
+ /** Prompt cache 写入 tokens(provider 未上报时为 0) */
+ int cacheWriteTokens,
+ /** 思考(reasoning)阶段消耗的 completion tokens(provider 未上报时为 0) */
+ int reasoningTokens
) {
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
- promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0);
+ promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0, 0);
}
/** 兼容 10-arg 调用点 */
@@ -1772,17 +1875,17 @@ public class NodeStreamingChatHelper {
int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
- promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0);
+ promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0, 0);
}
- /** 兼容 12-arg 调用点(pre-RFC-014) */
+ /** 兼容 11-arg 调用点(无 cache/reasoning 计数) */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType,
boolean stopped) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
- promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0);
+ promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0, 0);
}
/** 是否有不可忽略的错误(无内容 + 有错误) */
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
index 6e0fcd01..369dff8a 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java
@@ -13,6 +13,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
+import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.context.ConversationWindowManager;
@@ -197,6 +198,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
+ AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
+ AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
+ AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference finalModelName = new AtomicReference<>("");
AtomicReference finalProviderId = new AtomicReference<>("");
// 防重保护:同 chatStructuredStream
@@ -268,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
+ finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
+ finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
+ finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@@ -282,10 +289,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
- if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
+ DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
+ DelegatedUsageAccumulator.Drained delegated = acc != null
+ ? acc.drain(conversationId)
+ : new DelegatedUsageAccumulator.Drained(0, 0);
+ long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
+ long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
+ if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
- "promptTokens", finalPromptTokens.get(),
- "completionTokens", finalCompletionTokens.get(),
+ "promptTokens", promptTokens,
+ "completionTokens", completionTokens,
+ "delegatedPromptTokens", delegated.promptTokens(),
+ "delegatedCompletionTokens", delegated.completionTokens(),
+ "cacheReadTokens", finalCacheReadTokens.get(),
+ "cacheWriteTokens", finalCacheWriteTokens.get(),
+ "reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@@ -304,6 +322,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
+ })
+ // Leak guard: discard delegated usage if the turn ends without
+ // emitting _usage_final (error / cancel).
+ .doFinally(sig -> {
+ DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
+ if (acc != null) acc.clear(conversationId);
});
} catch (Exception e) {
setState(AgentState.ERROR);
@@ -333,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// Token usage 追踪(每次 NodeOutput 更新最新累计值,最后一次即最终值)
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
+ AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
+ AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
+ AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference finalModelName = new AtomicReference<>("");
AtomicReference finalProviderId = new AtomicReference<>("");
// 防重保护:StateGraph 对每个节点都 emit NodeOutput,FINAL_ANSWER 一旦写入后续节点都携带,
@@ -418,6 +445,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
+ finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
+ finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
+ finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@@ -434,10 +464,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
})
// 流正常完成后追加内部 usage 事件
.concatWith(Mono.fromSupplier(() -> {
- if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
+ DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
+ DelegatedUsageAccumulator.Drained delegated = acc != null
+ ? acc.drain(conversationId)
+ : new DelegatedUsageAccumulator.Drained(0, 0);
+ long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
+ long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
+ if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
- "promptTokens", finalPromptTokens.get(),
- "completionTokens", finalCompletionTokens.get(),
+ "promptTokens", promptTokens,
+ "completionTokens", completionTokens,
+ "delegatedPromptTokens", delegated.promptTokens(),
+ "delegatedCompletionTokens", delegated.completionTokens(),
+ "cacheReadTokens", finalCacheReadTokens.get(),
+ "cacheWriteTokens", finalCacheWriteTokens.get(),
+ "reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@@ -457,6 +498,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
+ })
+ // Leak guard: discard delegated usage if the turn ends without
+ // emitting _usage_final (error / cancel).
+ .doFinally(sig -> {
+ DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
+ if (acc != null) acc.clear(conversationId);
});
} catch (Exception e) {
setState(AgentState.ERROR);
@@ -522,6 +569,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(FORCED_TOOL_CALL, "");
inputs.put(PROMPT_TOKENS, 0);
inputs.put(COMPLETION_TOKENS, 0);
+ inputs.put(CACHE_READ_TOKENS, 0);
+ inputs.put(CACHE_WRITE_TOKENS, 0);
+ inputs.put(REASONING_TOKENS, 0);
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
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 71363c40..765096c8 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
@@ -7,6 +7,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext;
+import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
@@ -251,6 +252,13 @@ public class ToolExecutionExecutor {
*/
private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
+ /** Optional recency feed for budget-driven tool-disclosure demotion. */
+ private ToolUsageRecencyTracker usageRecencyTracker;
+
+ public void setUsageRecencyTracker(ToolUsageRecencyTracker tracker) {
+ this.usageRecencyTracker = tracker;
+ }
+
public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
this.skillRuntimeService = s;
}
@@ -885,6 +893,12 @@ public class ToolExecutionExecutor {
ToolExecutionContext.clear();
}
+ // Recency feed for budget-driven disclosure demotion: recently used
+ // tools keep their advertised schema, never-used ones demote first.
+ if (usageRecencyTracker != null) {
+ usageRecencyTracker.recordUse(toolName);
+ }
+
int rawLen = result != null ? result.length() : 0;
// RFC-052: returnDirect tools bypass spill / truncation / LLM context.
// Their full text goes to the user verbatim and is never persisted to
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java
index 426bf067..d27cf8fc 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java
@@ -1,8 +1,10 @@
package vip.mate.agent.graph.executor;
+import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.context.StructuredTruncator;
+import vip.mate.tool.guard.WorkspacePathGuard;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@@ -73,6 +75,30 @@ public class ToolResultStorage {
this.excludedToolsSnapshot = props.excludedToolsSet();
}
+ /**
+ * Trust the deterministic spill roots with the workspace path guard at
+ * startup, before any spill happens in this JVM. Without this, a
+ * conversation that spilled in a previous run and is then resumed after a
+ * restart would have its {@code read_file} of the still-on-disk spill path
+ * rejected as a boundary escape until the next spill re-registers the root.
+ * The per-workspace branch ({@code /.mateclaw/tool-results}) is
+ * intentionally not registered here — it already sits inside its own
+ * workspace boundary.
+ */
+ @PostConstruct
+ void registerSpillRootsAsTrusted() {
+ if (!props.isEnabled()) {
+ return;
+ }
+ if (!props.getStorageBaseDir().isEmpty()) {
+ WorkspacePathGuard.addTrustedRoot(props.getStorageBaseDir());
+ }
+ String tmp = System.getProperty("java.io.tmpdir");
+ if (tmp != null && !tmp.isEmpty()) {
+ WorkspacePathGuard.addTrustedRoot(Paths.get(tmp, "mateclaw", "tool-results").toString());
+ }
+ }
+
/** D-6: current cumulative spill count (monotonically increasing). */
public long getSpillCount() {
return spillCount.get();
@@ -283,18 +309,30 @@ public class ToolResultStorage {
private Path resolveBaseDir(String workspaceBasePath) {
Path base;
+ boolean outsideWorkspace;
if (!props.getStorageBaseDir().isEmpty()) {
base = Paths.get(props.getStorageBaseDir());
+ outsideWorkspace = true;
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
+ // Inside the workspace boundary already — read_file of these spill
+ // files is permitted without an extra trusted-root registration.
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
+ outsideWorkspace = false;
} else {
String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null;
base = Paths.get(tmp, "mateclaw", "tool-results");
+ outsideWorkspace = true;
}
// Register so the retention sweep and conversation-delete hook can
// reach this root even when the workspace path is no longer in scope.
observedRoots.add(base);
+ // A spill directory that lives outside the workspace must be trusted by
+ // the path guard; otherwise the read_file the spill preview tells the
+ // agent to perform is rejected as a workspace-boundary escape.
+ if (outsideWorkspace) {
+ WorkspacePathGuard.addTrustedRoot(base.toString());
+ }
return base;
}
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 7ddec09d..3870c022 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
@@ -265,12 +265,15 @@ public class FinalAnswerNode implements NodeAction {
/**
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
- * with a user-visible warning. No-op when no cache is wired (legacy
- * tests) or when the answer is empty.
+ * with a user-visible warning, and wrap live bare URLs into
+ * {@code [filename](url)} markdown links so the chat shows the file name
+ * instead of the raw id. No-op when no cache is wired (legacy tests) or
+ * when the answer is empty.
*/
private String scrubFakeUrls(String text) {
if (generatedFileCache == null || text == null || text.isEmpty()) return text;
- return generatedFileCache.scrubMissingReferences(text);
+ return generatedFileCache.linkifyBareReferences(
+ generatedFileCache.scrubMissingReferences(text));
}
private FinishReason parseFinishReason(String reason) {
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java
index 0ec0da7d..053b4223 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java
@@ -22,6 +22,7 @@ import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.LoopBudgetConfig;
+import vip.mate.agent.context.PrefixBudgetPlan;
import vip.mate.agent.context.LoopMessageBudgeter;
import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.context.TokenEstimator;
@@ -350,6 +351,52 @@ public class ReasoningNode implements NodeAction {
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
+ /**
+ * Token budget for the optional prefix injection blocks, computed at
+ * agent-build time against the model's effective context window. Null
+ * when the graph was assembled without budgeting (tests, legacy paths) —
+ * all injection sites then keep their previous absolute-cap behavior.
+ */
+ private PrefixBudgetPlan prefixBudgetPlan;
+
+ public void setPrefixBudgetPlan(PrefixBudgetPlan prefixBudgetPlan) {
+ this.prefixBudgetPlan = prefixBudgetPlan;
+ }
+
+ /**
+ * Core-tier tools auto-demoted to the extension catalog because the
+ * advertised schemas exceeded the window's tool-schema budget. Decided
+ * once at agent-build time (kept stable for prompt caching); the baked
+ * extension catalog lists them so {@code enable_tool} can surface any of
+ * them back.
+ */
+ private Set autoDemotedTools = Set.of();
+
+ public void setAutoDemotedTools(Set autoDemotedTools) {
+ this.autoDemotedTools = autoDemotedTools == null ? Set.of() : autoDemotedTools;
+ }
+
+ /** Floor for the window-aware output clamp — an answer needs at least this much room. */
+ private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512;
+
+ /**
+ * Output cap actually sent to the provider. Strict local servers (vLLM)
+ * statically reject {@code max_tokens >= max_model_len}, so when the
+ * effective context window is known and smaller than the configured /
+ * default output cap, clamp to half the window (leaving the other half
+ * for the prompt). No-op when the window is unknown or already larger.
+ */
+ int effectiveMaxOutputTokens() {
+ int window = (prefixBudgetPlan != null) ? prefixBudgetPlan.effectiveMaxTokens() : 0;
+ if (window > 0 && maxOutputTokens >= window) {
+ int clamped = Math.max(MIN_CLAMPED_OUTPUT_TOKENS, window / 2);
+ log.info("[ReasoningNode] max_tokens {} ≥ 模型窗口 {},钳制为 {}(窗口一半)以避免服务端拒绝",
+ maxOutputTokens, window, clamped);
+ return clamped;
+ }
+ return maxOutputTokens;
+ }
+
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
@@ -470,6 +517,12 @@ public class ReasoningNode implements NodeAction {
* otherwise the documented fallback.
*/
private int loopContextWindowTokens() {
+ // Per-model effective window (explicit config or probed) beats the
+ // global default — the loop budgeter is otherwise blind to small
+ // local models and never trims for them.
+ if (prefixBudgetPlan != null && prefixBudgetPlan.effectiveMaxTokens() > 0) {
+ return prefixBudgetPlan.effectiveMaxTokens();
+ }
if (conversationWindowManager != null) {
int v = conversationWindowManager.getDefaultMaxInputTokens();
if (v > 0) return v;
@@ -709,13 +762,35 @@ public class ReasoningNode implements NodeAction {
// so an enable_tool call earlier in this loop takes effect immediately.
// Falls back to the full tool set when no disclosure service is wired.
List activeCallbacks = (toolDisclosureService != null && toolSet != null)
- ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks()
+ ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
+ .activeCallbacks()
: toolCallbacks;
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
Prompt prompt = new Prompt(promptMessages, options);
+ // Prefix accounting: how much of the window the never-trimmed prefix
+ // (system prompt + runtime context + wiki + skill catalog + ledger)
+ // and the advertised tool schemas consume. Logged on the turn's first
+ // call so a small-window overflow is diagnosable per block instead of
+ // surfacing as an opaque provider 400.
+ int prefixEstimateTokens = TokenEstimator.estimateTokens(nonHistoryPrefix);
+ int toolSchemaEstimateTokens = TokenEstimator.estimateToolsTokens(activeCallbacks);
+ if (accessor.llmCallCount() == 0) {
+ log.info("[ReasoningNode] Prefix accounting conv={}: window={} tokens, prefix={} "
+ + "(system+context+wiki+skills+ledger), toolSchemas={}, history={}",
+ conversationId, loopContextWindowTokens(), prefixEstimateTokens,
+ toolSchemaEstimateTokens, TokenEstimator.estimateTokens(messages));
+ }
+ // The prefix cannot be compacted (history compaction is the only lever),
+ // so a prefix that alone exceeds the window makes the request doomed —
+ // fail fast with the same PROMPT_TOO_LONG shape a provider rejection
+ // would produce instead of sending it. Gated on budgeting being active
+ // (an estimation false-positive must not block requests otherwise).
+ boolean prefixOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
+ && prefixEstimateTokens + toolSchemaEstimateTokens > prefixBudgetPlan.effectiveMaxTokens();
+
// ======= LLM 调用区域 =======
// nextLlmCallCount 在首次 streamCall 之前计算。
// 所有退出路径(正常、stopped、fatal error、CancellationException)都必须写回此值。
@@ -745,7 +820,19 @@ public class ReasoningNode implements NodeAction {
NodeStreamingChatHelper.StreamResult result;
try {
- result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
+ if (prefixOverflow) {
+ String overflowMessage = "Prompt 前缀估算 " + (prefixEstimateTokens + toolSchemaEstimateTokens)
+ + " tokens(注入块 " + prefixEstimateTokens + " + 工具 schema " + toolSchemaEstimateTokens
+ + ")已超过模型上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens()
+ + " tokens,历史压缩无法解决——请精简 Agent 身份 prompt、减少绑定工具/技能,"
+ + "或换用更大窗口的模型";
+ log.error("[ReasoningNode] {}", overflowMessage);
+ result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
+ 0, 0, false, overflowMessage,
+ NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0);
+ } else {
+ result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
+ }
// PTL 处理:结构化压缩后重试。复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context;早期的 tail-only 路径会把
@@ -1119,7 +1206,9 @@ public class ReasoningNode implements NodeAction {
if (!projectRecalled && wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try {
Long parsedAgentId = Long.parseLong(agentIdStr);
- String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
+ Integer wikiBudgetTokens = (prefixBudgetPlan != null && prefixBudgetPlan.enabled())
+ ? prefixBudgetPlan.wikiTokens() : null;
+ String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg, wikiBudgetTokens);
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
prefix.add(new UserMessage(wikiRelevant));
}
@@ -1166,11 +1255,11 @@ public class ReasoningNode implements NodeAction {
default -> 16384;
};
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
- builder.maxTokens(budgetTokens + maxOutputTokens);
+ builder.maxTokens(budgetTokens + effectiveMaxOutputTokens());
builder.temperature(1.0);
log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens);
} else {
- builder.maxTokens(maxOutputTokens);
+ builder.maxTokens(effectiveMaxOutputTokens());
if (thinkingOn && !isClaudeModel) {
log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel);
}
@@ -1185,7 +1274,7 @@ public class ReasoningNode implements NodeAction {
// DashScope rejects max_tokens above its 8192 ceiling with a 400 that
// the failover layer misreads as "model not found"; clamp so a
// DashScope-backed model never overflows the provider limit.
- int effectiveMaxTokens = maxOutputTokens;
+ int effectiveMaxTokens = effectiveMaxOutputTokens();
if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel
&& effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) {
log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model",
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
index 7710241d..06af1b05 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java
@@ -11,6 +11,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
+import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
@@ -97,8 +98,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId);
Map inputs = buildInitialState(userMessage, conversationId);
- // 从 DB 恢复 awaiting_approval 状态的计划上下文
- PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext();
+ // 从 DB 恢复 awaiting_approval 状态的计划上下文(按 conversationId 过滤,避免并发会话误取)
+ PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(conversationId);
if (ctx != null) {
inputs.put(PlanStateKeys.PLAN_ID, ctx.planId());
inputs.put(PlanStateKeys.PLAN_STEPS, ctx.steps());
@@ -142,8 +143,15 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
+ AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
+ AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
+ AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference finalModelName = new AtomicReference<>("");
AtomicReference finalProviderId = new AtomicReference<>("");
+ // Root conversation for this turn — used to roll delegated sub-agent
+ // token usage into the turn's _usage_final and to clear the accumulator
+ // on terminal so an errored turn never leaks an entry.
+ final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
// 去重:记录上一次已持久化的 step 结果和 thinking,防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference lastPersistedStepResult = new AtomicReference<>("");
AtomicReference lastPersistedStepThinking = new AtomicReference<>("");
@@ -203,16 +211,33 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
+ finalCacheReadTokens.set(output.state().value(MateClawStateKeys.CACHE_READ_TOKENS, 0));
+ finalCacheWriteTokens.set(output.state().value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0));
+ finalReasoningTokens.set(output.state().value(MateClawStateKeys.REASONING_TOKENS, 0));
finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
- if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
+ // Roll delegated sub-agent usage (whole sub-tree, keyed by this
+ // root conversation) into the turn total so the assistant
+ // message reflects what the orchestrator + all children cost.
+ DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
+ DelegatedUsageAccumulator.Drained delegated = acc != null
+ ? acc.drain(usageConversationId)
+ : new DelegatedUsageAccumulator.Drained(0, 0);
+ long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
+ long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
+ if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
- "promptTokens", finalPromptTokens.get(),
- "completionTokens", finalCompletionTokens.get(),
+ "promptTokens", promptTokens,
+ "completionTokens", completionTokens,
+ "delegatedPromptTokens", delegated.promptTokens(),
+ "delegatedCompletionTokens", delegated.completionTokens(),
+ "cacheReadTokens", finalCacheReadTokens.get(),
+ "cacheWriteTokens", finalCacheWriteTokens.get(),
+ "reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@@ -223,6 +248,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
.doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
+ })
+ // Leak guard: if the turn ends without emitting _usage_final
+ // (error / cancel), discard any delegated usage left for this
+ // conversation so it can't bleed into a later turn.
+ .doFinally(sig -> {
+ DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
+ if (acc != null) acc.clear(usageConversationId);
});
}
@@ -297,6 +329,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.REQUESTER_ID, "");
inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0);
inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0);
+ inputs.put(MateClawStateKeys.CACHE_READ_TOKENS, 0);
+ inputs.put(MateClawStateKeys.CACHE_WRITE_TOKENS, 0);
+ inputs.put(MateClawStateKeys.REASONING_TOKENS, 0);
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java
index 33280fc7..fac3be5a 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java
@@ -33,6 +33,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -190,6 +191,48 @@ public class PlanGenerationNode implements NodeAction {
return goal;
}
+ /** Whole injected long-term-memory recall block (any casing). */
+ private static final Pattern MEMORY_CONTEXT_BLOCK =
+ Pattern.compile("(?is)<\\s*memory-context\\s*>.*?\\s*memory-context\\s*>");
+ /** Stray open/close memory-context fence tags left after block removal. */
+ private static final Pattern MEMORY_CONTEXT_TAG =
+ Pattern.compile("(?i)?\\s*memory-context\\s*>");
+ /** Marker that introduces the real instruction inside a scheduled-run wrapper. */
+ private static final String CRON_TASK_MARKER = "[任务指令]";
+ /** Suffix appended by a goal-driven re-plan pass; not part of the user's ask. */
+ private static final String FOLLOWUP_MARKER = "[Follow-up guidance]";
+
+ /**
+ * Recovers the user's actual request from the fully-assembled agent prompt so
+ * the persisted/displayed plan goal reads as the task itself, not the
+ * framework scaffolding wrapped around it. The graph receives the goal already
+ * enriched — a {@code …} recall block is
+ * prepended for every turn, scheduled runs add a wrapper whose real payload
+ * sits after {@code [任务指令]}, and a re-plan pass appends a
+ * {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan
+ * board showing "<memory-context> The following is what you…" instead of
+ * the user's goal. Strips, in order: the recall block, the scheduled-run
+ * preamble (keeping only the instruction body), and the follow-up suffix.
+ * Falls back to the raw goal if scrubbing would leave nothing.
+ */
+ static String displayGoal(String goal) {
+ if (goal == null || goal.isBlank()) {
+ return goal == null ? "" : goal;
+ }
+ String s = MEMORY_CONTEXT_BLOCK.matcher(goal).replaceAll("");
+ s = MEMORY_CONTEXT_TAG.matcher(s).replaceAll("");
+ int task = s.lastIndexOf(CRON_TASK_MARKER);
+ if (task >= 0) {
+ s = s.substring(task + CRON_TASK_MARKER.length());
+ }
+ int followup = s.indexOf(FOLLOWUP_MARKER);
+ if (followup >= 0) {
+ s = s.substring(0, followup);
+ }
+ s = s.strip();
+ return s.isEmpty() ? goal.strip() : s;
+ }
+
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
@@ -260,7 +303,7 @@ public class PlanGenerationNode implements NodeAction {
if (goalService.findActiveByConversation(convId) != null) {
return null; // respect an existing goal (incl. re-plan passes)
}
- String request = stripInjectedContext(accessor.goal()).strip();
+ String request = displayGoal(accessor.goal());
GoalCreateRequest req = new GoalCreateRequest();
req.setConversationId(convId);
req.setAgentId(origin.agentId());
@@ -368,10 +411,16 @@ public class PlanGenerationNode implements NodeAction {
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String conversationId = accessor.conversationId();
- log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
+ // The graph's goal carries framework scaffolding (memory recall block,
+ // scheduled-run wrapper, follow-up suffix). Persist and display the
+ // scrubbed user request so the Plan board shows the actual task; the raw
+ // goal still feeds the triage LLM below.
+ String persistGoal = displayGoal(goal);
+
+ log.info("[PlanGeneration] Evaluating goal: {}", persistGoal.length() > 100 ? persistGoal.substring(0, 100) + "..." : persistGoal);
List events = new ArrayList<>();
- events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
+ events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
Long existingPlanId = state.value(PlanStateKeys.PLAN_ID).orElse(null);
@@ -508,8 +557,8 @@ public class PlanGenerationNode implements NodeAction {
log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; "
+ "downgrading to single-step plan so tools can execute (goal: {})",
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
- List gatedSteps = List.of(goal);
- var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps);
+ List gatedSteps = List.of(persistGoal);
+ var gatedPlan = planningService.createPlan(agentId, conversationId, persistGoal, gatedSteps);
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
return PlanStateAccessor.output()
.needsPlanning(true)
@@ -547,7 +596,7 @@ public class PlanGenerationNode implements NodeAction {
// can still reach the tools. (Previous behavior dropped back to
// direct_answer, which silently stripped tool capability.)
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
- steps = List.of(goal);
+ steps = List.of(persistGoal);
}
// Resolve any per-step agent delegation the planner asked for. Null
@@ -555,7 +604,7 @@ public class PlanGenerationNode implements NodeAction {
List stepAgentIds = resolveStepAgents(steps,
triage != null ? triage.stepAgents() : null,
chatOrigin.workspaceId(), agentId);
- var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
+ var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
@@ -600,12 +649,12 @@ public class PlanGenerationNode implements NodeAction {
// answer. This preserves tool access on the failure path; the previous
// "direct answer" fallback silently degraded tool-requiring tasks.
try {
- var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
- events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
+ var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal));
+ events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal)));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
- .planSteps(List.of(goal))
+ .planSteps(List.of(persistGoal))
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
index 59c4ca8a..d2895c26 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java
@@ -30,6 +30,7 @@ import vip.mate.planning.service.PlanningService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.tool.builtin.DelegateAgentTool;
+import vip.mate.tool.builtin.DelegateAgentTool.ChildResult;
import vip.mate.tool.builtin.DelegationContext;
import vip.mate.tool.builtin.ToolExecutionContext;
@@ -247,6 +248,9 @@ public class StepExecutionNode implements NodeAction {
String approvalToolName = null;
int stepPromptTokens = 0;
int stepCompletionTokens = 0;
+ int stepCacheReadTokens = 0;
+ int stepCacheWriteTokens = 0;
+ int stepReasoningTokens = 0;
// RFC-052: any returnDirect tool that fires inside this step must
// short-circuit the entire plan (not just this step). We accumulate
@@ -321,6 +325,9 @@ public class StepExecutionNode implements NodeAction {
stepPromptTokens += result.promptTokens();
stepCompletionTokens += result.completionTokens();
+ stepCacheReadTokens += result.cacheReadTokens();
+ stepCacheWriteTokens += result.cacheWriteTokens();
+ stepReasoningTokens += result.reasoningTokens();
if (!result.thinking().isEmpty()) {
stepThinking = result.thinking();
@@ -443,8 +450,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("awaiting_approval")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
- .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
- .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
+ .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
+ stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@@ -479,10 +486,8 @@ public class StepExecutionNode implements NodeAction {
.contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
- .put(MateClawStateKeys.PROMPT_TOKENS,
- state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
- .put(MateClawStateKeys.COMPLETION_TOKENS,
- state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
+ .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
+ stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@@ -531,8 +536,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
- .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
- .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
+ .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
+ stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@@ -589,8 +594,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
- .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
- .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
+ .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
+ stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@@ -601,8 +606,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepResult(shortError)
.currentPhase("plan_aborted")
.contentStreamed(false)
- .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
- .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
+ .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
+ stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@@ -645,8 +650,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("step_completed")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
- .put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
- .put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
+ .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
+ stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@@ -678,7 +683,7 @@ public class StepExecutionNode implements NodeAction {
// Seed the delegation context with the plan's REAL conversation id (from
// graph state) so the delegated child conversation is parented to it and
// stays hidden from the user's conversation list. The ChatOrigin in the
- // plan-execute path carries no conversationId, so delegateByAgentId can't
+ // plan-execute path carries no conversationId, so the delegation can't
// derive the parent on its own — we provide it here.
boolean seeded = false;
if (conversationId != null && !conversationId.isBlank()
@@ -687,20 +692,30 @@ public class StepExecutionNode implements NodeAction {
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
seeded = true;
}
- String result;
+ ChildResult childResult = null;
+ String delegateError = null;
try {
- result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin);
+ childResult = delegateAgentTool.delegateByAgentIdStructured(assignedAgentId, step, chatOrigin);
} catch (Exception e) {
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
- result = "[错误] 委派执行异常:" + e.getMessage();
+ delegateError = e.getMessage();
} finally {
if (seeded) {
DelegationContext.exit();
}
}
- String finalResult = result != null ? result : "";
- boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]");
+ // Branch on the structured outcome instead of pattern-matching an error
+ // prefix out of the reply text: a successful child with non-empty content
+ // is the only "ok" case; blank / error / missing all count as failure.
+ boolean ok = childResult != null && childResult.success() && !childResult.isBlank();
+ String finalResult = ok
+ ? (childResult.result() != null ? childResult.result() : "")
+ : "[错误] 委派执行失败:" + (delegateError != null ? delegateError
+ : childResult != null && childResult.error() != null ? childResult.error()
+ : childResult != null && childResult.isBlank() ? "子 Agent 返回内容为空"
+ : "未知错误");
+ boolean failed = !ok;
if (failed) {
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
} else {
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java
index 98d3e385..f041871e 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java
@@ -258,10 +258,37 @@ public final class PlanStateAccessor {
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
+ map.put(MateClawStateKeys.CACHE_READ_TOKENS,
+ currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
+ map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
+ currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
+ map.put(MateClawStateKeys.REASONING_TOKENS,
+ currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + result.reasoningTokens());
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
return this;
}
+ /**
+ * 将一个 step 的累计 usage(含 cache / reasoning 分项)加到 state 已有值上。
+ * StepExecutionNode 在多个出口路径上写回同一组键,统一走这里避免漏项。
+ */
+ public OutputBuilder addStepUsage(OverAllState currentState,
+ int promptTokens, int completionTokens,
+ int cacheReadTokens, int cacheWriteTokens,
+ int reasoningTokens) {
+ map.put(MateClawStateKeys.PROMPT_TOKENS,
+ currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0) + promptTokens);
+ map.put(MateClawStateKeys.COMPLETION_TOKENS,
+ currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + completionTokens);
+ map.put(MateClawStateKeys.CACHE_READ_TOKENS,
+ currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + cacheReadTokens);
+ map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
+ currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + cacheWriteTokens);
+ map.put(MateClawStateKeys.REASONING_TOKENS,
+ currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + reasoningTokens);
+ return this;
+ }
+
public Map build() {
return map;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
index 23a8b6f8..5529f440 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java
@@ -537,6 +537,12 @@ public final class MateClawStateAccessor {
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);
map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens());
+ map.put(CACHE_READ_TOKENS,
+ currentState.value(CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
+ map.put(CACHE_WRITE_TOKENS,
+ currentState.value(CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
+ map.put(REASONING_TOKENS,
+ currentState.value(REASONING_TOKENS, 0) + result.reasoningTokens());
return this;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
index 56b66517..2bddc46c 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java
@@ -155,6 +155,12 @@ public final class MateClawStateKeys {
// ===== Token Usage 累计(REPLACE 策略,节点内累加后写回)=====
public static final String PROMPT_TOKENS = "prompt_tokens";
public static final String COMPLETION_TOKENS = "completion_tokens";
+ /** Prompt cache 命中 tokens 累计(provider 未上报时保持 0) */
+ public static final String CACHE_READ_TOKENS = "cache_read_tokens";
+ /** Prompt cache 写入 tokens 累计(provider 未上报时保持 0) */
+ public static final String CACHE_WRITE_TOKENS = "cache_write_tokens";
+ /** 思考(reasoning)tokens 累计(provider 未上报时保持 0) */
+ public static final String REASONING_TOKENS = "reasoning_tokens";
// ===== 运行时模型快照(REPLACE 策略,buildInitialState 注入)=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
index fe40501d..238e17fd 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
@@ -335,6 +335,29 @@ public class ApprovalWorkflowService implements ApplicationRunner {
approvalMapper.insert(entity);
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
entity.getId(), runId, workspaceId, kind);
+
+ // ISSUE #413: register the workflow approval into the in-memory map
+ // so the resolve → resume bridge actually fires. Without this, the
+ // row only lives in DB and ApprovalService.getPending("wf-...") returns
+ // null, so performResolve() short-circuits at the "not pending" guard
+ // and never reaches the WorkflowApprovalResolvedEvent publish in
+ // Phase 4 — leaving ApprovalResumeBridge as dead code. Mirrors the
+ // recoverFromDb() snapshot shape exactly.
+ Instant createdAt = entity.getCreatedAt() != null
+ ? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()
+ : Instant.now();
+ PendingApproval snapshot = new PendingApproval(
+ entity.getPendingId(),
+ entity.getConversationId(),
+ /*userId*/ null,
+ entity.getToolName(),
+ entity.getToolArguments(),
+ /*reason*/ entity.getSummary(),
+ createdAt,
+ "pending");
+ snapshot.setSummary(entity.getSummary());
+ approvalService.registerRecovered(snapshot);
+
return entity.getId();
} catch (Exception e) {
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage());
@@ -775,6 +798,17 @@ public class ApprovalWorkflowService implements ApplicationRunner {
/**
* 代理查询方法
*/
+ /**
+ * Look up a pending approval by its exact id. Delegates to the underlying
+ * {@link ApprovalService#getPending} so callers that only hold the workflow
+ * facade (e.g. WebChatController) can fetch the precise record for an IDOR
+ * cross-check without falling back to {@code findPendingByConversation}
+ * (which returns the earliest pending, wrong when several coexist).
+ */
+ public java.util.Optional getPending(String pendingId) {
+ return approvalService.getPending(pendingId);
+ }
+
public PendingApproval findPendingByConversation(String conversationId) {
return approvalService.findPendingByConversation(conversationId);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java
index 07ccad95..1bf490e3 100644
--- a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java
+++ b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java
@@ -50,7 +50,8 @@ public class AuthService {
.eq(UserEntity::getUsername, request.getUsername())
.eq(UserEntity::getEnabled, true));
- if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
+ if (user == null || user.getPassword() == null
+ || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
}
@@ -212,7 +213,10 @@ public class AuthService {
return userMapper.selectById(userId);
}
- private String generateToken(UserEntity user) {
+ /**
+ * 生成 JWT token。SSO 登录路径复用此方法签发格式一致的 token。
+ */
+ public String generateToken(UserEntity user) {
return Jwts.builder()
.subject(user.getUsername())
.claim("userId", user.getId())
diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java
new file mode 100644
index 00000000..e361201a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java
@@ -0,0 +1,33 @@
+package vip.mate.auth.sso;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import vip.mate.auth.sso.provider.FeishuSsoProvider;
+
+/**
+ * SSO 配置。启用 {@link SsoProperties} 绑定 + 按需注册飞书 Provider。
+ *
+ * 仅当 {@code mateclaw.sso.enabled=true} 时此配置生效。飞书 Provider 进一步要求
+ * {@code mateclaw.sso.feishu.enabled=true}。
+ *
+ * @author MateClaw Team
+ */
+@Configuration
+@EnableScheduling
+@EnableConfigurationProperties(SsoProperties.class)
+@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
+public class SsoAutoConfiguration {
+
+ /**
+ * 飞书 SSO Provider。仅当飞书 SSO 启用时注册。
+ */
+ @Bean
+ @ConditionalOnProperty(name = "mateclaw.sso.feishu.enabled", havingValue = "true")
+ public FeishuSsoProvider feishuSsoProvider(SsoProperties ssoProperties, ObjectMapper objectMapper) {
+ return new FeishuSsoProvider(ssoProperties.getFeishu(), objectMapper);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java
new file mode 100644
index 00000000..ca81a91a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java
@@ -0,0 +1,43 @@
+package vip.mate.auth.sso;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import vip.mate.auth.model.LoginResponse;
+
+/**
+ * SSO 回调响应。两种互斥形态由 {@code bindRequired} 区分:
+ *
+ * - {@code bindRequired=false}: 登录成功, {@code loginResponse} 携带 JWT
+ * - {@code bindRequired=true}: link-only 模式未绑定, {@code bindToken} 供前端引导绑定
+ *
+ *
+ * 替代了原先用 {@code R.fail(200, Map.toString())} 传递绑定信号的 hack。
+ *
+ * @author MateClaw Team
+ */
+@Data
+@AllArgsConstructor
+public class SsoCallbackResponse {
+
+ /** link-only 模式下未绑定时为 true */
+ private boolean bindRequired;
+
+ /** 登录成功时非空 */
+ private LoginResponse loginResponse;
+
+ /** bindRequired=true 时非空, 供前端调 /sso/bind */
+ private String bindToken;
+
+ private String provider;
+ private String displayName;
+
+ /** 登录成功响应工厂 */
+ public static SsoCallbackResponse of(LoginResponse loginResponse) {
+ return new SsoCallbackResponse(false, loginResponse, null, null, null);
+ }
+
+ /** 需绑定响应工厂 (link-only 模式) */
+ public static SsoCallbackResponse bindRequired(String bindToken, String provider, String displayName) {
+ return new SsoCallbackResponse(true, null, bindToken, provider, displayName);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java
new file mode 100644
index 00000000..a34e7e3c
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java
@@ -0,0 +1,72 @@
+package vip.mate.auth.sso;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.*;
+import vip.mate.auth.model.LoginResponse;
+import vip.mate.auth.sso.provider.SsoProviderRegistry;
+import vip.mate.common.result.R;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * SSO 单点登录 HTTP 端点。全部 permitAll (与 /auth/login 同级)。
+ *
+ * @author MateClaw Team
+ */
+@Tag(name = "SSO 单点登录")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/auth/sso")
+@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
+@RequiredArgsConstructor
+public class SsoController {
+
+ private final SsoProviderRegistry registry;
+ private final SsoService ssoService;
+
+ @Operation(summary = "列出已启用的 SSO Provider")
+ @GetMapping("/providers")
+ public R>> providers() {
+ List