Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/extensions/orchestration/continuation-nudge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,35 @@ describe("ContinuationNudge session-level tool tracking", () => {
expect(guard.hasToolBeenCalledThisSession()).toBe(true)
})

it("resetForModelSwitch clears the session-level tool latch", () => {
const guard = new ContinuationNudge()
guard.resetForNewUserInput()
guard.recordToolCall()
guard.resetForNewUserInput()
expect(guard.hasToolBeenCalledThisSession()).toBe(true)

guard.resetForModelSwitch()

expect(guard.hasToolBeenCalledThisSession()).toBe(false)
expect(guard.hasToolBeenCalledThisCycle()).toBe(false)
expect(guard.hasToolBeenCalledThisRun()).toBe(false)
// A text-only turn after the model switch is treated like a fresh
// session and is not nudged.
expect(guard.evaluateTurn(textOnlyMessage)).toBe(false)
})

it("resetForModelSwitch clears pending nudge response state", () => {
const guard = new ContinuationNudge()
simulateSessionWithPriorToolCall(guard)
guard.evaluateTurn(textOnlyMessage)
expect(guard.isNudgeResponsePending()).toBe(true)

guard.resetForModelSwitch()

expect(guard.isNudgeResponsePending()).toBe(false)
expect(guard.isDoneSignalReceived()).toBe(false)
})

