From fa64b853a80d2014759b7486d0071fa524d67455 Mon Sep 17 00:00:00 2001 From: Dmatut7 <2966283641@qq.com> Date: Sun, 14 Jun 2026 00:28:15 +0800 Subject: [PATCH 1/3] fix(agent-core): keep MCP tool-name hash suffix to 8 hex chars `stableHash8` hex-encoded a value produced by `Math.imul`, which returns a signed 32-bit int. For the ~50% of inputs that hash negative, `toString(16)` emitted a leading `-`, yielding a 9-char `-xxxxxxxx` suffix (and `-` is not a hex digit) instead of the documented deterministic 8-char hash used when a qualified MCP tool name exceeds 64 chars. Coerce to unsigned 32-bit before encoding so the suffix is always 8 lowercase-hex characters. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent-core/src/mcp/tool-naming.ts | 6 +++++- packages/agent-core/test/mcp/tool-naming.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/mcp/tool-naming.ts b/packages/agent-core/src/mcp/tool-naming.ts index 87dca29a5..749eed8bb 100644 --- a/packages/agent-core/src/mcp/tool-naming.ts +++ b/packages/agent-core/src/mcp/tool-naming.ts @@ -45,5 +45,9 @@ function stableHash8(input: string): string { hash ^= input.codePointAt(i)!; hash = Math.trunc(Math.imul(hash, 0x01000193)); } - return hash.toString(16).padStart(8, '0'); + // `Math.imul` yields a signed 32-bit int, so coerce to unsigned before + // hex-encoding — otherwise a negative hash renders as a 9-char `-xxxxxxxx` + // suffix (the `-` is not a hex digit), breaking the documented 8-char hash. + const unsigned = hash < 0 ? hash + 0x1_0000_0000 : hash; + return unsigned.toString(16).padStart(8, '0'); } diff --git a/packages/agent-core/test/mcp/tool-naming.test.ts b/packages/agent-core/test/mcp/tool-naming.test.ts index 7c26b8855..f0b349536 100644 --- a/packages/agent-core/test/mcp/tool-naming.test.ts +++ b/packages/agent-core/test/mcp/tool-naming.test.ts @@ -43,6 +43,11 @@ describe('qualifyMcpToolName', () => { const name = qualifyMcpToolName(server, tool); expect(name.length).toBeLessThanOrEqual(64); expect(name.startsWith('mcp__')).toBe(true); + // The suffix must be a deterministic 8-char lowercase-hex hash. `Math.imul` + // yields a signed int, so a negative hash must not leak a `-` sign (which + // would also make the suffix 9 chars). These inputs hash negative. + const suffix = name.slice(name.lastIndexOf('_') + 1); + expect(suffix).toMatch(/^[0-9a-f]{8}$/); // Same input → same output (stable hash). expect(qualifyMcpToolName(server, tool)).toBe(name); }); From 69d6f41658b4b9d8cee8cf6500497c0d8a3987b4 Mon Sep 17 00:00:00 2001 From: Dmatut7 <2966283641@qq.com> Date: Sun, 14 Jun 2026 00:28:15 +0800 Subject: [PATCH 2/3] fix(agent-core): drop injection marker when it is compacted into the summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onContextCompacted` remaps an injection's stored index after compaction replaces the first `compactedCount` messages with a single summary at index 0. A surviving injection (old index >= compactedCount) maps to new index >= 1, but the guard kept any `newInjectedAt >= 0`. When the injection was the last message in the compacted prefix (`injectedAt === compactedCount - 1`), `newInjectedAt` is 0 — so it was wrongly pinned to the summary message instead of being cleared. That stale marker made PluginSessionStartInjector never re-inject and PlanModeInjector emit a weaker (sparse/none) reminder right after the full one was compacted away. Require `newInjectedAt >= 1` so a compacted-away injection becomes null and re-injects as intended. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/agent/injection/injector.ts | 8 +++- .../test/agent/injection/manager.test.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/agent/injection/injector.ts b/packages/agent-core/src/agent/injection/injector.ts index 504e412de..404403a23 100644 --- a/packages/agent-core/src/agent/injection/injector.ts +++ b/packages/agent-core/src/agent/injection/injector.ts @@ -11,8 +11,14 @@ export abstract class DynamicInjector { onContextCompacted(compactedCount: number): void { if (this.injectedAt !== null) { + // applyCompaction replaces the first `compactedCount` messages with a + // single summary at index 0, so a surviving injection (old index >= + // compactedCount) maps to new index >= 1. An injection that was inside + // the compacted prefix — including the last one (injectedAt === + // compactedCount - 1, which yields 0) — was folded into the summary and + // must become null rather than pointing at the summary itself. const newInjectedAt = this.injectedAt - compactedCount + 1; - this.injectedAt = newInjectedAt >= 0 ? newInjectedAt : null; + this.injectedAt = newInjectedAt >= 1 ? newInjectedAt : null; } } diff --git a/packages/agent-core/test/agent/injection/manager.test.ts b/packages/agent-core/test/agent/injection/manager.test.ts index a8a91ea93..63f8b6297 100644 --- a/packages/agent-core/test/agent/injection/manager.test.ts +++ b/packages/agent-core/test/agent/injection/manager.test.ts @@ -37,6 +37,19 @@ class BoomInjector extends DynamicInjector { } } +class ProbeInjector extends DynamicInjector { + override readonly injectionVariant = 'probe_test'; + protected override getInjection(): string | undefined { + return undefined; + } + setInjectedAt(value: number | null): void { + (this as unknown as { injectedAt: number | null }).injectedAt = value; + } + getInjectedAt(): number | null { + return (this as unknown as { injectedAt: number | null }).injectedAt; + } +} + function installInjectors(manager: InjectionManager, injectors: DynamicInjector[]): void { (manager as unknown as { injectors: DynamicInjector[] }).injectors = injectors; } @@ -112,3 +125,28 @@ describe('InjectionManager registration', () => { expect(injectors.some((injector) => injector instanceof TodoListReminderInjector)).toBe(true); }); }); + +describe('DynamicInjector.onContextCompacted index remapping', () => { + it('remaps a surviving injection to its post-summary index', () => { + const ctx = testAgent(); + const probe = new ProbeInjector(ctx.agent); + probe.setInjectedAt(5); // old index 5 + probe.onContextCompacted(3); // first 3 messages folded into the summary at index 0 + expect(probe.getInjectedAt()).toBe(3); // 5 - 3 + 1 + }); + + it('nulls an injection folded into the summary, including the last compacted message', () => { + const ctx = testAgent(); + // Boundary: injectedAt === compactedCount - 1 was previously remapped to 0 + // (pointing at the summary) instead of null. + const last = new ProbeInjector(ctx.agent); + last.setInjectedAt(2); + last.onContextCompacted(3); // indices 0..2 compacted away + expect(last.getInjectedAt()).toBeNull(); + + const earlier = new ProbeInjector(ctx.agent); + earlier.setInjectedAt(0); + earlier.onContextCompacted(3); + expect(earlier.getInjectedAt()).toBeNull(); + }); +}); From 89bc94e0b93c1268699e3c0609d09aa9496ebca1 Mon Sep 17 00:00:00 2001 From: Dmatut7 <2966283641@qq.com> Date: Sun, 14 Jun 2026 00:28:44 +0800 Subject: [PATCH 3/3] chore(changeset): note agent-core MCP hash + injection compaction fixes Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/agent-core-mcp-hash-and-injection-compaction.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/agent-core-mcp-hash-and-injection-compaction.md diff --git a/.changeset/agent-core-mcp-hash-and-injection-compaction.md b/.changeset/agent-core-mcp-hash-and-injection-compaction.md new file mode 100644 index 000000000..85a28f89a --- /dev/null +++ b/.changeset/agent-core-mcp-hash-and-injection-compaction.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Fix two agent-core edge cases: long MCP tool names now always get an 8-char hex hash suffix (a signed-hash bug could emit a 9-char `-xxxxxxxx` suffix), and an injected system reminder that gets folded into a compaction summary is now cleared instead of being pinned to the summary message — so plugin session-start blocks re-inject and plan-mode reminders stay at full strength after compaction.