Skip to content

Commit db4717e

Browse files
fix(plugin): consolidate local runtime hardening (#2286)
## Description This PR consolidates the local-plugin reliability fixes developed and validated on `fix-local-plugin-260824`. ### Highlights - hardens Hermes bridge process detection, PID/status reconciliation, installer rollback, Windows-native provider behavior, and UTF-8 tool output; - makes secret environment fallback explicit and wires first-class `maxTokens` / `headers` configuration through the L3 and Skill Evolver clients; - moves idle-skill archival off the foreground path with bounded, atomic lifecycle work; - recovers useful crystallizer drafts when LLM output omits derivable fields; - bounds startup-recovery shutdown and adds the newest-trace index with collision-safe migration handling; - resolves DeepSeek Harness auxiliary reasoning capability from exact model metadata, with per-route TTL caching, concurrent lookup coalescing, adapter-update invalidation, and registration-bound race fallback. Contribution history is preserved from #2192, #2209, #2246, #2248, #2252, #2253, #2256, and #2284. The capability-driven implementation supersedes the negative probe cache proposed in #2285. No new runtime dependencies are introduced. Related Issue (Required): Fixes #2278 Reviewers: @hijzy @whipser030 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [x] Refactor (does not change functionality, e.g. code style improvements, linting) - [x] Documentation update ## How Has This Been Tested? - [x] Unit Test - `npm run test:unit -- --reporter=dot`: 179 files passed; 1,549 tests passed and 2 skipped. - `npm exec vitest run tests/unit/adapters/deepseek-harness-*.test.ts -- --reporter=dot`: 65/65 passed. - `python3 apps/memos-local-plugin/tests/python/test_hermes_provider_pipeline.py`: 36/36 passed. - [x] Test Script Or Test Steps - `npm run lint` - `npm run build:package` - `npm run check:hermes-version` - Ruff 0.11.8 check and format verification for both changed Python files. - Packed commit `d76c78aa` and installed the identical tarball into DSH `0.1.0-rc.6` `web` profiles on macOS and Windows. - Verified SHA-256 `61e805f862acbe0873581e22cac4592ab0a9ff4d23d607a5f9fac5df93e80253` on both hosts. - Verified unsupported-reasoning routes issue zero rejected `off` probes across repeated/concurrent calls, supported routes retain `off`, adapter invalidation refreshes the capability, and both installed profiles start with healthy Host LLM bridges. - [x] Pipeline Automated API Test - GitHub CI will run after PR creation. > Note: `make format` could not start because Poetry is not installed on the validation host. The equivalent Ruff 0.11.8 checks for the changed Python files passed. ## Checklist - [x] I have performed a self-review of my own code | 我已自行检查了自己的代码 - [x] I have commented my code in hard-to-understand areas | 我已在难以理解的地方对代码进行了注释 - [x] I have added tests that prove my fix is effective or that my feature works | 我已添加测试以证明我的修复有效或功能正常 - [x] I have created related documentation issue/PR in [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) (if applicable) | N/A: no external documentation change is required. - [x] I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR - [x] I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人 ## Reviewer Checklist - [x] closes #2278 - [x] Made sure Checks passed locally; GitHub CI will run on creation - [x] Tests have been provided
2 parents 9119efe + d76c78a commit db4717e

53 files changed

Lines changed: 3757 additions & 417 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/memos-local-plugin/adapters/deepseek-harness/host-llm.ts‎

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type GenerateOptions,
1111
type LlmCallConfig,
1212
type LlmFailure,
13+
type LlmResolvedModelInfo,
1314
type PreparedLlmCall,
1415
type StreamChunk,
1516
} from "@deepseek-ai/dsh-llm";
@@ -29,6 +30,7 @@ const HOST_LLM_TIMEOUT_CODE = "MEMOS_DSH_HOST_LLM_TIMEOUT";
2930
const HOST_LLM_MESSAGE_SOURCE = "memos-local-memory";
3031
const NO_REASONING_EFFORT = ReasoningEffortId("off");
3132
const UNSUPPORTED_REASONING_EFFORT = "UNSUPPORTED_REASONING_EFFORT";
33+
const MODEL_CAPABILITY_TTL_MS = 10 * 60 * 1_000;
3234