it("does not consume a nudge slot while no tools have been called this session", () => {
// Fresh session, no tools yet — repeated text-only turns must not burn
// the per-cycle budget, so when a tool is eventually called the full
Expand Down Expand Up @@ -396,6 +425,17 @@ describe("ContinuationNudge Agent-pending suppression", () => {
expect(guard.evaluateTurn(textOnlyMessage)).toBe(false)
})

it("resetForModelSwitch does NOT clear pending delegation count", () => {
const guard = new ContinuationNudge()
simulateSessionWithPriorToolCall(guard)
guard.markDelegationCall()
// User switches models while an Agent result is still in flight.
guard.resetForModelSwitch()
// Delegated agents are independent of the orchestrator model, so the
// pending count must survive the switch to keep the nudge suppressed.
expect(guard.evaluateTurn(textOnlyMessage)).toBe(false)
})

it("a regular tool call does NOT clear pending delegation count", () => {
const guard = new ContinuationNudge()
simulateSessionWithPriorToolCall(guard)
Expand Down Expand Up @@ -547,6 +587,18 @@ describe("EmptyTurnNudge", () => {
expect(guard.evaluateTurn(emptyMessage)).toBe(true)
})

it("re-arms after resetForModelSwitch", () => {
const guard = new EmptyTurnNudge()
expect(guard.evaluateTurn(emptyMessage)).toBe(true)
expect(guard.evaluateTurn(emptyMessage)).toBe(true)
expect(guard.evaluateTurn(emptyMessage)).toBe(false)
// A new model gets a fresh empty-turn budget even within the same cycle.
guard.resetForModelSwitch()
expect(guard.evaluateTurn(emptyMessage)).toBe(true)
expect(guard.evaluateTurn(emptyMessage)).toBe(true)
expect(guard.evaluateTurn(emptyMessage)).toBe(false)
})

it("does not nudge when the user aborted the turn (stopReason: aborted)", () => {
const guard = new EmptyTurnNudge()
const aborted = { ...emptyMessage, stopReason: "aborted" as const }
Expand Down
32 changes: 32 additions & 0 deletions src/extensions/orchestration/continuation-nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,27 @@ export class ContinuationNudge {
// would incorrectly allow the nudge to fire while Agents are still in flight.
}

/**
* Reset the session-level tool latch when the active model changes.
*
* A new model has not yet demonstrated tool-calling behaviour in this
* session, so the fresh-session suppression should apply until it makes
* its first tool call. Without this reset, a model that legitimately
* opens with an orientation/clarification text turn (e.g. kimi-k2.7 in
* single-model mode) is immediately nudged because the previous model
* already called tools.
*/
resetForModelSwitch(): void {
this.toolsCalledThisSession = false
this.toolsCalledSinceLastUserInput = false
this.toolsCalledThisAgentRun = false
this.nudgeCountThisCycle = 0
this.nudgeResponsePending = false
this.accumulatedResponseText = ""
// pendingDelegationCount is intentionally NOT reset here — delegated
// agents are independent of which orchestrator model is active.
}

recordToolCall(): void {
this.toolsCalledSinceLastUserInput = true
this.toolsCalledThisAgentRun = true
Expand Down Expand Up @@ -232,6 +253,17 @@ export class EmptyTurnNudge {
resetForNewUserInput(): void {
this.nudgeCountThisCycle = 0
}

/**
* Reset the per-cycle empty-turn budget when the active model changes.
*
* A new model has its own empty-turn behaviour, so it should get the
* full per-cycle budget rather than inheriting any budget consumed by
* the previous model.
*/
resetForModelSwitch(): void {
this.nudgeCountThisCycle = 0
}
}

export const NUDGE_CUSTOM_TYPE = "nudge"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { ContextEvent } from "@earendil-works/pi-coding-agent"
import { describe, expect, it } from "vitest"
import { isKimiK2Model, normalizeKimiToolCallIds } from "./normalize-kimi-tool-call-ids.js"

type ContextMessage = ContextEvent["messages"][number]

function assistantWithToolCall(id: string, name: string): ContextMessage {
return {
role: "assistant",
content: [{ type: "toolCall", id, name, arguments: {} }],
} as unknown as ContextMessage
}

function toolResult(toolCallId: string, toolName: string): ContextMessage {
return {
role: "toolResult",
toolCallId,
toolName,
content: [{ type: "text", text: "ok" }],
} as unknown as ContextMessage
}

function userMessage(): ContextMessage {
return {
role: "user",
content: [{ type: "text", text: "continue" }],
} as unknown as ContextMessage
}

describe("isKimiK2Model", () => {
it.each([
"kimi-k2",
"kimi-k2.5",
"kimi-k2.6",
"kimi-k2.7",
"kimi-k2.7-code",
"kimi-k2-thinking",
])("matches %s", (id) => {
expect(isKimiK2Model(id)).toBe(true)
})

it.each(["kimi-k3", "glm-5.2-fp8", "minimax-m2.7", "deepseek-v4-flash", "gpt-5.4"])("does not match %s", (id) => {
expect(isKimiK2Model(id)).toBe(false)
})

it("does not match undefined", () => {
expect(isKimiK2Model(undefined)).toBe(false)
})
})

describe("normalizeKimiToolCallIds", () => {
it("returns the same array reference when there are no tool calls", () => {
const messages = [userMessage()]
expect(normalizeKimiToolCallIds(messages)).toBe(messages)
})

it("rewrites a non-canonical tool call id and the paired toolResult id", () => {
const result = normalizeKimiToolCallIds([
userMessage(),
assistantWithToolCall("call_abc123", "read"),
toolResult("call_abc123", "read"),
])

const assistant = result[1] as { content: { type: string; id: string }[] }
const resultMsg = result[2] as { toolCallId: string }
expect(assistant.content[0].id).toBe("functions.read:0")
expect(resultMsg.toolCallId).toBe("functions.read:0")
})

it("increments the index globally across calls, keeping the tool name per call", () => {
const result = normalizeKimiToolCallIds([
assistantWithToolCall("call_1", "read"),
toolResult("call_1", "read"),
assistantWithToolCall("call_2", "bash"),
toolResult("call_2", "bash"),
])

expect((result[0] as { content: { id: string }[] }).content[0].id).toBe("functions.read:0")
expect((result[1] as { toolCallId: string }).toolCallId).toBe("functions.read:0")
expect((result[2] as { content: { id: string }[] }).content[0].id).toBe("functions.bash:1")
expect((result[3] as { toolCallId: string }).toolCallId).toBe("functions.bash:1")
})

it("renumbers already-canonical ids so the sequence stays globally unique", () => {
// k2.7's own history uses canonical ids; rest of history may not.
const result = normalizeKimiToolCallIds([
assistantWithToolCall("functions.bash:7", "bash"),
toolResult("functions.bash:7", "bash"),
])

expect((result[0] as { content: { id: string }[] }).content[0].id).toBe("functions.bash:0")
expect((result[1] as { toolCallId: string }).toolCallId).toBe("functions.bash:0")
})

it("leaves toolResults without a matching tool call untouched", () => {
const result = normalizeKimiToolCallIds([
assistantWithToolCall("call_1", "read"),
toolResult("call_orphan", "read"),
])

expect((result[1] as { toolCallId: string }).toolCallId).toBe("call_orphan")
})

it("passes non-matching messages through by reference and does not mutate the input", () => {
const user = userMessage()
const assistant = assistantWithToolCall("call_1", "read")
const before = JSON.stringify([user, assistant])
const result = normalizeKimiToolCallIds([user, assistant])

expect(result[0]).toBe(user)
expect(JSON.stringify([user, assistant])).toBe(before)
})
})
86 changes: 86 additions & 0 deletions src/extensions/prompt-construction/normalize-kimi-tool-call-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* kimi-k2.x historical tool_call ID normalization.
*
* Moonshot trains the K2 family with tool-call IDs in the canonical format
* `functions.{tool_name}:{idx}` (see Kimi-K2 docs/tool_call_guidance.md), and
* k2.x deployments abandon turns (immediate end-of-turn with no content) when
* conversation history carries non-canonical IDs, e.g. OpenAI-style `call_*`
* IDs written by another model during a mid-session model switch.
* Corroborated upstream: MoonshotAI/Kimi-K2#128, vllm-project/vllm#50782.
* Measured for kimchi (getkimchi/kimchi#1063): on a reconstructed 141-message
* / ~75k-token context, kimi-k2.7 acted in only 1/10 trials on canonical
* mismatch and 12/12 after rewriting IDs to the canonical format.
*
* Moonshot's hosted gateway rewrites IDs server-side; LiteLLM (the ai-enabler
* gateway our provider routes through) does not. Until it does, we rewrite
* here for kimi-k2.* targets.
*
* Every assistant toolCall ID is rewritten to `functions.{name}:{idx}` with a
* global incrementing index, and each `toolResult.toolCallId` is remapped with
* the same value so call/result pairing stays intact. Already-canonical IDs
* are renumbered as well: keeping them while rewriting the rest could yield
* duplicate IDs (e.g. a historical `functions.bash:0` colliding with an
* assigned `functions.bash:0`), and a single globally consistent sequence
* matches the training distribution.
*
* Copy-on-write: messages that need no rewriting are returned by reference; if
* the context contains no tool calls at all, the original array is returned.
*/

import type { ContextEvent } from "@earendil-works/pi-coding-agent"

type ContextMessages = ContextEvent["messages"]

const KIMI_K2_ID = /kimi-k2(?:[.-]|$)/i

/** True for kimi-k2.x model ids (kimi-k2.5/2.6/2.7, kimi-k2-thinking, ...). */
export function isKimiK2Model(modelId: string | undefined): boolean {
return !!modelId && KIMI_K2_ID.test(modelId)
}

type ToolCallBlockLite = { type?: string; id?: unknown; name?: unknown }
type MessageLite = { role?: string; content?: unknown; toolCallId?: unknown }

export function normalizeKimiToolCallIds(messages: ContextMessages): ContextMessages {
// Pass 1: assign a canonical ID to every assistant toolCall block, in
// context order.
const renames = new Map<string, string>()
let nextIdx = 0
for (const msg of messages) {
const lite = msg as MessageLite
if (lite.role !== "assistant" || !Array.isArray(lite.content)) continue
for (const block of lite.content as ToolCallBlockLite[]) {
if (block?.type !== "toolCall" || typeof block.id !== "string" || block.id.length === 0) continue
const name = typeof block.name === "string" && block.name.length > 0 ? block.name : "tool"
renames.set(block.id, `functions.${name}:${nextIdx++}`)
}
}
if (renames.size === 0) return messages

// Pass 2: rewrite assistant toolCall IDs and the paired toolResult
// toolCallIds with the same mapping.
return messages.map((msg) => {
const lite = msg as MessageLite
if (lite.role === "assistant" && Array.isArray(lite.content)) {
let changed = false
const content = (lite.content as ToolCallBlockLite[]).map((block) => {
if (block?.type === "toolCall" && typeof block.id === "string") {
const renamed = renames.get(block.id)
if (renamed && renamed !== block.id) {
changed = true
return { ...block, id: renamed }
}
}
return block
})
return changed ? ({ ...(msg as object), content } as ContextMessages[number]) : msg
}
if (lite.role === "toolResult" && typeof lite.toolCallId === "string") {
const renamed = renames.get(lite.toolCallId)
if (renamed && renamed !== lite.toolCallId) {
return { ...(msg as object), toolCallId: renamed } as ContextMessages[number]
}
}
return msg
})
}
Loading
Loading