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
13 changes: 13 additions & 0 deletions packages/agents/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ import {
buildStatusMessageByProvider,
} from "@/utils/status";

/**
* Session → provider index. Intentionally unbounded: this map is used by
* `abortSession`, `subscribeToSession`, `ensureSession`, and the task / cron
* schedulers to dispatch to the correct agent client for a given session id.
* Evicting an entry and falling back to a default provider would misroute
* those calls, so this has to stay a correctness index, not a size-capped
* cache.
*
* Practical growth is bounded by the number of unique session ids the daemon
* has observed during its lifetime (~60 bytes/entry). A typical daemon sees
* far fewer sessions than the per-turn event buffers we optimised elsewhere,
* so the unbounded footprint here is acceptable.
*/
const sessionProviders = new Map<string, AgentProviderId>();

function getProviderForChannel(channelId: string): AgentProviderId {
Expand Down
11 changes: 9 additions & 2 deletions packages/agents/claude/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setThreadSessionId } from "@/config/local/sessions";
import { log } from "@/utils";
import { BoundedSet, log } from "@/utils";
import { buildPromptParts, buildPromptText, buildSystemPrompt } from "../shared";
import {
CliAgentRuntime,
Expand All @@ -18,7 +18,14 @@ export type SessionEnvironment = RuntimeSessionEnvironment;

const runtime = new CliAgentRuntime("Claude");
type RuntimeRequestEntry = ReturnType<CliAgentRuntime["beginRequest"]>;
const newSessions = new Set<string>();
/**
* FIFO-bounded cache of session ids that have not yet completed their first
* turn. Evicting the oldest entry on overflow is safe: the flag only gates
* one-time startup behaviour for that session, and any session that has been
* queued for this long without being consumed is effectively abandoned.
*/
const NEW_SESSIONS_MAX_ENTRIES = 1000;
const newSessions = new BoundedSet<string>(NEW_SESSIONS_MAX_ENTRIES);
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

type ClaudeJsonRecord = {
Expand Down
6 changes: 4 additions & 2 deletions packages/agents/gemini/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setThreadSessionId } from "@/config/local/sessions";
import { log } from "@/utils";
import { BoundedSet, log } from "@/utils";
import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared";
import {
CliAgentRuntime,
Expand Down Expand Up @@ -37,7 +37,9 @@ type GeminiJsonRecord = {
};

const runtime = new CliAgentRuntime("Gemini");
const newSessions = new Set<string>();
/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */
const NEW_SESSIONS_MAX_ENTRIES = 1000;
const newSessions = new BoundedSet<string>(NEW_SESSIONS_MAX_ENTRIES);
export const { createSession, getOrCreateSession } = createCliThreadSessionManager({
providerId: "gemini",
providerName: "Gemini",
Expand Down
6 changes: 4 additions & 2 deletions packages/agents/goose/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setThreadSessionId } from "@/config/local/sessions";
import { log } from "@/utils";
import { BoundedSet, log } from "@/utils";
import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared";
import {
CliAgentRuntime,
Expand Down Expand Up @@ -41,7 +41,9 @@ type GooseJsonRecord = {
};

const runtime = new CliAgentRuntime("Goose");
const newSessions = new Set<string>();
/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */
const NEW_SESSIONS_MAX_ENTRIES = 1000;
const newSessions = new BoundedSet<string>(NEW_SESSIONS_MAX_ENTRIES);
export const { createSession, getOrCreateSession } = createCliThreadSessionManager({
providerId: "goose",
providerName: "Goose",
Expand Down
6 changes: 4 additions & 2 deletions packages/agents/kilo/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setThreadSessionId } from "@/config/local/sessions";
import { log } from "@/utils";
import { BoundedSet, log } from "@/utils";
import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared";
import {
CliAgentRuntime,
Expand Down Expand Up @@ -63,7 +63,9 @@ type KiloJsonRecord = {
};

const runtime = new CliAgentRuntime("Kilo");
const newSessions = new Set<string>();
/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */
const NEW_SESSIONS_MAX_ENTRIES = 1000;
const newSessions = new BoundedSet<string>(NEW_SESSIONS_MAX_ENTRIES);
const kiloSessionPrefix = "ses_";

function resolveKiloBinary(): string {
Expand Down
6 changes: 4 additions & 2 deletions packages/agents/kiro/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "child_process";
import { log } from "@/utils";
import { BoundedSet, log } from "@/utils";
import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared";
import {
CliAgentRuntime,
Expand All @@ -17,7 +17,9 @@ import type {
export type SessionEnvironment = RuntimeSessionEnvironment;

const runtime = new CliAgentRuntime("Kiro");
const newSessions = new Set<string>();
/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */
const NEW_SESSIONS_MAX_ENTRIES = 1000;
const newSessions = new BoundedSet<string>(NEW_SESSIONS_MAX_ENTRIES);
export const { createSession, getOrCreateSession } = createCliThreadSessionManager({
providerId: "kiro",
providerName: "Kiro",
Expand Down
6 changes: 4 additions & 2 deletions packages/agents/qwen/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setThreadSessionId } from "@/config/local/sessions";
import { log } from "@/utils";
import { BoundedSet, log } from "@/utils";
import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared";
import {
CliAgentRuntime,
Expand Down Expand Up @@ -35,7 +35,9 @@ type QwenJsonRecord = {
};

const runtime = new CliAgentRuntime("Qwen");
const newSessions = new Set<string>();
/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */
const NEW_SESSIONS_MAX_ENTRIES = 1000;
const newSessions = new BoundedSet<string>(NEW_SESSIONS_MAX_ENTRIES);
export const { createSession, getOrCreateSession } = createCliThreadSessionManager({
providerId: "qwen",
providerName: "Qwen",
Expand Down
12 changes: 11 additions & 1 deletion packages/agents/runtime/cli-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,21 @@ type CliSessionRuntime = {
setSessionEnvironment: (sessionId: string, env: SessionEnvironment) => void;
};

/**
* Minimal surface a "new session" tracker needs to expose. We accept either a
* raw `Set<string>` or the bounded variant from `@/utils` — both satisfy this
* contract — so agent clients can pick whichever they want without forcing a
* circular dependency here.
*/
type NewSessionTracker = {
add: (value: string) => unknown;
};

type CliThreadSessionManagerOptions = {
providerId: AgentProviderId;
providerName: string;
runtime: CliSessionRuntime;
newSessions?: Set<string>;
newSessions?: NewSessionTracker;
sessionIdFactory?: () => string;
validateSessionId?: (sessionId: string) => boolean;
};
Expand Down
26 changes: 25 additions & 1 deletion packages/core/kernel/request-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
buildSessionMessageState,
extractEventSessionId,
getStatusMessageKey,
truncateEventPayload,
type SessionEvent,
type SessionMessageState,
log,
Expand Down Expand Up @@ -290,10 +291,33 @@ async function startKernelEventStreamWatcher(params: {
}
}

// Decide up-front which string fields inside this event must be kept
// verbatim (i.e. never replaced by a truncation marker). Assistant text /
// reasoning / thinking parts inside `message.part.updated` feed
// `state.currentText` in session-inspector.ts, which request-run.ts later
// publishes to Slack as the final reply on `stop` and tool-only turns —
// truncating them here would post `...[truncated N bytes]` to the user.
const preserveAssistantText =
event?.type === "message.part.updated" &&
((): boolean => {
const partType = (event as any)?.properties?.part?.type;
return partType === "text" || partType === "reasoning" || partType === "thinking";
})();
const preserveStringAtPath = preserveAssistantText
? (path: string): boolean => path === "properties.part.text"
: undefined;

const sessionEvent: SessionEvent = {
timestamp: Date.now(),
type: event.type || "unknown",
data: event as Record<string, unknown>,
// Truncate any multi-KB strings inside the raw payload before we buffer
// it for the turn. The live-status renderer never shows full tool output
// — it only reads a short preview of tool input and a 90-char slice of
// thinking text — so capping most strings at ~4 KB is lossless for the
// UI. See packages/utils/event-truncation.ts.
data: truncateEventPayload(event as Record<string, unknown>, {
preserveStringAtPath,
}),
};
eventHistory.push(sessionEvent);

Expand Down
21 changes: 17 additions & 4 deletions packages/core/runtime/message-updates.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import type { IMAdapter } from "@/core/types";
import { getMessageUpdateIntervalMs } from "@/config";
import { log } from "@/utils";
import { BoundedMap, BoundedSet, log } from "@/utils";
import { CoalescedUpdateQueue } from "@/shared/queue/coalesced-update-queue";
import { isRateLimitError } from "@/shared/delivery/rate-limit";

/**
* Soft cap for the per-daemon record of which messages we have finalized or
* hit rate limits on. The runtime is a long-lived singleton (one per daemon,
* see `packages/core/kernel/runtime-facade.ts`) so an unbounded Set here leaks
* ~40 bytes per finalized message for the life of the daemon.
*
* A bounded FIFO is fine because the only consumers (`updateMessage`,
* `wasRateLimited`, `getRateLimitError`) only care about recent messages — a
* message finalized millions of updates ago will not receive further updates.
*/
const FINALIZED_SET_MAX_ENTRIES = 5000;
const RATE_LIMIT_MAP_MAX_ENTRIES = 5000;

export function createRateLimitedImAdapter(
im: IMAdapter,
intervalMs = getMessageUpdateIntervalMs()
): IMAdapter {
const rateLimitedMessages = new Set<string>();
const finalizedMessages = new Set<string>();
const rateLimitErrors = new Map<string, string>();
const rateLimitedMessages = new BoundedSet<string>(FINALIZED_SET_MAX_ENTRIES);
const finalizedMessages = new BoundedSet<string>(FINALIZED_SET_MAX_ENTRIES);
const rateLimitErrors = new BoundedMap<string, string>(RATE_LIMIT_MAP_MAX_ENTRIES);
const updateErrors = new Map<string, string>();

function key(channelId: string, messageTs: string): string {
Expand Down
64 changes: 64 additions & 0 deletions packages/live-status-harness/test/truncation-stability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "bun:test";
import { truncateEventPayload } from "@/utils/event-truncation";
import { renderStatusesFromRun } from "../renderer";
import type { HarnessCapturedEvent, HarnessRunMeta } from "../types";

type FixtureShape = {
meta: HarnessRunMeta;
events: HarnessCapturedEvent[];
};

/**
* Ensures the memory-saving truncation applied in `request-run.ts` before events
* are buffered doesn't change the rendered live-status output. The live-status
* renderer (`packages/utils/status.ts::buildLiveStatusMessage`) only reads short
* previews of tool.input and thinking text, never full tool output or assistant
* body — so capping strings inside event.data at ~4 KB must be lossless for UI.
*/
async function loadFixture(path: string): Promise<FixtureShape> {
const file = Bun.file(`${import.meta.dir}/fixtures/${path}`);
return JSON.parse(await file.text()) as FixtureShape;
}

function truncateFixtureEvents(fixture: FixtureShape): FixtureShape {
return {
meta: fixture.meta,
events: fixture.events.map((e) => ({
...e,
event: truncateEventPayload(e.event as Record<string, unknown>, {
maxStringBytes: 256, // aggressive: catches even small payloads
}),
})),
};
}

describe("live status renderer is stable under event truncation", () => {
const fixtures = [
"claude-basic-run.json",
"codex-basic-run.json",
"kiro-basic-run.json",
"kilo-basic-run.json",
"qwen-basic-run.json",
"goose-basic-run.json",
];

for (const name of fixtures) {
it(`${name}: statuses match before vs after truncation`, async () => {
const original = await loadFixture(name);
const truncated = truncateFixtureEvents(original);

const statusesOriginal = renderStatusesFromRun(original.meta, original.events);
const statusesTruncated = renderStatusesFromRun(truncated.meta, truncated.events);

// Same number of distinct status snapshots
expect(statusesTruncated.length).toBe(statusesOriginal.length);

// Each snapshot's rendered text is identical. The renderer only uses
// short-preview fields (tool name, path, command first N chars, thinking
// 90-char slice), which sit well below the truncation cap.
for (let i = 0; i < statusesOriginal.length; i++) {
expect(statusesTruncated[i]?.text).toBe(statusesOriginal[i]!.text);
}
});
}
});
98 changes: 98 additions & 0 deletions packages/utils/bounded-collections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* FIFO-bounded Set and Map used to cap long-lived runtime caches that would
* otherwise grow without bound across the daemon's lifetime (e.g. per-message
* finalization markers, per-session provider lookup, per-session "is new"
* flags). When the cap is reached, the oldest entry is evicted.
*
* Semantics:
* - Insertion order is preserved by the underlying `Set` / `Map`.
* - `BoundedSet.add(existing)` is a no-op and does NOT refresh order.
* - `BoundedMap.set(existingKey, v)` updates the value in place and does
* NOT refresh order. This matches the historical behaviour of the bespoke
* copy that previously lived in `packages/core/runtime/message-updates.ts`.
* - Eviction only happens on `add` / `set` for new keys; `get`/`has` are
* read-only.
*/

export class BoundedSet<T = string> {
private readonly max: number;
private readonly set = new Set<T>();

constructor(max: number) {
if (!Number.isFinite(max) || max <= 0) {
throw new Error(`BoundedSet requires a positive max (got ${max})`);
}
this.max = Math.floor(max);
}

add(value: T): this {
if (this.set.has(value)) return this;
this.set.add(value);
if (this.set.size > this.max) {
const oldest = this.set.values().next().value;
if (oldest !== undefined) this.set.delete(oldest);
}
return this;
}

has(value: T): boolean {
return this.set.has(value);
}

delete(value: T): boolean {
return this.set.delete(value);
}

get size(): number {
return this.set.size;
}

clear(): void {
this.set.clear();
}
}

export class BoundedMap<K, V> {
private readonly max: number;
private readonly map = new Map<K, V>();

constructor(max: number) {
if (!Number.isFinite(max) || max <= 0) {
throw new Error(`BoundedMap requires a positive max (got ${max})`);
}
this.max = Math.floor(max);
}

set(key: K, value: V): this {
if (this.map.has(key)) {
this.map.set(key, value);
return this;
}
this.map.set(key, value);
if (this.map.size > this.max) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
return this;
}

get(key: K): V | undefined {
return this.map.get(key);
}

has(key: K): boolean {
return this.map.has(key);
}

delete(key: K): boolean {
return this.map.delete(key);
}

get size(): number {
return this.map.size;
}

clear(): void {
this.map.clear();
}
}
Loading
Loading