3335
/** Atomic provider/model route captured from the DSH agent that owns a turn. */
3436
export interface DeepSeekHarnessLlmRoute {
@@ -40,12 +42,34 @@ export interface DeepSeekHarnessLlmRoute {
4042

4143
/** Public subset of DSH's LLM runtime used by this adapter. */
4244
export interface DeepSeekHarnessLlmLike {
45+
resolveModelInfo(
46+
provider: string,
47+
model: string,
48+
signal?: AbortSignal,
49+
): Promise<LlmResolvedModelInfo>;
4350
prepareCall(
4451
config: LlmCallConfig,
4552
signal?: AbortSignal,
4653
): Promise<PreparedLlmCall>;
4754
}
4855

56+
export interface DeepSeekHarnessHostLlmBridge extends HostLlmBridge {
57+
/** Drop exact-route capability snapshots after a DSH adapter topology update. */
58+
invalidateModelCapabilities(): void;
59+
}
60+
61+
type AuxiliaryReasoningCapability = "off" | "plain";
62+
63+
interface CapabilityCacheEntry {
64+
readonly expiresAt: number;
65+
readonly value: Promise<AuxiliaryReasoningCapability>;
66+
}
67+
68+
interface CapabilityCache {
69+
readonly entries: Map<string, CapabilityCacheEntry>;
70+
generation: number;
71+
}
72+
4973
/**
5074
* Async route scope for MemOS work spawned by one DSH session.
5175
*
@@ -95,9 +119,17 @@ export interface CreateDeepSeekHarnessHostLlmBridgeOptions {
95119
*/
96120
export function createDeepSeekHarnessHostLlmBridge(
97121
options: CreateDeepSeekHarnessHostLlmBridgeOptions,
98-
): HostLlmBridge {
122+
): DeepSeekHarnessHostLlmBridge {
123+
const capabilityCache: CapabilityCache = {
124+
entries: new Map(),
125+
generation: 0,
126+
};
99127
return {
100128
id: HOST_LLM_BRIDGE_ID,
129+
invalidateModelCapabilities(): void {
130+
capabilityCache.generation++;
131+
capabilityCache.entries.clear();
132+
},
101133
async complete(input: HostLlmCompleteInput): Promise<HostLlmCompletion> {
102134
const route = options.routes.current();
103135
if (!route) {
@@ -122,6 +154,7 @@ export function createDeepSeekHarnessHostLlmBridge(
122154
input,
123155
route,
124156
callDeadline.signal,
157+
capabilityCache,
125158
);
126159
const request = createGenerateOptions(input, route, prepared.config);
127160
request.signal = callDeadline.signal;
@@ -217,20 +250,30 @@ function createGenerateOptions(
217250
*
218251
* Retrieval filters and JSON extractors intentionally use small output caps.
219252
* Reusing a conversation's high reasoning effort can spend that entire cap on
220-
* reasoning and produce no JSON/text. DSH effort ids are adapter-owned, so the
221-
* exact registered adapter validates the branded conventional `off` id. Only
222-
* an explicit unsupported-effort result retries without it, preserving the
223-
* adapter/provider default. prepareCall performs no provider generation I/O
224-
* and binds that validation to the same registration used for dispatch, even
225-
* if HMR replaces the route before the returned stream starts.
253+
* reasoning and produce no JSON/text. Resolve exact-model metadata through
254+
* DSH, then cache whether its adapter advertises the conventional `off` id.
255+
* prepareCall remains the final registration-bound authority: if HMR changes
256+
* the route after metadata resolution, an explicit unsupported-effort result
257+
* refreshes the cache and retries with the adapter/provider default.
226258
*/
227259
async function prepareAuxiliaryCall(
228260
llm: DeepSeekHarnessLlmLike,
229261
input: HostLlmCompleteInput,
230262
route: DeepSeekHarnessLlmRoute,
231263
signal: AbortSignal,
264+
capabilityCache: CapabilityCache,
232265
): Promise<PreparedLlmCall> {
233266
const config = createCallConfig(input, route);
267+
const capability = await resolveAuxiliaryReasoningCapability(
268+
llm,
269+
route,
270+
signal,
271+
capabilityCache,
272+
);
273+
if (capability === "plain") {
274+
return llm.prepareCall(config, signal);
275+
}
276+
const preparationGeneration = capabilityCache.generation;
234277
try {
235278
return await llm.prepareCall(
236279
{ ...config, reasoningEffort: NO_REASONING_EFFORT },
@@ -243,10 +286,64 @@ async function prepareAuxiliaryCall(
243286
) {
244287
throw error;
245288
}
289+
// The exact adapter may have changed after the metadata lookup. Preserve
290+
// prepareCall's registration-bound validation as the final authority and
291+
// remember the corrected capability only if no newer topology update has
292+
// already invalidated this preparation generation.
293+
if (capabilityCache.generation === preparationGeneration) {
294+
rememberAuxiliaryReasoningCapability(route, "plain", capabilityCache);
295+
}
246296
return llm.prepareCall(config, signal);
247297
}
248298
}
249299

300+
function capabilityRouteKey(route: DeepSeekHarnessLlmRoute): string {
301+
return JSON.stringify([route.provider, route.model]);
302+
}
303+
304+
function rememberAuxiliaryReasoningCapability(
305+
route: DeepSeekHarnessLlmRoute,
306+
capability: AuxiliaryReasoningCapability,
307+
cache: CapabilityCache,
308+
): void {
309+
cache.entries.set(capabilityRouteKey(route), {
310+
expiresAt: Date.now() + MODEL_CAPABILITY_TTL_MS,
311+
value: Promise.resolve(capability),
312+
});
313+
}
314+
315+
async function resolveAuxiliaryReasoningCapability(
316+
llm: DeepSeekHarnessLlmLike,
317+
route: DeepSeekHarnessLlmRoute,
318+
signal: AbortSignal,
319+
cache: CapabilityCache,
320+
): Promise<AuxiliaryReasoningCapability> {
321+
const key = capabilityRouteKey(route);
322+
const cached = cache.entries.get(key);
323+
if (cached && cached.expiresAt > Date.now()) {
324+
return cached.value;
325+
}
326+
if (cached) cache.entries.delete(key);
327+
328+
let entry: CapabilityCacheEntry;
329+
const value = llm.resolveModelInfo(route.provider, route.model, signal)
330+
.then((info): AuxiliaryReasoningCapability => (
331+
info.reasoning?.efforts.some((effort) => effort.id === NO_REASONING_EFFORT)
332+
? "off"
333+
: "plain"
334+
))
335+
.catch((error: unknown) => {
336+
if (cache.entries.get(key) === entry) cache.entries.delete(key);
337+
throw error;
338+
});
339+
entry = {
340+
expiresAt: Date.now() + MODEL_CAPABILITY_TTL_MS,
341+
value,
342+
};
343+
cache.entries.set(key, entry);
344+
return value;
345+
}
346+
250347
function createCallConfig(
251348
input: HostLlmCompleteInput,
252349
route: DeepSeekHarnessLlmRoute,

‎apps/memos-local-plugin/adapters/deepseek-harness/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import {
3737
createDeepSeekHarnessHostLlmBridge,
3838
DeepSeekHarnessLlmRouteContext,
39+
type DeepSeekHarnessHostLlmBridge,
3940
} from "./host-llm.js";
4041
import { registerDeepSeekHarnessTools } from "./tools.js";
4142

@@ -126,6 +127,16 @@ export function configureDeepSeekHarnessHostLlm(
126127
});
127128
}
128129

130+
/** Refresh exact-model capability snapshots whenever DSH replaces an adapter. */
131+
export function registerDeepSeekHarnessHostLlmCapabilityInvalidation(
132+
ctx: Context,
133+
bridge: DeepSeekHarnessHostLlmBridge,
134+
): () => void {
135+
return ctx.on("llm/adapters-updated", () => {
136+
bridge.invalidateModelCapabilities();
137+
});
138+
}
139+
129140
/** Autonomous recovery has no owning DSH turn from which to capture a route. */
130141
export function deepSeekHarnessAutoRecoveryEnabled(config: ResolvedConfig): boolean {
131142
return config.llm.provider.trim().toLowerCase() !== "host" ||
@@ -247,6 +258,12 @@ export async function apply(
247258
const hostLlmBridge = config.hostLlmEnabled
248259
? createDeepSeekHarnessHostLlmBridge({ llm: ctx.llm, routes })
249260
: null;
261+
if (hostLlmBridge) {
262+
registrations.push(registerDeepSeekHarnessHostLlmCapabilityInvalidation(
263+
ctx,
264+
hostLlmBridge,
265+
));
266+
}
250267
const autoRecoveryEnabled = deepSeekHarnessAutoRecoveryEnabled(memoryConfig);
251268

252269
core = await bootstrapMemoryCore({

‎apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,12 +1504,12 @@ def get_tool_schemas(self) -> list[dict[str, Any]]: # type: ignore[override]
15041504

15051505
def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any) -> str: # type: ignore[override]
15061506
if not self._bridge:
1507-
return json.dumps({"error": "bridge not connected"})
1507+
return json.dumps({"error": "bridge not connected"}, ensure_ascii=False)
15081508
try:
15091509
if tool_name == "memos_search":
15101510
query = (args.get("query") or "").strip()
15111511
if not query:
1512-
return json.dumps({"error": "missing query"})
1512+
return json.dumps({"error": "missing query"}, ensure_ascii=False)
15131513
max_results = self._int_arg(args, "maxResults", 10, 1, 50)
15141514
params: dict[str, Any] = {
15151515
"agent": "hermes",
@@ -1528,11 +1528,11 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
15281528
params,
15291529
timeout=_LONG_RPC_TIMEOUT,
15301530
)
1531-
return json.dumps({"hits": resp.get("hits", [])})
1531+
return json.dumps({"hits": resp.get("hits", [])}, ensure_ascii=False)
15321532
if tool_name == "memos_get":
15331533
item_id = (args.get("id") or "").strip()
15341534
if not item_id:
1535-
return json.dumps({"error": "missing id"})
1535+
return json.dumps({"error": "missing id"}, ensure_ascii=False)
15361536
kind = args.get("kind") or "trace"
15371537
methods = {
15381538
"trace": "memory.get_trace",
@@ -1541,12 +1541,14 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
15411541
}
15421542
method = methods.get(kind)
15431543
if method is None:
1544-
return json.dumps({"error": f"unknown memory kind: {kind}"})
1544+
return json.dumps({"error": f"unknown memory kind: {kind}"}, ensure_ascii=False)
15451545
item = self._bridge_request_with_retry(
15461546
method, {"id": item_id, "namespace": self._runtime_namespace()}
15471547
)
15481548
if not item:
1549-
return json.dumps({"found": False, "kind": kind, "id": item_id})
1549+
return json.dumps(
1550+
{"found": False, "kind": kind, "id": item_id}, ensure_ascii=False
1551+
)
15501552
if kind == "trace":
15511553
body = self._clip(item.get("agentText") or item.get("body"))
15521554
meta = {
@@ -1584,7 +1586,8 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
15841586
"id": item.get("id", item_id),
15851587
"body": body,
15861588
"meta": meta,
1587-
}
1589+
},
1590+
ensure_ascii=False,
15881591
)
15891592
if tool_name == "memos_timeline":
15901593
resp = self._bridge_request_with_retry(
@@ -1596,13 +1599,16 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
15961599
)
15971600
limit = self._int_arg(args, "limit", 20, 1, 100)
15981601
traces = resp.get("traces", [])[:limit]
1599-
return json.dumps({"traces": traces})
1602+
return json.dumps({"traces": traces}, ensure_ascii=False)
16001603
if tool_name == "memos_skill_list":
16011604
limit = self._int_arg(args, "limit", 10, 1, 50)
16021605
params = {"limit": limit, "namespace": self._runtime_namespace()}
16031606
if args.get("status"):
16041607
params["status"] = args["status"]
1605-
return json.dumps(self._bridge_request_with_retry("skill.list", params))
1608+
return json.dumps(
1609+
self._bridge_request_with_retry("skill.list", params),
1610+
ensure_ascii=False,
1611+
)
16061612
if tool_name == "memos_environment":
16071613
query = (args.get("query") or "").strip()
16081614
limit = self._int_arg(args, "limit", 5, 1, 30)
@@ -1621,7 +1627,8 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
16211627
for w in resp.get("worldModels", [])
16221628
],
16231629
"queried": False,
1624-
}
1630+
},
1631+
ensure_ascii=False,
16251632
)
16261633
resp = self._bridge_request_with_retry(
16271634
"memory.search",
@@ -1651,12 +1658,13 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
16511658
for h in hits[:limit]
16521659
],
16531660
"queried": True,
1654-
}
1661+
},
1662+
ensure_ascii=False,
16551663
)
16561664
if tool_name == "memos_skill_get":
16571665
skill_id = (args.get("id") or "").strip()
16581666
if not skill_id:
1659-
return json.dumps({"error": "missing id"})
1667+
return json.dumps({"error": "missing id"}, ensure_ascii=False)
16601668
skill = self._bridge_request_with_retry(
16611669
"skill.get",
16621670
{
@@ -1667,10 +1675,10 @@ def handle_tool_call(self, tool_name: str, args: dict[str, Any], **_kwargs: Any)
16671675
"episodeId": self._episode_id or None,
16681676
},
16691677
)
1670-
return json.dumps({"found": bool(skill), "skill": skill})
1678+
return json.dumps({"found": bool(skill), "skill": skill}, ensure_ascii=False)
16711679
except Exception as err:
1672-
return json.dumps({"error": str(err)})
1673-
return json.dumps({"error": f"unknown tool: {tool_name}"})
1680+
return json.dumps({"error": str(err)}, ensure_ascii=False)
1681+
return json.dumps({"error": f"unknown tool: {tool_name}"}, ensure_ascii=False)
16741682

16751683
# ─── Config schema (for `hermes memory setup`) ────────────────────────
16761684

0 commit comments

Comments
 (0)