Skip to content

Commit 4dbc258

Browse files
committed
Add context window management: implement context size configuration for models and apply token limit trimming in message history
1 parent ea60c4f commit 4dbc258

File tree

5 files changed

+206
-43
lines changed

5 files changed

+206
-43
lines changed

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,22 @@
3131
"@types/marked": "^5.0.2",
3232
"ai": "^5.0.15",
3333
"dotenv": "^16.4.5",
34+
"gpt-tokenizer": "^3.0.1",
3435
"html-to-text": "^9.0.5",
3536
"ink": "^6.2.0",
3637
"ink-spinner": "^5.0.0",
3738
"ink-text-input": "^6.0.0",
3839
"json5": "^2.2.3",
3940
"marked": "^16.2.0",
4041
"ollama-ai-provider-v2": "^1.1.1",
42+
"os-locale": "^6.0.2",
4143
"react": "^19.1.1",
4244
"simple-git": "^3.28.0",
45+
"typescript": "^5.5.3",
4346
"winston": "^3.17.0",
4447
"zod": "^4.0.17",
45-
"zustand": "^5.0.7",
46-
"typescript": "^5.5.3",
47-
"zod-to-ts": "^1.2.0"
48+
"zod-to-ts": "^1.2.0",
49+
"zustand": "^5.0.7"
4850
},
4951
"devDependencies": {
5052
"@types/node": "^20.14.9",

src/agent/context-window.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { encode } from "gpt-tokenizer";
2+
import type { ModelMessage } from "ai";
3+
import type { ModelConfig } from "@/config.js";
4+
import logger from "@/logger.js";
5+
6+
function getTokenCount(text: string): number {
7+
return encode(text).length;
8+
}
9+
10+
export function applyContextWindow(
11+
history: ModelMessage[],
12+
modelConfig: ModelConfig,
13+
): ModelMessage[] {
14+
const { context: contextLimit } = modelConfig;
15+
const safeContextLimit = contextLimit * 0.8;
16+
17+
let totalTokens = 0;
18+
for (const message of history) {
19+
if (typeof message.content === "string") {
20+
totalTokens += getTokenCount(message.content);
21+
} else {
22+
// Handle content arrays
23+
for (const part of message.content) {
24+
if ("text" in part) {
25+
totalTokens += getTokenCount(part.text);
26+
}
27+
}
28+
}
29+
}
30+
31+
if (totalTokens <= safeContextLimit) {
32+
logger.info(
33+
`Token count (${totalTokens}) is within the safe limit of ${safeContextLimit}. No trimming needed.`,
34+
);
35+
return history;
36+
}
37+
38+
logger.warn(
39+
`Token count (${totalTokens}) exceeds the safe limit of ${safeContextLimit}. Trimming history...`,
40+
);
41+
42+
const trimmedHistory = [...history];
43+
44+
// Always preserve the system prompt (if it's the first message)
45+
const hasSystemPrompt = trimmedHistory[0]?.role === "system";
46+
const startIndex = hasSystemPrompt ? 1 : 0;
47+
48+
while (totalTokens > safeContextLimit && trimmedHistory.length > startIndex + 1) {
49+
const removedMessage = trimmedHistory.splice(startIndex, 1)[0];
50+
if (removedMessage) {
51+
let removedTokens = 0;
52+
if (typeof removedMessage.content === "string") {
53+
removedTokens = getTokenCount(removedMessage.content);
54+
} else {
55+
for (const part of removedMessage.content) {
56+
if ("text" in part) {
57+
removedTokens += getTokenCount(part.text);
58+
}
59+
}
60+
}
61+
totalTokens -= removedTokens;
62+
logger.info(
63+
`Removed message at index ${startIndex} to save ${removedTokens} tokens. New total: ${totalTokens}`,
64+
);
65+
}
66+
}
67+
68+
logger.info(`History trimmed. Final token count: ${totalTokens}.`);
69+
return trimmedHistory;
70+
}

src/agent/state.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import path from "path";
1313
import simpleGit from "simple-git";
1414
import { HistoryItem, ToolRequestItem } from "./history.js";
1515
import type { ModelMessage } from "ai";
16+
import { applyContextWindow } from "./context-window.js";
1617

1718
function loadCommandHistory(): string[] {
1819
try {
@@ -269,7 +270,7 @@ export const useStore = create<AppState & AppActions>((set, get) => ({
269270
const { history, config } = get();
270271
if (!config) throw new FatalError("Configuration not loaded.");
271272

272-
const sdkCompliantHistory = history
273+
let sdkCompliantHistory = history
273274
.map((item): ModelMessage | null => {
274275
switch (item.role) {
275276
case "user":
@@ -306,6 +307,15 @@ export const useStore = create<AppState & AppActions>((set, get) => ({
306307
})
307308
.filter(Boolean) as ModelMessage[];
308309

310+
const modelConfig = config.models.find((m) => m.name === config.defaultModel);
311+
if (!modelConfig) {
312+
throw new FatalError(
313+
`Model ${config.defaultModel} not found in configuration.`,
314+
);
315+
}
316+
317+
sdkCompliantHistory = applyContextWindow(sdkCompliantHistory, modelConfig);
318+
309319
const systemPrompt = await generateSystemPrompt(config);
310320

311321
const { textStream, toolCalls: toolCallPartsPromise } =

src/agent/system-prompt.ts

Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,39 @@ import { zodToTs, printNode } from "zod-to-ts";
55
import fs from "fs/promises";
66
import path from "path";
77
import os from "os";
8+
import { osLocale } from "os-locale"; // NEW: Import the library for locale detection.
9+
10+
/**
11+
* Dynamically gets the user's system locale (language and region).
12+
* Handles cross-platform differences and provides a safe fallback.
13+
* @returns A promise that resolves to the user's locale string (e.g., "en-US").
14+
*/
15+
async function getUserLocale(): Promise<string> {
16+
try {
17+
// `os-locale` is the standard way to solve this problem in Node.js.
18+
// It correctly checks LANG, LC_ALL, etc., on Linux/macOS and uses OS APIs on Windows.
19+
const locale = await osLocale();
20+
// The library might return 'en_US'. We convert it to the IETF BCP 47 standard 'en-US'.
21+
return locale.replace("_", "-");
22+
} catch (e) {
23+
// If detection fails for any reason, fall back to a sensible default.
24+
return "en-US";
25+
}
26+
}
27+
28+
/**
29+
* Dynamically gets the user's system timezone.
30+
* @returns The user's timezone string (e.g., "Europe/Oslo").
31+
*/
32+
function getUserTimezone(): string {
33+
try {
34+
// The Intl API is the standard, modern way to get the system timezone in JavaScript.
35+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
36+
} catch (e) {
37+
// Fallback in case the environment is unusual and the API fails.
38+
return "Europe/Oslo";
39+
}
40+
}
841

942
/**
1043
* Recursively searches for instruction files (TOBI.md, AGENTS.md) upwards from the current directory.
@@ -14,21 +47,17 @@ import os from "os";
1447
async function findInstructionFile(currentDir: string): Promise<string | null> {
1548
const instructionFiles = ["TOBI.md", "AGENTS.md"];
1649
const homeDir = os.homedir();
17-
1850
let dir = currentDir;
19-
// Stop if we reach the root directory or the user's home directory
2051
while (dir !== path.dirname(dir) && dir.startsWith(homeDir)) {
2152
for (const fileName of instructionFiles) {
2253
const filePath = path.join(dir, fileName);
2354
try {
24-
// Check if the file exists and we can read it
2555
await fs.access(filePath, fs.constants.R_OK);
2656
return await fs.readFile(filePath, "utf-8");
2757
} catch {
28-
// File does not exist or is not readable, continue searching
58+
// Continue searching
2959
}
3060
}
31-
// Move to the parent directory
3261
dir = path.dirname(dir);
3362
}
3463
return null;
@@ -39,24 +68,28 @@ async function findInstructionFile(currentDir: string): Promise<string | null> {
3968
* @param config The application configuration.
4069
* @returns A promise that resolves to the generated system prompt string.
4170
*/
42-
export async function generateSystemPrompt(_config: Config): Promise<string> {
71+
export async function generateSystemPrompt(config: Config): Promise<string> {
4372
const cwd = process.cwd();
4473

45-
// 1. Get workspace files
74+
// 1. Gather environmental context, now including locale and timezone
4675
const dirents = await fs.readdir(cwd, { withFileTypes: true });
4776
const filesAndDirs = dirents.map((d) => (d.isDirectory() ? `${d.name}/` : d.name)).join("\n");
77+
const osPlatform = os.platform();
78+
79+
// NEW: Call the dynamic helper functions.
80+
const userLocale = await getUserLocale();
81+
const userTimezone = getUserTimezone();
82+
// NEW: Use the detected locale and timezone to format the date correctly for the user.
83+
const currentDate = new Date().toLocaleString(userLocale, { timeZone: userTimezone });
4884

49-
// 2. Find instruction file
85+
// 2. Find project-specific instructions
5086
const instructionContent = await findInstructionFile(cwd);
5187

5288
// 3. Generate tool definitions as TypeScript types
5389
const toolDefinitions = Object.values(toolModules)
5490
.map((module) => {
55-
// We pass the zod schema for the arguments to zodToTs
5691
const { node } = zodToTs(module.schema.shape.arguments);
57-
// Then we print the resulting TypeScript AST node to a string.
5892
const argumentsString = printNode(node);
59-
6093
return (
6194
`// ${module.description}\n` +
6295
`type ${module.schema.shape.name.value} = {\n` +
@@ -67,37 +100,63 @@ export async function generateSystemPrompt(_config: Config): Promise<string> {
67100
})
68101
.join("\n\n");
69102

70-
// 4. Assemble the prompt
103+
// 4. Assemble the prompt, now with the new context
71104
const promptParts: string[] = [
72-
`You are a helpful AI assistant named Tobi. You can use tools to help the user with coding and file system tasks.`,
73-
];
105+
`You are Tobi, an autonomous AI software engineer. Your role is to assist the user, named "${config.defaultModel}", by executing tasks with the tools provided. You operate with maximum efficiency and precision.`,
74106

75-
promptParts.push("### Current Workspace");
76-
promptParts.push("Here is a list of files and directories in the current working directory:");
77-
promptParts.push("```");
78-
promptParts.push(filesAndDirs);
79-
promptParts.push("```");
107+
// UPDATED: The Environment section now includes the new dynamic information.
108+
`### Environment\n` +
109+
`* **Operating System:** ${osPlatform}\n` +
110+
`* **User Locale:** ${userLocale}\n` +
111+
`* **User Timezone:** ${userTimezone}\n` +
112+
`* **Current Date & Time:** ${currentDate}\n` +
113+
`* **Working Directory:** ${cwd}\n` +
114+
"* **Directory Contents:**\n" +
115+
"```\n" +
116+
`${filesAndDirs || "(empty)"}\n` +
117+
"```",
118+
119+
"### Rules of Engagement\n" +
120+
"1. **Think Step-by-Step:** Before acting, briefly state your plan to achieve the user's goal.\n" +
121+
"2. **Execute Autonomously:** You are autonomous. Use your tools to execute your plan without asking for permission. The user will intervene if your plan is incorrect.\n" +
122+
"3. **One Tool at a Time:** You can only call one tool per turn. Decompose complex tasks into a sequence of single tool calls.\n" +
123+
"4. **Stay on Task:** Your responses should consist of your thought process followed by a tool call. Avoid conversational filler or apologies.\n" +
124+
"5. **Code Concisely:** Do not add comments to code unless explicitly requested by the user.",
125+
];
80126

81127
if (instructionContent) {
82-
promptParts.push("### User-Provided Instructions");
83128
promptParts.push(
84-
"The user has provided the following instructions in a `TOBI.md` or `AGENTS.md` file. Follow them carefully.",
129+
"### User-Provided Instructions\n" +
130+
"The user has provided the following project-specific instructions. Adhere to them strictly.\n" +
131+
"```markdown\n" +
132+
instructionContent +
133+
"\n```",
85134
);
86-
promptParts.push("```markdown");
87-
promptParts.push(instructionContent);
88-
promptParts.push("```");
89135
}
90136

91-
promptParts.push("### Available Tools");
92-
promptParts.push(
93-
"You have the following tools available. To use a tool, respond with a JSON object that strictly adheres to the TypeScript type definition of the tool.",
94-
);
95137
promptParts.push(
96-
"The following are TypeScript type definitions for the tools. The `name` property is the tool to call, and you must provide the corresponding `arguments` object.",
138+
"### Tool Reference\n" +
139+
"To use a tool, you must respond with a single JSON object containing the `tool_calls` property. This object must conform to the following TypeScript definitions.\n\n" +
140+
"**Example:** To list files, you would respond with:\n" +
141+
"```json\n" +
142+
JSON.stringify(
143+
{
144+
tool_calls: [
145+
{
146+
name: "list",
147+
arguments: { path: "." },
148+
},
149+
],
150+
},
151+
null,
152+
2,
153+
) +
154+
"\n```\n\n" +
155+
"**Tool Definitions:**\n" +
156+
"```typescript\n" +
157+
toolDefinitions +
158+
"\n```",
97159
);
98-
promptParts.push("```typescript");
99-
promptParts.push(toolDefinitions);
100-
promptParts.push("```");
101160

102161
return promptParts.join("\n\n");
103162
}

src/config.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const modelSchema = z.object({
1212
.enum(["openai", "google", "anthropic", "ollama"])
1313
.describe("The provider of the model."),
1414
modelId: z.string().describe("The actual model ID used by the API."),
15+
context: z.number().describe("The context window size for the model."),
1516
baseUrl: z.url().optional().describe("Optional base URL for providers like Ollama."),
1617
});
1718
export type ModelConfig = z.infer<typeof modelSchema>;
@@ -57,17 +58,38 @@ const defaultConfig: Config = {
5758
systemPrompt: `You are a helpful AI assistant named Tobi. You can use tools to help the user with coding and file system tasks.`,
5859
defaultModel: "gpt-4.1-mini",
5960
models: [
60-
{ name: "gpt-4.1-mini", provider: "openai", modelId: "gpt-4.1-mini" },
61-
{ name: "gpt-4.1", provider: "openai", modelId: "gpt-4.1" },
62-
{ name: "gpt-4o", provider: "openai", modelId: "gpt-4o" },
63-
{ name: "claude-3.5-sonnet", provider: "anthropic", modelId: "claude-3-5-sonnet" },
64-
{ name: "claude-3.5-haiku", provider: "anthropic", modelId: "claude-3-5-haiku" },
65-
{ name: "gemini-2.5-pro", provider: "google", modelId: "models/gemini-2.5-pro" },
66-
{ name: "gemini-2.5-flash", provider: "google", modelId: "models/gemini-2.5-flash" },
61+
{ name: "gpt-4.1-mini", provider: "openai", modelId: "gpt-4.1-mini", context: 128000 },
62+
{ name: "gpt-4.1", provider: "openai", modelId: "gpt-4.1", context: 128000 },
63+
{ name: "gpt-4o", provider: "openai", modelId: "gpt-4o", context: 128000 },
64+
{
65+
name: "claude-3.5-sonnet",
66+
provider: "anthropic",
67+
modelId: "claude-3-5-sonnet",
68+
context: 200000,
69+
},
70+
{
71+
name: "claude-3.5-haiku",
72+
provider: "anthropic",
73+
modelId: "claude-3-5-haiku",
74+
context: 200000,
75+
},
76+
{
77+
name: "gemini-2.5-pro",
78+
provider: "google",
79+
modelId: "models/gemini-2.5-pro",
80+
context: 1000000,
81+
},
82+
{
83+
name: "gemini-2.5-flash",
84+
provider: "google",
85+
modelId: "models/gemini-2.5-flash",
86+
context: 1000000,
87+
},
6788
{
6889
name: "qwen3",
6990
provider: "ollama",
7091
modelId: "qwen3:8b",
92+
context: 32768,
7193
baseUrl: "http://localhost:11434/v1",
7294
},
7395
],

0 commit comments

Comments
 (0)