Merge remote-tracking branch 'origin/dev' into fix/retry-recovery-and-observability
This commit is contained in:
@@ -3023,6 +3023,22 @@
|
||||
"created_at": "2026-04-28T03:32:31Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3695
|
||||
},
|
||||
{
|
||||
"name": "unclok",
|
||||
"id": 5087124,
|
||||
"comment_id": 4335472715,
|
||||
"created_at": "2026-04-28T13:00:37Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3706
|
||||
},
|
||||
{
|
||||
"name": "deopa0402",
|
||||
"id": 107998765,
|
||||
"comment_id": 4336992103,
|
||||
"created_at": "2026-04-28T16:03:18Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 3713
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -177,6 +177,7 @@ export class BackgroundManager {
|
||||
|
||||
|
||||
private tasks: Map<string, BackgroundTask>
|
||||
private tasksByParentSession: Map<string, Set<string>>
|
||||
private notifications: Map<string, BackgroundTask[]>
|
||||
private pendingNotifications: Map<string, string[]>
|
||||
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
|
||||
@@ -218,6 +219,7 @@ export class BackgroundManager {
|
||||
}
|
||||
) {
|
||||
this.tasks = new Map()
|
||||
this.tasksByParentSession = new Map()
|
||||
this.notifications = new Map()
|
||||
this.pendingNotifications = new Map()
|
||||
this.pendingByParent = new Map()
|
||||
@@ -322,6 +324,50 @@ export class BackgroundManager {
|
||||
this.unregisterRootDescendant(task.rootSessionID)
|
||||
}
|
||||
|
||||
private addTask(task: BackgroundTask): void {
|
||||
this.tasks.set(task.id, task)
|
||||
if (!task.parentSessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskIDs = this.tasksByParentSession.get(task.parentSessionID) ?? new Set<string>()
|
||||
taskIDs.add(task.id)
|
||||
this.tasksByParentSession.set(task.parentSessionID, taskIDs)
|
||||
}
|
||||
|
||||
private removeTask(task: BackgroundTask): void {
|
||||
this.tasks.delete(task.id)
|
||||
this.removeTaskFromParentIndex(task.id, task.parentSessionID)
|
||||
}
|
||||
|
||||
private updateTaskParent(task: BackgroundTask, parentSessionID: string): void {
|
||||
if (task.parentSessionID === parentSessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
this.removeTaskFromParentIndex(task.id, task.parentSessionID)
|
||||
task.parentSessionID = parentSessionID
|
||||
const taskIDs = this.tasksByParentSession.get(parentSessionID) ?? new Set<string>()
|
||||
taskIDs.add(task.id)
|
||||
this.tasksByParentSession.set(parentSessionID, taskIDs)
|
||||
}
|
||||
|
||||
private removeTaskFromParentIndex(taskID: string, parentSessionID: string | undefined): void {
|
||||
if (!parentSessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskIDs = this.tasksByParentSession.get(parentSessionID)
|
||||
if (!taskIDs) {
|
||||
return
|
||||
}
|
||||
|
||||
taskIDs.delete(taskID)
|
||||
if (taskIDs.size === 0) {
|
||||
this.tasksByParentSession.delete(parentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
log("[background-agent] launch() called with:", {
|
||||
agent: input.agent,
|
||||
@@ -368,7 +414,7 @@ export class BackgroundManager {
|
||||
}
|
||||
const firstAttempt = startAttempt(task, input.model)
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
this.addTask(task)
|
||||
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
||||
|
||||
// Track for batched notifications immediately (pending state)
|
||||
@@ -745,13 +791,25 @@ The fallback retry session is now created and can be inspected directly.
|
||||
}
|
||||
|
||||
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.parentSessionID === sessionID) {
|
||||
result.push(task)
|
||||
const taskIDs = this.tasksByParentSession.get(sessionID)
|
||||
if (!taskIDs) {
|
||||
const result: BackgroundTask[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.parentSessionID === sessionID) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const tasks: BackgroundTask[] = []
|
||||
for (const taskID of taskIDs) {
|
||||
const task = this.tasks.get(taskID)
|
||||
if (task) {
|
||||
tasks.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return tasks
|
||||
}
|
||||
|
||||
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
|
||||
@@ -830,7 +888,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
const parentChanged = input.parentSessionID !== existingTask.parentSessionID
|
||||
if (parentChanged) {
|
||||
this.cleanupPendingByParent(existingTask) // Clean from OLD parent
|
||||
existingTask.parentSessionID = input.parentSessionID
|
||||
this.updateTaskParent(existingTask, input.parentSessionID)
|
||||
}
|
||||
if (input.parentAgent !== undefined) {
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
@@ -885,7 +943,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
concurrencyGroup,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
this.addTask(task)
|
||||
subagentSessions.add(input.sessionID)
|
||||
this.startPolling()
|
||||
this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt })
|
||||
@@ -935,7 +993,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
existingTask.status = "running"
|
||||
existingTask.completedAt = undefined
|
||||
existingTask.error = undefined
|
||||
existingTask.parentSessionID = input.parentSessionID
|
||||
this.updateTaskParent(existingTask, input.parentSessionID)
|
||||
existingTask.parentMessageID = input.parentMessageID
|
||||
existingTask.parentModel = input.parentModel
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
@@ -1680,7 +1738,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
}
|
||||
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
this.removeTask(task)
|
||||
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
@@ -2360,6 +2418,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
|
||||
this.concurrencyManager.clear()
|
||||
this.tasks.clear()
|
||||
this.tasksByParentSession.clear()
|
||||
this.notifications.clear()
|
||||
this.pendingNotifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
|
||||
@@ -141,6 +141,22 @@ function extractFilePath(metadata: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractLineCount(metadata: unknown): number | undefined {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const objectMeta = metadata as Record<string, unknown>
|
||||
const candidates = [objectMeta.lineCount, objectMeta.linesWritten, objectMeta.lines]
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
|
||||
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
|
||||
return
|
||||
@@ -151,6 +167,12 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
|
||||
return
|
||||
}
|
||||
|
||||
const metadataLineCount = extractLineCount(output.metadata)
|
||||
if (metadataLineCount !== undefined) {
|
||||
output.output = `${WRITE_SUCCESS_MARKER} ${metadataLineCount} lines written.`
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = extractFilePath(output.metadata)
|
||||
if (!filePath) {
|
||||
return
|
||||
|
||||
@@ -11,9 +11,9 @@ function mockCtx(): PluginInput {
|
||||
return {
|
||||
client: {} as PluginInput["client"],
|
||||
directory: "/test",
|
||||
project: "/test" as unknown as PluginInput["project"],
|
||||
project: "/test" as PluginInput["project"],
|
||||
worktree: "/test",
|
||||
serverUrl: "http://localhost" as unknown as PluginInput["serverUrl"],
|
||||
serverUrl: "http://localhost" as PluginInput["serverUrl"],
|
||||
$: {} as PluginInput["$"],
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,26 @@ describe("hashline-read-enhancer", () => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("uses write metadata line count without reading the file", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
const input = { tool: "write", sessionID: "s", callID: "c" }
|
||||
const output = {
|
||||
title: "write",
|
||||
output: "Wrote file successfully.",
|
||||
metadata: {
|
||||
filepath: "/tmp/hashline-metadata-fast-path-missing-file.ts",
|
||||
lineCount: 7,
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("File written successfully. 7 lines written.")
|
||||
})
|
||||
|
||||
it("does not overwrite write tool error output with success message", async () => {
|
||||
//#given — write tool failed, but stale file exists from previous write
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||
|
||||
@@ -43,6 +43,7 @@ interface ClientLike {
|
||||
export interface AssistantCompactionMessageInfo {
|
||||
sessionID: string
|
||||
id?: string
|
||||
parts?: unknown
|
||||
}
|
||||
|
||||
async function withTimeout<TValue>(
|
||||
@@ -185,6 +186,7 @@ export function createPostCompactionDegradationMonitor(args: {
|
||||
sessionID: info.sessionID,
|
||||
messageID: info.id,
|
||||
directory,
|
||||
parts: info.parts,
|
||||
})
|
||||
|
||||
if (!isNoTextTail) {
|
||||
|
||||
@@ -46,8 +46,13 @@ export async function resolveNoTextTailFromSession(args: {
|
||||
sessionID: string
|
||||
messageID?: string
|
||||
directory: string
|
||||
parts?: unknown
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, messageID, directory } = args
|
||||
const { client, sessionID, messageID, directory, parts } = args
|
||||
|
||||
if (Array.isArray(parts)) {
|
||||
return isStepOnlyNoTextParts(parts)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
|
||||
@@ -192,4 +192,28 @@ describe("preemptive-compaction post-compaction degradation monitor", () => {
|
||||
// then
|
||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("uses message update parts without refetching session messages", async () => {
|
||||
// given
|
||||
const sessionHistory: AssistantHistoryMessage[] = []
|
||||
const ctx = createMockCtx(sessionHistory)
|
||||
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||
const sessionID = "ses_tail_update_parts"
|
||||
const stepOnlyParts = [{ type: "step-start" }, { type: "step-finish" }]
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_1", parts: stepOnlyParts }))
|
||||
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_2", parts: stepOnlyParts }))
|
||||
|
||||
// then
|
||||
expect(ctx.client.session.messages).not.toHaveBeenCalled()
|
||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,7 @@ export function createPreemptiveCompactionHook(
|
||||
modelID?: string
|
||||
finish?: boolean
|
||||
tokens?: TokenInfo
|
||||
parts?: unknown
|
||||
} | undefined
|
||||
|
||||
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
||||
@@ -92,6 +93,7 @@ export function createPreemptiveCompactionHook(
|
||||
await postCompactionMonitor.onAssistantMessageUpdated({
|
||||
sessionID: info.sessionID,
|
||||
id: info.id,
|
||||
parts: info.parts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
|
||||
import { handleSessionIdle } from "./idle-event"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
import type { ContinuationProgressUpdate, SessionState } from "./types"
|
||||
|
||||
function createStateStore(): {
|
||||
store: SessionStateStore
|
||||
resetCalls: string[]
|
||||
} {
|
||||
const state: SessionState = {
|
||||
stagnationCount: 0,
|
||||
consecutiveFailures: 0,
|
||||
}
|
||||
const resetCalls: string[] = []
|
||||
const progressUpdate: ContinuationProgressUpdate = {
|
||||
previousStagnationCount: 0,
|
||||
stagnationCount: 0,
|
||||
hasProgressed: false,
|
||||
progressSource: "none",
|
||||
}
|
||||
|
||||
return {
|
||||
resetCalls,
|
||||
store: {
|
||||
getState: () => state,
|
||||
getExistingState: () => state,
|
||||
startPruneInterval: () => {},
|
||||
recordActivity: () => {},
|
||||
trackContinuationProgress: () => progressUpdate,
|
||||
resetContinuationProgress: (sessionID: string) => {
|
||||
resetCalls.push(sessionID)
|
||||
},
|
||||
cancelCountdown: () => {},
|
||||
cleanup: () => {},
|
||||
cancelAllCountdowns: () => {},
|
||||
shutdown: () => {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("handleSessionIdle", () => {
|
||||
it("resets continuation progress once when todos are empty", async () => {
|
||||
// given
|
||||
const sessionID = "ses_empty_todos"
|
||||
const { store, resetCalls } = createStateStore()
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
|
||||
// when
|
||||
await handleSessionIdle({
|
||||
ctx: ctx as never,
|
||||
sessionID,
|
||||
sessionStateStore: store,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(resetCalls).toEqual([sessionID])
|
||||
})
|
||||
|
||||
it("resets continuation progress once when every todo is complete", async () => {
|
||||
// given
|
||||
const sessionID = "ses_completed_todos"
|
||||
const { store, resetCalls } = createStateStore()
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
todo: async () => ({
|
||||
data: [
|
||||
{ id: "todo-1", content: "Ship", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Verify", status: "completed", priority: "medium" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
|
||||
// when
|
||||
await handleSessionIdle({
|
||||
ctx: ctx as never,
|
||||
sessionID,
|
||||
sessionStateStore: store,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(resetCalls).toEqual([sessionID])
|
||||
})
|
||||
})
|
||||
@@ -108,7 +108,6 @@ export async function handleSessionIdle(args: {
|
||||
}
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||
return
|
||||
@@ -116,7 +115,6 @@ export async function handleSessionIdle(args: {
|
||||
|
||||
const incompleteCount = getIncompleteCount(todos)
|
||||
if (incompleteCount === 0) {
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||
return
|
||||
|
||||
+6
-2
@@ -1,4 +1,5 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import type { PluginContext } from "./types";
|
||||
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
import { resetMessageCursor } from "../shared";
|
||||
import { getAgentConfigKey } from "../shared/agent-display-names";
|
||||
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
|
||||
import { invalidateContextWindowUsageCache } from "../shared/dynamic-truncator";
|
||||
import { log } from "../shared/logger";
|
||||
import { shouldRetryError } from "../shared/model-error-classifier";
|
||||
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
||||
@@ -161,8 +163,7 @@ export function createEventHandler(args: {
|
||||
body: { parts: Array<{ type: "text"; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
summarize: (...args: any[]) => Promise<unknown>;
|
||||
summarize: (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -476,6 +477,9 @@ export function createEventHandler(args: {
|
||||
const sessionID = info?.sessionID as string | undefined;
|
||||
const agent = info?.agent as string | undefined;
|
||||
const role = info?.role as string | undefined;
|
||||
if (sessionID && info?.finish === true) {
|
||||
invalidateContextWindowUsageCache(pluginContext as PluginInput, sessionID);
|
||||
}
|
||||
if (sessionID && role === "user") {
|
||||
const isCompactionMessage = agent ? isCompactionAgent(agent) : false;
|
||||
if (agent && !isCompactionMessage) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, it, afterEach } from "bun:test"
|
||||
|
||||
import { getContextWindowUsage } from "./dynamic-truncator"
|
||||
import { getContextWindowUsage, invalidateContextWindowUsageCache } from "./dynamic-truncator"
|
||||
|
||||
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
|
||||
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
|
||||
@@ -53,6 +53,39 @@ function createContextUsageMockContext(
|
||||
}
|
||||
}
|
||||
|
||||
function createCountingContextUsageMockContext(inputTokens: number) {
|
||||
let messagesCalls = 0
|
||||
return {
|
||||
ctx: {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => {
|
||||
messagesCalls += 1
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
tokens: {
|
||||
input: inputTokens,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getMessagesCalls: () => messagesCalls,
|
||||
}
|
||||
}
|
||||
|
||||
describe("getContextWindowUsage", () => {
|
||||
afterEach(() => {
|
||||
resetContextLimitEnv()
|
||||
@@ -125,6 +158,39 @@ describe("getContextWindowUsage", () => {
|
||||
expect(usage?.remainingTokens).toBe(82144)
|
||||
})
|
||||
|
||||
it("reuses context usage for repeated calls in the same session", async () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const { ctx, getMessagesCalls } = createCountingContextUsageMockContext(100000)
|
||||
const modelCacheState = { anthropicContext1MEnabled: false }
|
||||
|
||||
// when
|
||||
const firstUsage = await getContextWindowUsage(ctx as never, "ses_cached_usage", modelCacheState)
|
||||
const secondUsage = await getContextWindowUsage(ctx as never, "ses_cached_usage", modelCacheState)
|
||||
|
||||
// then
|
||||
expect(firstUsage?.remainingTokens).toBe(100000)
|
||||
expect(secondUsage?.remainingTokens).toBe(100000)
|
||||
expect(getMessagesCalls()).toBe(1)
|
||||
})
|
||||
|
||||
it("refetches context usage after cache invalidation", async () => {
|
||||
// given
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
const { ctx, getMessagesCalls } = createCountingContextUsageMockContext(100000)
|
||||
const modelCacheState = { anthropicContext1MEnabled: false }
|
||||
|
||||
// when
|
||||
await getContextWindowUsage(ctx as never, "ses_invalidated_usage", modelCacheState)
|
||||
invalidateContextWindowUsageCache(ctx as never, "ses_invalidated_usage")
|
||||
await getContextWindowUsage(ctx as never, "ses_invalidated_usage", modelCacheState)
|
||||
|
||||
// then
|
||||
expect(getMessagesCalls()).toBe(2)
|
||||
})
|
||||
|
||||
it("returns null for non-anthropic providers without a cached limit", async () => {
|
||||
// given
|
||||
const ctx = createContextUsageMockContext(180000, {
|
||||
|
||||
@@ -24,6 +24,66 @@ interface MessageWrapper {
|
||||
info: { role: string } & Partial<AssistantMessageInfo>;
|
||||
}
|
||||
|
||||
type ContextWindowUsage = {
|
||||
usedTokens: number;
|
||||
remainingTokens: number;
|
||||
usagePercentage: number;
|
||||
}
|
||||
|
||||
type ContextWindowUsageClient = Pick<PluginInput["client"], "session">
|
||||
|
||||
const usageCacheByClient = new WeakMap<object, Map<string, Map<string, Promise<ContextWindowUsage | null>>>>()
|
||||
|
||||
function createModelCacheKey(modelCacheState?: ContextLimitModelCacheState): string {
|
||||
if (!modelCacheState) {
|
||||
return "default"
|
||||
}
|
||||
|
||||
const cachedLimits = modelCacheState.modelContextLimitsCache
|
||||
? [...modelCacheState.modelContextLimitsCache.entries()]
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.map(([modelKey, limit]) => `${modelKey}:${limit}`)
|
||||
.join(",")
|
||||
: ""
|
||||
|
||||
return `${modelCacheState.anthropicContext1MEnabled ? "1m" : "200k"}|${cachedLimits}`
|
||||
}
|
||||
|
||||
function getUsageCache(
|
||||
client: ContextWindowUsageClient,
|
||||
modelCacheState?: ContextLimitModelCacheState,
|
||||
): Map<string, Promise<ContextWindowUsage | null>> {
|
||||
let cacheByModelState = usageCacheByClient.get(client)
|
||||
if (!cacheByModelState) {
|
||||
cacheByModelState = new Map()
|
||||
usageCacheByClient.set(client, cacheByModelState)
|
||||
}
|
||||
|
||||
const modelCacheKey = createModelCacheKey(modelCacheState)
|
||||
let cache = cacheByModelState.get(modelCacheKey)
|
||||
if (!cache) {
|
||||
cache = new Map()
|
||||
cacheByModelState.set(modelCacheKey, cache)
|
||||
}
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
export function invalidateContextWindowUsageCache(ctx: PluginInput, sessionID?: string): void {
|
||||
const cacheByModelState = usageCacheByClient.get(ctx.client)
|
||||
if (!cacheByModelState) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const cache of cacheByModelState.values()) {
|
||||
if (sessionID) {
|
||||
cache.delete(sessionID)
|
||||
} else {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface TruncationResult {
|
||||
result: string;
|
||||
truncated: boolean;
|
||||
@@ -112,11 +172,23 @@ export async function getContextWindowUsage(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
modelCacheState?: ContextLimitModelCacheState,
|
||||
): Promise<{
|
||||
usedTokens: number;
|
||||
remainingTokens: number;
|
||||
usagePercentage: number;
|
||||
} | null> {
|
||||
): Promise<ContextWindowUsage | null> {
|
||||
const cache = getUsageCache(ctx.client, modelCacheState)
|
||||
const cached = cache.get(sessionID)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const usagePromise = fetchContextWindowUsage(ctx, sessionID, modelCacheState)
|
||||
cache.set(sessionID, usagePromise)
|
||||
return usagePromise
|
||||
}
|
||||
|
||||
async function fetchContextWindowUsage(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
modelCacheState?: ContextLimitModelCacheState,
|
||||
): Promise<ContextWindowUsage | null> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
|
||||
Reference in New Issue
Block a user