fix(memory): quiet transient capture timeouts and stop same-turn retries

This commit is contained in:
Johnny Eric Amancio
2026-08-06 18:08:52 +02:00
parent 73844ae68d
commit fdc46654c5
22 changed files with 446 additions and 401 deletions
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-memory": patch
"kilo-code": patch
---
Reduce noisy memory timeout warnings and retry transient background consolidation failures once.
@@ -126,7 +126,7 @@ export namespace MemoryOperations {
const adds = input.ops
.filter((item): item is Add => item.action === "add")
// Redact rejected text too: this filter runs before the secret one, so a rejected op never
// reaches it, and skips flow into the persistent decisions audit (/memory/show, TUI).
// reaches it, and skips remain visible in the operation result.
.filter((op) => {
const item = reject(op)
if (!item) return true
@@ -314,7 +314,7 @@ export namespace MemoryOperations {
}
}
async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) {
async function persist(input: { root: string; state: MemorySchema.State; count: number }) {
const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state })
await MemoryFiles.writeState(input.root, {
...input.state,
@@ -323,7 +323,6 @@ export namespace MemoryOperations {
lastOperationCount: input.count,
},
})
await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`)
return index
}
@@ -365,9 +364,9 @@ export namespace MemoryOperations {
const prepared = prepare({ state, ops: input.ops, max: state.limits.maxLineChars })
const removes = input.ops.filter((item): item is Remove => item.action === "remove")
const plan = planOps({ docs, inventory, removes, adds: prepared.adds, now: Date.now() })
// Commit (IO): write changed documents, then rebuild the index, persist state, and audit.
// Commit (IO): write changed documents, then rebuild the index and persist state.
await writeDocs({ root: input.root, plan })
const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed })
const index = await persist({ root: input.root, state, count: plan.count })
return {
operationCount: plan.count,
added: plan.added,
+69 -4
View File
@@ -1,9 +1,12 @@
import { MemoryOperations } from "./operations"
import { MemoryRedact } from "./redact"
import { MemoryShared } from "../recall/shared"
import { TRANSIENT } from "../schema"
import type { MemoryFiles } from "../storage/store"
import type { CaptureSkip } from "./parse"
export { TRANSIENT }
export type CaptureSourceItem = {
id: string
text: string
@@ -69,14 +72,75 @@ export function errorReason(err: unknown) {
return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500)
}
export function guardReason(input: string) {
const value = input.toLowerCase()
if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard"
if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value))
const RATE_TEXT = /\b(429|rate[_ -]?limit|too many requests)\b/
const QUOTA_TEXT = /\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/
const TIMEOUT_TEXT =
/\b(timeouterror|etimedout|deadline[ _-]?exceeded|timed out|(connect|headers|body|gateway)[ _-]?time[ -]?out)\b|^timeout$/
const TIMEOUT_CODES = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"])
/** Structural timeout detection over an error's name/status/code, its message text, and its nested
* cause/errors chains (cycle-safe). Message matching is limited to `message` — never the serialized
* body/data blobs — so response payloads that merely mention "timeout" cannot classify as transient. */
function timedOut(item: unknown, seen = new WeakSet<object>()): boolean {
if (!item || typeof item !== "object" || seen.has(item)) return false
seen.add(item)
const err = item as {
name?: unknown
code?: unknown
status?: unknown
statusCode?: unknown
message?: unknown
cause?: unknown
errors?: unknown
}
if (err.name === "TimeoutError") return true
if (err.status === 504 || err.statusCode === 504) return true
if (typeof err.code === "string" && TIMEOUT_CODES.has(err.code)) return true
if (typeof err.message === "string" && TIMEOUT_TEXT.test(err.message.toLowerCase())) return true
if (timedOut(err.cause, seen)) return true
return Array.isArray(err.errors) && err.errors.some((entry) => timedOut(entry, seen))
}
function rateGuarded(item: unknown, seen = new WeakSet<object>()): boolean {
if (!item || typeof item !== "object" || seen.has(item)) return false
seen.add(item)
const err = item as {
status?: unknown
statusCode?: unknown
message?: unknown
cause?: unknown
errors?: unknown
}
if (err.status === 429 || err.statusCode === 429) return true
if (typeof err.message === "string" && RATE_TEXT.test(err.message.toLowerCase())) return true
if (rateGuarded(err.cause, seen)) return true
return Array.isArray(err.errors) && err.errors.some((entry) => rateGuarded(entry, seen))
}
function quotaGuarded(item: unknown, seen = new WeakSet<object>()): boolean {
if (!item || typeof item !== "object" || seen.has(item)) return false
seen.add(item)
const err = item as {
message?: unknown
cause?: unknown
errors?: unknown
}
if (typeof err.message === "string" && QUOTA_TEXT.test(err.message.toLowerCase())) return true
if (quotaGuarded(err.cause, seen)) return true
return Array.isArray(err.errors) && err.errors.some((entry) => quotaGuarded(entry, seen))
}
export function guardReason(input: unknown) {
const value = (typeof input === "string" ? input : errorReason(input)).toLowerCase()
if (typeof input === "string" ? RATE_TEXT.test(value) : rateGuarded(input) || RATE_TEXT.test(value))
return "rate_limit_guard"
if (typeof input === "string" ? QUOTA_TEXT.test(value) : quotaGuarded(input) || QUOTA_TEXT.test(value))
return "quota_guard"
if (typeof input === "string" ? TIMEOUT_TEXT.test(value) : timedOut(input)) return TRANSIENT
return undefined
}
/** @deprecated Memory audit persistence was removed. */
export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision {
return {
kind: "typed",
@@ -94,6 +158,7 @@ export function skipped(input: { sessionID: string; reason: string }): MemoryFil
}
}
/** @deprecated Memory audit persistence was removed. */
export function auditOps(ops: MemoryOperations.Op[]) {
return MemoryShared.audit(ops)
}
+18 -104
View File
@@ -1,6 +1,5 @@
import { Cause, Effect } from "effect"
import {
auditOps,
cap,
capturePlan,
digestPrompt,
@@ -18,9 +17,9 @@ import {
parseJson,
parseOps,
salvageTyped,
skipped,
summarize,
summarizeDiffs,
TRANSIENT,
typedPrompt,
usage,
verifySkips,
@@ -105,11 +104,16 @@ export namespace MemoryCapture {
yield* memory.prepare({ root })
const state = yield* memory.state({ root })
const reported = new Set<string>()
const fail = (reason: string) =>
const fail = (reason: string, detail?: string) =>
Effect.promise(async () => {
const safe = MemoryRedact.text(reason)
if (reported.has(safe)) return
reported.add(safe)
if (reason === TRANSIENT)
MemoryLog.warn("memory capture transient failure", {
reason: safe,
detail: MemoryShared.brief(MemoryRedact.text(detail ?? safe), 160),
})
await MemoryEvents.publish({
event: "error",
payload: MemoryEvents.status({
@@ -123,7 +127,6 @@ export namespace MemoryCapture {
})
const skip = (reason: string, opts?: { idleFlush?: boolean }) =>
Effect.gen(function* () {
if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) })
yield* Effect.promise(() =>
MemoryEvents.publish({
event: "status",
@@ -203,23 +206,6 @@ export namespace MemoryCapture {
tokens: 0,
fallback: true,
})
yield* memory.decide({
root,
decision: {
kind: "digest",
trigger: "turn-close",
sessionID: input.sessionID,
result: "fallback",
llm: false,
parsed: false,
fallback: true,
reason: input.reason,
tokens: 0,
operationCount: 1,
skippedCount: 0,
summary: `session digest fallback on ${input.reason ?? "close"}`,
},
})
}
return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined)
}
@@ -232,22 +218,12 @@ export namespace MemoryCapture {
const model =
digestDue || typedCall
? yield* Effect.gen(function* () {
const resolution = yield* input.model.resolve({
? (
yield* input.model.resolve({
configured: input.memoryModel,
session: view.sessionModel,
})
if (resolution.fallback) {
yield* memory.append({
root,
text: `memory_model_config reason=${MemoryShared.brief(
MemoryRedact.text(resolution.fallback.reason),
160,
)} fallback=1`,
})
}
return resolution.handle
})
).handle
: undefined
const digestEffect = digestDue
? Effect.gen(function* () {
@@ -277,9 +253,8 @@ export namespace MemoryCapture {
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` })
const reason = MemoryRedact.text(guardReason(err) ?? raw)
yield* fail(reason, raw)
return { ok: false as const, reason }
}),
),
@@ -296,14 +271,9 @@ export namespace MemoryCapture {
try: () => parseJson(digestSchema, result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.catch(() =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("digest parse_error")
yield* memory.append({
root,
text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`,
})
return undefined
}),
),
@@ -402,9 +372,8 @@ export namespace MemoryCapture {
Effect.gen(function* () {
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
const raw = errorReason(err)
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
yield* fail(reason)
yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` })
const reason = MemoryRedact.text(guardReason(err) ?? raw)
yield* fail(reason, raw)
return { ok: false as const, reason }
}),
),
@@ -423,11 +392,9 @@ export namespace MemoryCapture {
try: () => salvageTyped(result.result.text),
catch: (error) => error,
}).pipe(
Effect.catch((err: unknown) =>
Effect.catch(() =>
Effect.gen(function* () {
const reason = MemoryRedact.text(errorReason(err))
yield* fail("consolidate parse_error")
yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` })
return undefined
}),
),
@@ -475,30 +442,6 @@ export namespace MemoryCapture {
fallback: Boolean(digest.reason),
})
}
if (digestDue) {
yield* memory.decide({
root,
decision: {
kind: "digest",
trigger: "turn-close",
sessionID: input.sessionID,
result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped",
llm: true,
parsed: Boolean(digest.summary && !digest.reason),
fallback: Boolean(digest.reason),
reason: digest.reason,
tokens: digest.tokens,
operationCount: digest.summary ? 1 : 0,
skippedCount: digest.summary ? 0 : 1,
summary: digest.reason
? `session digest used fallback after ${digest.reason}`
: digest.summary
? "session digest saved"
: "session digest skipped",
},
})
}
// Apply adds only: a same-key add supersedes/updates an existing fact in place. reconcile also
// surfaces exact-key auto-removes, but V0 keeps hard removes explicit-only — auto-capture never
// deletes memory it merely paraphrased (or wrongly flags), so reconciled.removes is not applied.
@@ -506,39 +449,11 @@ export namespace MemoryCapture {
const ops = reconciled.ops.slice(0, state.capture.maxOpsPerRun)
const project =
ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined
// Apply-time skips (content gate + secret, both redacted at creation) surface in the typed audit
// record alongside the model's own declared skips.
const applied: CaptureSkip[] = [...generated.skipped, ...(project?.skipped ?? [])]
const count = project?.operationCount ?? 0
if (typedCall) {
yield* memory.decide({
root,
decision: {
kind: "typed",
trigger: "turn-close",
sessionID: input.sessionID,
result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped",
llm: true,
parsed: !generated.fallback,
fallback: generated.fallback,
reason: generated.reason,
tokens: generated.tokens,
operationCount: count,
skippedCount: applied.length,
skipped: applied,
operations: auditOps(ops),
files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))],
summary: generated.fallback
? `typed consolidation skipped after ${generated.reason ?? "model failure"}`
: count > 0
? `typed consolidation saved ${count} ops`
: `typed consolidation skipped ${applied.length} candidates`,
},
})
}
const tokens = digest.tokens + generated.tokens
if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops")
if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) {
if (digestDue || typedCall || count > 0) {
yield* memory.commit({
root,
now,
@@ -546,8 +461,7 @@ export namespace MemoryCapture {
tokens,
count,
digest: Boolean(digest.summary),
typed: typedCall,
skipped: applied,
typed: typedCall && !generated.fallback,
})
}
const updated = yield* memory.state({ root })
+7 -16
View File
@@ -1,5 +1,4 @@
import { Context, Effect, Layer, Semaphore } from "effect"
import { skipLine, type CaptureSkip } from "../capture/capture"
import type { Memory } from "../memory"
import type { MemoryOperations } from "../capture/operations"
import { MemoryRecall } from "../recall/recall"
@@ -104,7 +103,6 @@ type CommitInput = RootInput & {
// shared typed-interval clock (lastTypedConsolidationAt); a digest-only commit must leave it untouched so a
// digest in one session cannot throttle another session's typed capture.
typed: boolean
skipped: CaptureSkip[]
cost?: number
}
@@ -156,10 +154,12 @@ export namespace MemoryService {
readonly recent: (
input: RecentInput,
) => Effect.Effect<Awaited<ReturnType<typeof MemoryFiles.recentSessions>>, Failure>
/** @deprecated Memory audit persistence was removed. */
readonly append: (input: AppendInput) => Effect.Effect<void, Failure>
readonly index: (input: RootInput) => Effect.Effect<Index, Failure>
readonly commit: (input: CommitInput) => Effect.Effect<void, Failure>
readonly recordRecall: (input: RecordRecallInput) => Effect.Effect<void, Failure>
/** @deprecated Memory audit persistence was removed. */
readonly decide: (input: DecideInput) => Effect.Effect<void, Failure>
readonly readSource: (input: ReadSourceInput) => Effect.Effect<string, Failure>
readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore
@@ -200,7 +200,7 @@ export namespace MemoryService {
return Object.fromEntries(entries) as Sources
}),
recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)),
append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)),
append: () => Effect.void,
index: (input) =>
bridge(async () => {
const text = await MemoryFiles.readIndex(input.root)
@@ -219,20 +219,11 @@ export namespace MemoryService {
lastSessionSavedAt: input.digest ? input.now : state.stats.lastSessionSavedAt,
lastConsolidatedMessageID: input.messageID,
lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost,
lastConsolidationTokens: input.tokens,
lastOperationCount: input.count,
lastConsolidationTokens:
input.typed || input.digest ? input.tokens : state.stats.lastConsolidationTokens,
lastOperationCount: input.typed ? input.count : state.stats.lastOperationCount,
},
})
const skip = skipLine(input.skipped)
await MemoryFiles.append(
input.root,
[
`consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`,
skip,
]
.filter(Boolean)
.join(" "),
)
}),
),
recordRecall: (input) =>
@@ -266,7 +257,7 @@ export namespace MemoryService {
}),
})
}),
decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)),
decide: () => Effect.void,
readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)),
// Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one
// semaphore. Each call must be balanced by exactly one `dropLock`.
+1 -23
View File
@@ -1,23 +1,12 @@
import { MemoryShared } from "./recall/shared"
import type { MemoryOperations } from "./capture/operations"
import { MemoryRedact } from "./capture/redact"
/** Human-facing messages and audit views describing an explicit apply result. */
/** Human-facing messages describing an explicit apply result. */
export namespace MemoryNotice {
export function saved(input: { added: number; removed: number }) {
return input.removed > 0 || input.added > 0
}
export function summary(input: { added: number; removed: number; count: number }) {
if (input.added > 0 && input.removed > 0) {
return `explicit memory operation saved ${input.added} and removed ${input.removed}`
}
if (input.added > 0) return `explicit memory operation saved ${input.added} ops`
if (input.removed > 0) return `explicit memory operation removed ${input.removed} entries`
if (input.count > 0) return "explicit memory operation matched no source memory"
return "explicit memory operation had no accepted ops"
}
export function message(input: { ops: MemoryOperations.Op[]; added: number; removed: number; count: number }) {
const refs = MemoryShared.refs(input.ops)
if (input.added > 0 && input.removed > 0) return `Memory updated · ${input.added} saved, ${input.removed} removed`
@@ -25,15 +14,4 @@ export namespace MemoryNotice {
if (input.removed > 0) return `Memory updated · ${input.removed} removed`
return `Memory unchanged · ${input.count} ops`
}
export function skip(input: MemoryOperations.Rejection[]) {
return input.map((item) => (item.reason === "out_of_scope" ? { reason: item.reason } : item))
}
export function ops(input: { ops: MemoryOperations.Op[]; skipped: MemoryOperations.Rejection[] }) {
const blocked = new Set(input.skipped.filter((item) => item.reason === "out_of_scope").map((item) => item.text))
return MemoryShared.audit(
input.ops.filter((item) => item.action !== "add" || !blocked.has(MemoryRedact.text(item.text))),
)
}
}
-59
View File
@@ -8,7 +8,6 @@ import { MemorySchema } from "./schema"
import { MemoryShared } from "./recall/shared"
import { MemoryToken } from "./recall/token"
import { MemorySlug } from "./slug"
import { MemoryRedact } from "./capture/redact"
/** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */
export namespace Memory {
@@ -97,7 +96,6 @@ export namespace Memory {
const state = await MemoryFiles.readState(input.root)
const next = { ...state, enabled: false }
await MemoryFiles.writeState(input.root, next)
await MemoryFiles.append(input.root, `disable ${next.scope} source=command`)
return { root: input.root, state: next }
})
}
@@ -124,16 +122,6 @@ export namespace Memory {
...(input.settings.verbose === undefined ? {} : { verbose: input.settings.verbose }),
}
await MemoryFiles.writeState(input.root, next)
await MemoryFiles.append(
input.root,
[
`settings ${next.scope}`,
input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`,
input.settings.verbose === undefined ? "" : `verbose=${next.verbose}`,
]
.filter(Boolean)
.join(" "),
)
return { root: input.root, state: next }
})
}
@@ -216,24 +204,6 @@ export namespace Memory {
}
const state = await MemoryFiles.readState(input.root)
const ok = MemoryNotice.saved({ added: result.added, removed: result.removed })
if (trigger === "explicit") {
await MemoryFiles.decide(input.root, {
kind: "typed",
trigger,
sessionID: input.sessionID,
result: ok ? "saved" : "skipped",
llm: false,
parsed: true,
fallback: false,
tokens: input.tokens ?? 0,
operationCount: result.operationCount,
skippedCount: result.skipped.length || (ok ? 0 : 1),
skipped: MemoryNotice.skip(result.skipped),
operations: MemoryNotice.ops({ ops: accepted, skipped: result.skipped }),
files: MemoryShared.files(accepted),
summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }),
})
}
return {
root: input.root,
state,
@@ -318,31 +288,6 @@ export namespace Memory {
const hits = result?.hits ?? []
const files = [...new Set(hits.map((hit) => hit.source))]
const topics = [...new Set(hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))]
await MemoryFiles.decide(input.root, {
kind: "recall",
trigger: "targeted-recall",
sessionID: input.sessionID,
result: result ? "recalled" : "skipped",
llm: false,
parsed: false,
fallback: false,
reason: result ? undefined : "no_matches",
query: MemoryShared.brief(MemoryRedact.text(input.query), 240),
topics,
files,
tokens: result?.tokens ?? 0,
operationCount: hits.length,
skippedCount: result ? 0 : 1,
summary: result ? `targeted recall matched ${hits.length} memories` : "targeted recall found no matches",
})
if (result) {
await MemoryFiles.queue(input.root, async () => {
await MemoryFiles.append(
input.root,
`recall session=${input.sessionID ?? ""} hits=${result.hits.length} tokens=${result.tokens} files=${files.join(",")}`,
)
})
}
return { root: input.root, state, result, hits, files, topics }
}
@@ -368,10 +313,6 @@ export namespace Memory {
})
await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles)
const index = await MemoryIndexer.rebuild({ root: input.root, state })
await MemoryFiles.append(
input.root,
`session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`,
)
return { root: input.root, state, skipped: false as const, index }
})
}
@@ -226,7 +226,6 @@ export namespace MemoryIndexer {
return MemoryFiles.queue(input.root, async () => {
const result = await build(input)
await MemoryFiles.writeIndex(input.root, result.text)
await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`)
return result
})
}
@@ -94,6 +94,7 @@ export namespace MemoryShared {
]
}
/** @deprecated Memory audit persistence was removed. */
export function audit(ops: MemoryOperations.Op[]) {
return ops.map((item) =>
item.action === "add"
+6
View File
@@ -1,3 +1,9 @@
/** Wire contract for suppressible background-capture failures: guardReason emits this as the
* `memory.error` reason, and every client surface (TUI toast filter, VS Code provider) matches
* against this same constant rather than a local literal. Lives here (dependency-free module)
* so clients can import it without pulling the capture pipeline into their bundles. */
export const TRANSIENT = "transient" as const
export namespace MemorySchema {
export const VERSION = 1
export const maxStoredDigestSummary = 4_000
+7 -37
View File
@@ -1,15 +1,5 @@
import z from "zod"
import { MemoryFs } from "./fs"
/** Compatibility facade for the removed memory audit log. */
export namespace MemoryAudit {
const Log = z
.object({
kind: z.literal("log"),
summary: z.string(),
time: z.string().optional(),
})
.passthrough()
export type Decision =
| {
kind: "log"
@@ -43,18 +33,14 @@ export namespace MemoryAudit {
}[]
}
function audit(root: string, input: Decision) {
void root
void input
return Promise.resolve()
}
export async function append(root: string, text: string) {
await audit(root, { kind: "log", result: "logged", summary: text })
void root
void text
}
export async function decide(root: string, input: Decision) {
await audit(root, input)
void root
void input
}
export async function readDecisions(root: string) {
@@ -62,24 +48,8 @@ export namespace MemoryAudit {
return ""
}
function record(input: string) {
try {
const data = JSON.parse(input)
const parsed = Log.safeParse(data)
return parsed.success ? parsed.data : undefined
} catch (error) {
if (MemoryFs.parse(error)) return undefined
throw error
}
}
export async function readChanges(root: string) {
const lines = (await readDecisions(root)).split("\n").flatMap((line) => {
const data = record(line)
if (!data) return []
const time = data.time ?? ""
return [`${time} ${data.summary}`.trim()]
})
return lines.join("\n")
void root
return ""
}
}
+3 -8
View File
@@ -1,6 +1,5 @@
import { readdir, rm } from "fs/promises"
import path from "path"
import { MemoryAudit } from "./audit"
import { MemoryFs } from "./fs"
import { MemoryMarkdown } from "./markdown"
import { MemoryPaths } from "./paths"
@@ -19,13 +18,10 @@ export namespace MemoryState {
"corrections.md": "# Corrective Memory\n\n## Corrections\n",
}
async function recover(root: string, file: string, error: unknown) {
async function recover(root: string, file: string) {
await MemoryFs.backup(file)
const state = MemorySchema.missing()
await writeState(root, state)
await MemoryAudit.append(root, `recover state.json error=${MemoryFs.brief(error)}`).catch((err: unknown) =>
MemoryFs.warn("failed to audit memory state recovery", { err, root }),
)
return state
}
@@ -33,14 +29,14 @@ export namespace MemoryState {
const file = MemoryPaths.files(root).state
const data = await MemoryFs.json(file).catch(async (error: unknown) => {
if (MemoryFs.miss(error)) return undefined
if (MemoryFs.parse(error)) return recover(root, file, error)
if (MemoryFs.parse(error)) return recover(root, file)
throw error
})
if (data === undefined) return MemorySchema.missing()
return Promise.resolve()
.then(() => MemorySchema.parse(data))
.catch((error: unknown) => {
if (MemoryFs.parse(error)) return recover(root, file, error)
if (MemoryFs.parse(error)) return recover(root, file)
throw error
})
}
@@ -199,7 +195,6 @@ export namespace MemoryState {
? { ...(await readState(root)), enabled: true, autoInject: true }
: { ...MemorySchema.create(), enabled: true }
await writeState(root, state)
await MemoryAudit.append(root, "enable project source=command")
return state
}
+8 -121
View File
@@ -4,7 +4,6 @@ import { MemoryError, type MemoryError as Failure } from "./effect/errors"
import { MemoryPaths } from "./effect/paths"
import { MemoryService } from "./effect/service"
import { MemoryRecall } from "./recall/recall"
import { MemoryToken } from "./recall/token"
import { MemorySchema } from "./schema"
import recallDescription from "./prompts/tool-memory-recall.txt"
import saveDescription from "./prompts/tool-memory-save.txt"
@@ -115,50 +114,6 @@ export namespace MemoryTool {
}
}
function audit(
memory: MemoryService.Interface,
input: {
root: string
params: RecallParams
current: string
hits: MemoryRecall.Hit[]
skipped?: string
output: string
},
) {
const files = [...new Set(input.hits.map((hit) => hit.source))]
const topics = [...new Set(input.hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))]
const query =
input.params.query ??
(input.params.sessionID
? `sessionID=${input.params.sessionID}`
: input.params.mode === "digest"
? "recent digests"
: undefined)
return memory.decide({
root: input.root,
decision: {
kind: "recall",
trigger: "targeted-recall",
sessionID: input.current,
result: input.hits.length ? "recalled" : "skipped",
llm: false,
parsed: false,
fallback: false,
reason: input.skipped,
query,
topics,
files,
tokens: MemoryToken.estimate(input.output),
operationCount: input.hits.length,
skippedCount: input.hits.length ? 0 : 1,
summary: input.hits.length
? `memory recall returned ${input.hits.length} ${input.params.mode} hits`
: `memory recall found no ${input.params.mode} hits`,
},
})
}
function miss(input: { params: RecallParams; current: string }) {
const self = input.params.sessionID === input.current
if (self && input.params.mode === "digest") {
@@ -239,35 +194,6 @@ export namespace MemoryTool {
})
}
function catalogAudit(
memory: MemoryService.Interface,
input: {
root: string
current: string
query: string
result: { output: string; count: number; files: string[] }
},
) {
return memory.decide({
root: input.root,
decision: {
kind: "recall",
trigger: "targeted-recall",
sessionID: input.current,
result: input.result.count ? "recalled" : "skipped",
llm: false,
parsed: false,
fallback: false,
query: input.query || "all keys",
files: input.result.files,
tokens: MemoryToken.estimate(input.result.output),
operationCount: input.result.count,
skippedCount: input.result.count ? 0 : 1,
summary: `memory catalog listed ${input.result.count} entries`,
},
})
}
function approvalRecall(input: Recall) {
return input.ask({
permission: "kilo_memory_recall",
@@ -285,7 +211,6 @@ export namespace MemoryTool {
return Effect.gen(function* () {
const result = yield* catalog(input.memory, { root: live.root, query })
const safe = { ...result, output: block(result.output) }
yield* catalogAudit(input.memory, { root: live.root, current: live.current, query, result: safe })
yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: result.count })
return {
title: `Kilo memory catalog: ${result.count} entr${result.count === 1 ? "y" : "ies"}`,
@@ -296,22 +221,13 @@ export namespace MemoryTool {
}
function recallQuery(input: Recall, live: Live) {
return Effect.gen(function* () {
const output = "Provide a topic query for typed/search memory recall."
yield* audit(input.memory, {
root: live.root,
params: input.params,
current: live.current,
hits: [],
skipped: "missing_query",
output,
})
return {
return Effect.succeed(
{
title: `Kilo memory ${input.params.mode}: no query`,
output,
output: "Provide a topic query for typed/search memory recall.",
metadata: { sources: [], count: 0 },
} satisfies Result
})
} satisfies Result,
)
}
function recallSearch(input: Recall, live: Live, query: string, mode: MemoryRecall.Mode) {
@@ -327,15 +243,7 @@ export namespace MemoryTool {
limit,
})
const hits = result?.hits ?? []
const self = input.params.sessionID === live.current
const skipped =
input.params.sessionID && input.params.mode === "digest" && hits.length === 0
? self
? "current_session_digest"
: "missing_session_digest"
: undefined
const output = hits.length ? result!.block : miss({ params: input.params, current: live.current })
yield* audit(input.memory, { root: live.root, params: input.params, current: live.current, hits, skipped, output })
yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: hits.length })
if (hits.length === 0) {
@@ -440,29 +348,8 @@ export namespace MemoryTool {
return saved(input)
}
function skip(input: Base & { params: SaveParams }, root: string) {
return Effect.gen(function* () {
const reason = input.params.reason ?? "out_of_scope"
yield* input.memory.decide({
root,
decision: {
kind: "typed",
trigger: "explicit",
sessionID: input.sessionID,
result: "skipped",
llm: false,
parsed: true,
fallback: false,
reason,
tokens: 0,
operationCount: 0,
skippedCount: 1,
skipped: [{ reason }],
summary: `explicit memory save skipped: ${reason}`,
},
})
return skipped({ reason })
})
function skip(input: Base & { params: SaveParams }) {
return Effect.succeed(skipped({ reason: input.params.reason ?? "out_of_scope" }))
}
function forget(input: Save, root: string) {
@@ -497,7 +384,7 @@ export namespace MemoryTool {
const state = yield* input.memory.state({ root })
if (!state.enabled) return disabled()
if (input.params.action === "forget") return yield* forget(input, root)
if (input.params.action === "skip") return yield* skip(input, root)
if (input.params.action === "skip") return yield* skip(input)
return yield* write(input, root)
})
}
+71 -13
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import {
auditOps,
capturePlan,
duplicateOps,
errorReason,
fallbackDigest,
guardReason,
hasSubstantialDiff,
@@ -128,7 +128,7 @@ describe("memory capture parsing", () => {
expect(() => salvageTyped(`{"op":"upsert_project_fact","key":"good_one","value":"Keep this fact."}`)).toThrow()
})
test("redacts secrets in salvaged unsupported ops before they reach the audit", () => {
test("redacts secrets in salvaged unsupported ops before they reach callers", () => {
const parsed = salvageTyped(
`{"operations":[{"op":"not_a_real_op","key":"leak","value":"key is sk-abcdefghijklmnopqrstuvwxyz"}],"skipped":[]}`,
)
@@ -169,17 +169,6 @@ describe("memory capture parsing", () => {
expect(JSON.stringify(parsed.skipped)).not.toContain(secret.slice(0, 20))
})
test("redacts remove audit queries before truncating", () => {
const secret = "sk-" + "a".repeat(40)
const query = "x".repeat(100) + secret
const audit = auditOps([{ action: "remove", query }])
const text = JSON.stringify(audit)
expect(text).toContain("[redacted]")
expect(text).not.toContain(secret)
expect(text).not.toContain(secret.slice(0, 20))
})
test("truncates typed batches beyond the op cap instead of failing", () => {
const ops = Array.from(
{ length: 20 },
@@ -332,6 +321,11 @@ describe("memory capture parsing", () => {
input: base,
expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined },
},
{
name: "expected work: a persisted null typed clock does not throttle capture",
input: { ...base, lastTypedConsolidationAt: null },
expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined },
},
{
name: "expected idle flush: completed turn inside interval skips now",
input: { ...base, priorTime: 900, lastTypedConsolidationAt: 900 },
@@ -650,6 +644,70 @@ describe("memory capture parsing", () => {
)
expect(guardReason("429 too many requests")).toBe("rate_limit_guard")
expect(guardReason("billing credits exhausted")).toBe("quota_guard")
expect(guardReason("request timed out")).toBe("transient")
expect(guardReason("deadline exceeded")).toBe("transient")
expect(guardReason("DeadlineExceeded")).toBe("transient")
expect(guardReason('cause={"code":"ETIMEDOUT"}')).toBe("transient")
expect(guardReason("504 Gateway Timeout")).toBe("transient")
expect(guardReason("Connect Timeout Error")).toBe("transient")
expect(guardReason("Headers Timeout Error")).toBe("transient")
expect(guardReason("Body Timeout Error")).toBe("transient")
expect(guardReason("connect_timeout")).toBe("transient")
expect(guardReason('status=400 body={"error":"invalid parameter: timeout"}')).toBeUndefined()
expect(guardReason("set request timeout to 30000")).toBeUndefined()
const timeout = new Error("request aborted")
timeout.name = "TimeoutError"
expect(guardReason(timeout)).toBe("transient")
expect(errorReason(timeout)).toBe("request aborted")
expect(guardReason(Object.assign(new Error("request failed"), { status: 504 }))).toBe("transient")
expect(
guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT" } })),
).toBe("transient")
expect(
guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_HEADERS_TIMEOUT" } })),
).toBe("transient")
expect(guardReason(Object.assign(new Error("request failed"), { cause: { code: "ETIMEDOUT" } }))).toBe(
"transient",
)
expect(
guardReason(Object.assign(new Error("failed after 2 attempts"), { errors: [{ statusCode: 504 }] })),
).toBe("transient")
expect(guardReason(new Error("set request timeout to 30000"))).toBeUndefined()
expect(guardReason(new Error("request timed out"))).toBe("transient")
expect(guardReason(Object.assign(new Error("request failed"), { cause: new Error("connect timeout") }))).toBe(
"transient",
)
expect(
guardReason(
Object.assign(new Error("failed after 2 attempts"), {
errors: [Object.assign(new Error("rate limit reached"), { status: 429 }), timeout],
}),
),
).toBe("rate_limit_guard")
expect(
guardReason(
Object.assign(new Error("failed after 2 attempts"), {
errors: [new Error("insufficient quota available"), timeout],
}),
),
).toBe("quota_guard")
expect(
guardReason(
Object.assign(new Error("request failed"), {
cause: Object.assign(new Error("rate limit reached"), { status: 429 }),
}),
),
).toBe("rate_limit_guard")
expect(
guardReason(
Object.assign(new Error("timed out"), {
name: "TimeoutError",
cause: new Error("billing credit balance exhausted"),
}),
),
).toBe("quota_guard")
expect(guardReason("timeout after 429 too many requests")).toBe("rate_limit_guard")
expect(guardReason("timeout after quota exceeded")).toBe("quota_guard")
})
test("redacts common secret token shapes", () => {
+16
View File
@@ -50,6 +50,22 @@ describe("memory core package", () => {
})
})
test("legacy audit compatibility APIs remain no-op", async () => {
await use(async (t) => {
await Memory.enable({ root: t.root })
await MemoryFiles.append(t.root, "provider error with sensitive detail")
await MemoryFiles.decide(t.root, {
kind: "typed",
result: "error",
reason: "provider error with sensitive detail",
})
expect(await MemoryFiles.readChanges(t.root)).toBe("")
expect(await MemoryFiles.readDecisions(t.root)).toBe("")
expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false)
})
})
test("prepare removes legacy decisions once from owned memory roots", async () => {
await use(async (t) => {
await Memory.enable({ root: t.root })
@@ -7,6 +7,7 @@ import { digestPrompt, typedPrompt } from "../src/capture/capture"
import { MemoryCapture } from "../src/effect/capture"
import { MemoryEvents } from "../src/effect/events"
import { KiloMemory } from "../src/effect/index"
import { MemoryLog } from "../src/effect/log"
import type { MemoryPorts } from "../src/effect/ports"
import { MemoryService } from "../src/effect/service"
import { MemoryTimers } from "../src/effect/timers"
@@ -49,11 +50,18 @@ function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPor
/** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is
* order-independent (digest and typed run concurrently). */
function model(input: { digest: string; typed: string; fallback?: string; onRun?: (system: string) => void }): MemoryPorts.ModelPort {
function model(input: {
digest: string
typed: string
fallback?: string
fail?: Error
onRun?: (system: string) => void
}): MemoryPorts.ModelPort {
return {
resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }),
run: async ({ system }) => {
input.onRun?.(system)
if (system === typedPrompt && input.fail) throw input.fail
const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}"
return { text, usage: USAGE }
},
@@ -66,6 +74,7 @@ function run(input: {
model: MemoryPorts.ModelPort
memoryModel?: string
reason?: "completed" | "interrupted" | "error"
bypassInterval?: boolean
}) {
return Effect.runPromise(
MemoryCapture.turn({
@@ -75,6 +84,7 @@ function run(input: {
model: input.model,
memoryModel: input.memoryModel,
reason: input.reason ?? "completed",
bypassInterval: input.bypassInterval,
}).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())),
)
}
@@ -107,6 +117,121 @@ describe("MemoryCapture (fake ports)", () => {
}
})
test("typed timeout preserves digest progress without advancing the typed clock", async () => {
const t = await tmp()
const events: MemoryEvents.Status[] = []
const logs: string[] = []
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
MemoryLog.setWarn((message, meta) => logs.push(`${message}:${meta?.reason}:${meta?.detail}`))
MemoryEvents.setSink((input) => {
events.push(input.payload)
})
const result = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}',
typed: "{}",
fail: new DOMException("memory model timed out", "TimeoutError"),
}),
})
expect(result).toMatchObject({ skipped: false, operationCount: 0 })
const state = await MemoryFiles.readState(t.root)
expect(state.stats.lastTypedConsolidationAt).toBeNull()
expect(state.stats.lastSessionSavedAt).toEqual(expect.any(Number))
expect(state.stats.lastConsolidatedMessageID).toBe("msg_assistant")
expect(events.find((item) => item.state === "error")?.reason).toBe("transient")
expect(logs).toEqual(["memory capture transient failure:transient:memory model timed out"])
const calls: string[] = []
const retry = await run({
root: t.root,
session: session(view()),
model: model({
digest: "{}",
typed: '{"operations":[],"skipped":[]}',
onRun: (system) => calls.push(system),
}),
bypassInterval: true,
})
expect(retry).toMatchObject({ skipped: true, reason: "no_new_content" })
expect(calls).toEqual([])
const next = await run({
root: t.root,
session: session(
view({
assistant: "Use bun install, then run the package tests and typecheck.",
lastAssistantID: "msg_assistant_next",
}),
),
model: model({
digest: "{}",
typed: '{"operations":[],"skipped":[]}',
onRun: (system) => calls.push(system),
}),
})
expect(next).toMatchObject({ skipped: false, operationCount: 0 })
expect(calls).toEqual([typedPrompt])
const updated = await MemoryFiles.readState(t.root)
expect(updated.stats.lastTypedConsolidationAt).toEqual(expect.any(Number))
expect(updated.stats.lastConsolidatedMessageID).toBe("msg_assistant_next")
} finally {
MemoryLog.setWarn(() => {})
MemoryEvents.setSink(() => {})
await t.done()
}
})
test("fallback commit preserves metrics from the prior successful typed consolidation", async () => {
const t = await tmp()
try {
await KiloMemory.enable({ root: t.root })
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
const first = await run({
root: t.root,
session: session(view()),
model: model({
digest: '{"topic":"repo setup","summary":"Explored repo setup."}',
typed:
'{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"test_cmd","value":"bun test"}],"skipped":[]}',
}),
})
expect(first).toMatchObject({ skipped: false, operationCount: 1 })
const initial = await MemoryFiles.readState(t.root)
expect(initial.stats.lastOperationCount).toBe(1)
expect(initial.stats.lastTypedConsolidationAt).toEqual(expect.any(Number))
const second = await run({
root: t.root,
session: session(
view({
assistant: "Investigated a timeout edge case.",
lastAssistantID: "msg_assistant_timeout",
}),
),
model: model({
digest: '{"topic":"investigation","summary":"Investigated timeouts."}',
typed: "{}",
fail: new DOMException("memory model timed out", "TimeoutError"),
}),
bypassInterval: true,
})
expect(second).toMatchObject({ skipped: false, operationCount: 0 })
const preserved = await MemoryFiles.readState(t.root)
expect(preserved.stats.lastOperationCount).toBe(1)
expect(preserved.stats.lastTypedConsolidationAt).toBe(initial.stats.lastTypedConsolidationAt)
expect(preserved.stats.lastConsolidatedMessageID).toBe("msg_assistant_timeout")
} finally {
await t.done()
}
})
test("turn-close skips a secret-like op and applies the rest of the batch", async () => {
const t = await tmp()
const events: MemoryEvents.Status[] = []
+5 -2
View File
@@ -1,6 +1,7 @@
import * as path from "path"
import { existsSync } from "fs"
import * as vscode from "vscode"
import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema"
import type {
KiloClient,
Session,
@@ -4307,8 +4308,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
for (const sid of trackedByDir) targets.add(sid)
if (local && active) targets.add(active)
if (targets.size === 0 && local) targets.add(undefined)
const detail =
props.detail && typeof props.detail === "object"
const transient = event.type === "memory.error" && props.reason === MEMORY_TRANSIENT
const detail = transient
? undefined
: props.detail && typeof props.detail === "object"
? props.detail
: event.type === "memory.error" && typeof props.reason === "string"
? { type: "error", message: props.reason, reason: props.reason }
@@ -127,6 +127,43 @@ describe("KiloProvider memory events", () => {
expect(calls).toEqual(["/repo", "/repo"])
})
it("refreshes status without forwarding transient memory errors", async () => {
const calls: string[] = []
const posts: unknown[] = []
const client = {
memory: {
status: async (input: { directory: string }) => {
calls.push(input.directory)
return { data: status(input.directory) }
},
},
} as unknown as KiloClient
const provider = new KiloProvider(
{} as never,
{
getClient: () => client,
} as never,
)
const item = provider as unknown as Internals
item.webview = { postMessage: async (message) => posts.push(message) }
item.currentSession = { id: "ses_active" }
item.trackedSessionIds.add("ses_active")
provider.setSessionDirectory("ses_active", "/repo")
item.handleEvent(
{
type: "memory.error",
properties: { sessionID: "ses_active", reason: "transient" },
},
"/repo",
)
await item.memory.idle()
expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent" }))
expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" }))
expect(calls).toEqual(["/repo"])
})
it("uses the project directory when toggling memory", async () => {
const calls: unknown[] = []
const client = {
@@ -1,3 +1,5 @@
import { TRANSIENT } from "@kilocode/kilo-memory/schema"
type Event = {
on(type: "memory.error", fn: (event: MemoryEvent) => void): void | (() => void)
}
@@ -22,6 +24,7 @@ export namespace MemoryTuiEvents {
}) {
const handler = (event: MemoryEvent) => {
if (event.properties.sessionID && event.properties.sessionID !== input.sessionID) return
if (event.properties.reason === TRANSIENT) return
const detail = event.properties.detail
if (!detail || typeof detail !== "object") {
input.toast.show({
@@ -191,6 +191,7 @@ async function memoryText(input: {
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxRetries: 1,
}
const work = async () => {
if (!openai) return generateText(common)
@@ -210,7 +211,7 @@ async function memoryText(input: {
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
ctl.abort()
reject(new Error("memory model timed out"))
reject(new DOMException("memory model timed out", "TimeoutError"))
}, ms)
})
try {
@@ -276,7 +276,7 @@ describe("memory TUI events", () => {
expect(handlers).toEqual({ "memory.error": [expect.any(Function)] })
})
test("keeps generic and detailed errors visible", async () => {
test("suppresses transient errors while keeping generic and detailed errors visible", async () => {
const shown: string[] = []
const handlers: Record<string, Handler[]> = {}
MemoryTuiEvents.attach({
@@ -298,6 +298,17 @@ describe("memory TUI events", () => {
fn({ properties: { sessionID: "ses_tui_memory", reason: "model failed" } }),
),
)
await Promise.all(
(handlers["memory.error"] ?? []).map((fn) =>
fn({
properties: {
sessionID: "ses_tui_memory",
reason: "transient",
detail: { message: "Memory model timed out" },
},
}),
),
)
await Promise.all(
(handlers["memory.error"] ?? []).map((fn) =>
fn({ properties: { sessionID: "ses_tui_memory", detail: { message: "Memory save failed" } } }),
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { APICallError } from "ai"
import { Effect } from "effect"
import { ModelNotFoundError, type Provider } from "../../../src/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -31,15 +32,22 @@ function mdl(id = mid): Provider.Model {
} as unknown as Provider.Model
}
function lang(outputs = ["{}"]): LanguageModelV3 {
function lang(outputs: (string | Error)[] = ["{}"], calls?: unknown[], hang?: boolean): LanguageModelV3 {
let idx = 0
const next = () => {
const item = outputs[idx++] ?? outputs.at(-1) ?? "{}"
if (item instanceof Error) throw item
return item
}
return {
specificationVersion: "v3",
provider: "test",
modelId: "fake-memory-model",
supportedUrls: {},
doGenerate: async () => {
const text = outputs[idx++] ?? outputs.at(-1) ?? "{}"
doGenerate: async (...args: Parameters<LanguageModelV3["doGenerate"]>) => {
calls?.push(args[0])
if (hang) return new Promise(() => {})
const text = next()
return {
content: [{ type: "text", text }],
finishReason: { unified: "stop" },
@@ -57,7 +65,9 @@ function lang(outputs = ["{}"]): LanguageModelV3 {
} as unknown as LanguageModelV3
}
function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider.Interface {
function provider(
input: { outputs?: (string | Error)[]; seen?: string[]; calls?: unknown[]; hang?: boolean } = {},
): Provider.Interface {
const base = mdl()
const mem = mdl(ModelV2.ID.make("memory-config-model"))
const info = {
@@ -78,7 +88,7 @@ function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider
},
getLanguage: (model) => {
input.seen?.push(model.id)
return Effect.succeed(lang(input.outputs))
return Effect.succeed(lang(input.outputs, input.calls, input.hang))
},
closest: () => Effect.succeed({ providerID: pid, modelID: base.id }),
getSmallModel: () => Effect.succeed(mem),
@@ -280,6 +290,34 @@ describe("memory ports", () => {
expect(seen).toEqual(["memory-config-model", "fake-memory-model"])
})
test("model port retries a transient provider failure once", async () => {
const calls: unknown[] = []
const err = new APICallError({
message: "temporarily unavailable",
url: "https://example.com/v1/generate",
requestBodyValues: {},
statusCode: 503,
responseHeaders: {},
responseBody: '{"error":"temporarily unavailable"}',
isRetryable: true,
})
const port = MemoryModel.port({ provider: provider({ outputs: [err, "{}"], calls }) })
const resolved = await Effect.runPromise(port.resolve({ session: ref }))
await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 })
expect(calls).toHaveLength(2)
})
test("model port emits a structured timeout error", async () => {
const port = MemoryModel.port({ provider: provider({ hang: true }) })
const resolved = await Effect.runPromise(port.resolve({ session: ref }))
await expect(
port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 1 }),
).rejects.toMatchObject({ name: "TimeoutError", message: "memory model timed out" })
})
test("model port clears its timeout after successful output", async () => {
const set = globalThis.setTimeout
const clear = globalThis.clearTimeout