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
8 changes: 6 additions & 2 deletions packages/cli/src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ResolvedShellPolicy } from "../policy/shell.js";
import type { EmbeddingClient } from "../tools/embeddings-api.js";
import { inariJsonLog } from "../observability/json-log.js";
import { buildSystemPrompt } from "./system-prompt.js";
import { compactHistory } from "../session/context-compact.js";
import { summarizeAndCompactHistory, type SummarizationConfig } from "../session/context-compact.js";
import { executeTool } from "../utils/concurrency-pool.js";

function truncJson(s: string, max = 2_000): string {
Expand All @@ -31,6 +31,8 @@ export type AgentTurnOptions = {
streaming: boolean;
onTextDelta?: (chunk: string) => void;
signal?: AbortSignal;
/** LLM-driven context summarization config */
summarization: SummarizationConfig;
};

export type AgentTurnResult = {
Expand Down Expand Up @@ -60,7 +62,9 @@ export async function runAgentTurn(opts: AgentTurnOptions): Promise<AgentTurnRes
while (steps < opts.maxSteps) {
steps += 1;
// Compact history if approaching context limits
history = compactHistory(history, { maxChars: 180_000 });
history = await summarizeAndCompactHistory(history, opts.provider, opts.summarization, {
maxChars: 180_000,
});

const onDelta =
opts.streaming && opts.onTextDelta
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ const RawConfigSchema = z
enabled: z.boolean().optional().default(false),
})
.optional(),
/** LLM-driven history summarization (replaces old turns before lossy compact). */
summarization: z
.object({
enabled: z.boolean().optional().default(false),
/** Char count above which summarization fires (default 120_000). */
threshold: z.number().int().positive().optional().default(120_000),
/** Recent user-led turns to keep unsummarized (default 4). */
keepRecentTurns: z.number().int().min(1).max(20).optional().default(4),
})
.optional(),
})
.superRefine((val, ctx) => {
if (val.provider === "custom" && !val.baseURL) {
Expand Down Expand Up @@ -226,6 +236,11 @@ export type InariConfig = {
/** Relative or absolute paths to skill pack dirs (skill.yaml + prompt). */
skillPackPaths: string[];
chatTheme: "default" | "soft" | "high_contrast";
summarization: {
enabled: boolean;
threshold: number;
keepRecentTurns: number;
};
};

export type InariInitConfigFormat = "yaml" | "cjs";
Expand Down Expand Up @@ -650,6 +665,11 @@ export function resolveConfigFromRaw(c: RawInariConfig): InariConfig {
picker,
skillPackPaths: skillPackPathsFromParsed(c),
chatTheme: c.chatTheme,
summarization: {
enabled: c.summarization?.enabled ?? false,
threshold: c.summarization?.threshold ?? 120_000,
keepRecentTurns: c.summarization?.keepRecentTurns ?? 4,
},
};
}

Expand Down Expand Up @@ -688,6 +708,11 @@ export function resolveConfigFromRaw(c: RawInariConfig): InariConfig {
picker,
skillPackPaths: skillPackPathsFromParsed(c),
chatTheme: c.chatTheme,
summarization: {
enabled: c.summarization?.enabled ?? false,
threshold: c.summarization?.threshold ?? 120_000,
keepRecentTurns: c.summarization?.keepRecentTurns ?? 4,
},
};
}

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/i18n/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ const EN = {
slashCompactNoop: "Nothing to compact — already at or below {keep} user turn(s).",
slashCompactDone:
"Compacted session: {before} → {after} history items (kept last {keep} user turns). Saved if --session.",
slashCompactSummarized:
"History summarized via LLM. Old turns replaced with summary. Saved if --session.",
slashUnknown: "Unknown command: {cmd} — try /help",
confirmBlock: "\n[confirm: {title}]\n{body}\n",
confirmPrompt: "Proceed? [y/N] ",
Expand Down Expand Up @@ -198,6 +200,8 @@ const MN: Record<keyof typeof EN, string> = {
slashCompactNoop: "Нягтруулах зүйл алга — аль хэдийн {keep} эргэлт эсвэл түүнээс бага.",
slashCompactDone:
"Session нягтруулсан: {before} → {after} түүхийн мөр (--session бол хадгалагдсан). Сүүлийн {keep} хэрэглэгчийн эргэлт.",
slashCompactSummarized:
"Түүх LLM-ээр нягтруулсан. Хуучин эргэлтийг хураангуйгаар солилоо. --session бол хадгалагдсан.",
slashUnknown: "Танигдаагүй тушаал: {cmd} — /help үзнэ үү",
confirmBlock: "\n[баталгаа: {title}]\n{body}\n",
confirmPrompt: "Үргэлжлүүлэх үү? [т/г] (эсвэл y/n) ",
Expand Down
33 changes: 32 additions & 1 deletion packages/cli/src/session/context-compact.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
/** Context compaction: intelligently trim conversation history to stay within provider limits. */

import type { AgentHistoryItem, NormalizedBlock } from "../llm/types.js";
import type { AgentHistoryItem, LLMProvider, NormalizedBlock } from "../llm/types.js";
import { summarizeHistory } from "./summarize-history.js";

export type SummarizationConfig = {
enabled: boolean;
/** Char count threshold above which summarization fires (default: 120_000) */
threshold: number;
/** Recent user-led turns to keep unsummarized (default: 4) */
keepRecentTurns: number;
};

export type CompactionOptions = {
/** Maximum total characters in history (default: 200_000) */
Expand Down Expand Up @@ -115,3 +124,25 @@ export function compactHistory(history: AgentHistoryItem[], opts: CompactionOpti

return result;
}

/**
* Summarize old turns with the LLM when over threshold, then run synchronous
* compaction as a safety net.
*/
export async function summarizeAndCompactHistory(
history: AgentHistoryItem[],
provider: LLMProvider,
summarization: SummarizationConfig,
compactOpts: CompactionOptions = {},
): Promise<AgentHistoryItem[]> {
let current = history;

if (summarization.enabled && countChars(current) > summarization.threshold) {
current = await summarizeHistory(current, {
provider,
keepRecentTurns: summarization.keepRecentTurns,
});
}

return compactHistory(current, compactOpts);
}
80 changes: 80 additions & 0 deletions packages/cli/src/session/summarize-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { AgentHistoryItem, LLMProvider } from "../llm/types.js";
import { segmentHistoryByUserTurns } from "./compact-history.js";

export type SummarizeOptions = {
provider: LLMProvider;
/** User-led turns to keep unsummarized from the end (default: 4) */
keepRecentTurns?: number;
};

/** Render history items as readable text for the summarization prompt. */
export function renderHistoryAsText(history: AgentHistoryItem[]): string {
const parts: string[] = [];
for (const item of history) {
if (item.kind === "user_text") {
parts.push(`User: ${item.text}`);
} else if (item.kind === "assistant") {
const textParts = item.blocks
.filter((b) => b.type === "text")
.map((b) => (b.type === "text" ? b.text : ""));
const toolNames = item.blocks
.filter((b) => b.type === "tool_use")
.map((b) => (b.type === "tool_use" ? `[called ${b.name}]` : ""));
const combined = [...textParts, ...toolNames].join(" ").trim();
parts.push(`Assistant: ${combined}`);
} else {
// tool_outputs
const preview = item.outputs
.map((o) => o.content.slice(0, 200))
.join("; ");
parts.push(`Tool results: ${preview}`);
}
}
return parts.join("\n\n");
}

/**
* Summarize old conversation turns using the LLM.
* Replaces all turns older than `keepRecentTurns` user-led segments with a
* single `[Session context summary: …]` user_text item.
* Returns the original reference unchanged when there is nothing old to summarize.
*/
export async function summarizeHistory(
history: AgentHistoryItem[],
opts: SummarizeOptions,
): Promise<AgentHistoryItem[]> {
const keepRecentTurns = opts.keepRecentTurns ?? 4;
const segments = segmentHistoryByUserTurns(history);

if (segments.length <= keepRecentTurns) return history;

const oldHistory = segments.slice(0, -keepRecentTurns).flat();
const recentHistory = segments.slice(-keepRecentTurns).flat();

const summaryText = await callLLMForSummary(oldHistory, opts.provider);

return [
{ kind: "user_text", text: `[Session context summary: ${summaryText}]` },
...recentHistory,
];
}

async function callLLMForSummary(
history: AgentHistoryItem[],
provider: LLMProvider,
): Promise<string> {
const rendered = renderHistoryAsText(history);
const result = await provider.complete({
system:
"You are a conversation summarizer. Summarize the following conversation history " +
"in 2-4 concise paragraphs. Preserve: the user's goals, key decisions made, " +
"files read or changed, and current state. Be factual and brief. " +
"Output only the summary, no preamble.",
history: [
{ kind: "user_text", text: `Summarize this conversation:\n\n${rendered}` },
],
tools: [],
});
const textBlock = result.blocks.find((b) => b.type === "text");
return textBlock?.type === "text" ? textBlock.text : "No summary available.";
}
3 changes: 3 additions & 0 deletions packages/cli/src/ui/chat-repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export async function runChatRepl(options: {
},
persistEmpty,
slashHelpExtra,
provider,
summarization: cfg.summarization,
});
if (slash.kind === "exit") break;
if (slash.kind === "again") continue;
Expand Down Expand Up @@ -172,6 +174,7 @@ export async function runChatRepl(options: {
}
: undefined,
signal: options.signal,
summarization: cfg.summarization,
});
history = next;
if (useStream) {
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/src/ui/chat-slash.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AgentHistoryItem } from "../llm/types.js";
import type { AgentHistoryItem, LLMProvider } from "../llm/types.js";
import type { Locale } from "../i18n/locale.js";
import { compactHistoryByUserTurns } from "../session/compact-history.js";
import { summarizeHistory } from "../session/summarize-history.js";
import { tr } from "../i18n/strings.js";

export type SlashLoopAction =
Expand All @@ -21,6 +22,9 @@ type SlashCtx = {
persistEmpty: () => Promise<void>;
/** Appended after built-in /help (e.g. skill pack slash_hints). */
slashHelpExtra?: string;
/** Required for /compact summary. */
provider: LLMProvider;
summarization: { enabled: boolean; threshold: number; keepRecentTurns: number };
};

/**
Expand Down Expand Up @@ -71,6 +75,19 @@ export async function handleChatSlashInput(ctx: SlashCtx): Promise<SlashLoopActi

if (cmd === "compact" || cmd === "trim") {
const rest = raw.slice(cmd.length).trim();

if (rest === "summary") {
const history = ctx.getHistory();
const summarized = await summarizeHistory(history, {
provider: ctx.provider,
keepRecentTurns: ctx.summarization.keepRecentTurns,
});
ctx.setHistory(summarized);
await ctx.persistHistory(summarized);
await ctx.write(`${tr(ctx.locale, "slashCompactSummarized")}\n`);
return { kind: "again" };
}

let keep = 8;
if (rest.length > 0) {
const n = parseInt(rest, 10);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/chat-tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ function ChatTuiInner(
write: (s) => setTranscript((t) => t + s),
persistEmpty,
slashHelpExtra: props.slashHelpExtra,
provider: props.provider,
summarization: props.cfg.summarization,
});
if (slash.kind === "exit") {
await persist(history);
Expand Down Expand Up @@ -190,6 +192,7 @@ function ChatTuiInner(
streaming: props.useStream,
onTextDelta: props.useStream ? (chunk: string) => setStreaming((s) => s + chunk) : undefined,
signal: props.signal,
summarization: props.cfg.summarization,
});
setHistory(next);
await persist(next);
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/test/config-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,45 @@ plugins:
}
});

it("loads summarization config with explicit values", async () => {
const dir = mkdtempSync(join(tmpdir(), "inari-sum-"));
try {
vi.stubEnv("ANTHROPIC_API_KEY", "sk-test");
writeFileSync(
join(dir, "inaricode.yaml"),
`provider: anthropic
summarization:
enabled: true
threshold: 90000
keepRecentTurns: 3
`,
"utf8",
);
const cfg = await loadConfig(dir);
expect(cfg.summarization).toEqual({
enabled: true,
threshold: 90000,
keepRecentTurns: 3,
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("summarization defaults to disabled when omitted", async () => {
const dir = mkdtempSync(join(tmpdir(), "inari-sum2-"));
try {
vi.stubEnv("ANTHROPIC_API_KEY", "sk-test");
writeFileSync(join(dir, "inaricode.yaml"), `provider: anthropic\n`, "utf8");
const cfg = await loadConfig(dir);
expect(cfg.summarization.enabled).toBe(false);
expect(cfg.summarization.threshold).toBe(120_000);
expect(cfg.summarization.keepRecentTurns).toBe(4);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("resolves egune key when provider is eguna", async () => {
const dir = mkdtempSync(join(tmpdir(), "inari-keys-"));
try {
Expand Down
Loading
Loading