@townco/agent 0.1.82 → 0.1.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/acp-server/adapter.js +150 -49
  2. package/dist/acp-server/http.js +56 -1
  3. package/dist/acp-server/session-storage.d.ts +44 -12
  4. package/dist/acp-server/session-storage.js +153 -59
  5. package/dist/definition/index.d.ts +2 -2
  6. package/dist/definition/index.js +1 -1
  7. package/dist/runner/agent-runner.d.ts +4 -2
  8. package/dist/runner/hooks/executor.d.ts +1 -0
  9. package/dist/runner/hooks/executor.js +18 -2
  10. package/dist/runner/hooks/predefined/compaction-tool.js +3 -2
  11. package/dist/runner/hooks/predefined/tool-response-compactor.d.ts +0 -4
  12. package/dist/runner/hooks/predefined/tool-response-compactor.js +30 -16
  13. package/dist/runner/hooks/types.d.ts +4 -5
  14. package/dist/runner/langchain/index.d.ts +1 -0
  15. package/dist/runner/langchain/index.js +156 -33
  16. package/dist/runner/langchain/tools/artifacts.d.ts +68 -0
  17. package/dist/runner/langchain/tools/artifacts.js +466 -0
  18. package/dist/runner/langchain/tools/browser.js +15 -3
  19. package/dist/runner/langchain/tools/filesystem.d.ts +8 -4
  20. package/dist/runner/langchain/tools/filesystem.js +118 -82
  21. package/dist/runner/langchain/tools/generate_image.d.ts +19 -0
  22. package/dist/runner/langchain/tools/generate_image.js +54 -14
  23. package/dist/runner/langchain/tools/subagent.js +2 -2
  24. package/dist/runner/langchain/tools/todo.js +3 -0
  25. package/dist/runner/langchain/tools/web_search.js +6 -0
  26. package/dist/runner/session-context.d.ts +40 -0
  27. package/dist/runner/session-context.js +69 -0
  28. package/dist/runner/tools.d.ts +2 -2
  29. package/dist/runner/tools.js +2 -0
  30. package/dist/scaffold/project-scaffold.js +7 -3
  31. package/dist/telemetry/setup.js +1 -1
  32. package/dist/templates/index.d.ts +1 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/dist/utils/context-size-calculator.d.ts +1 -10
  35. package/dist/utils/context-size-calculator.js +1 -12
  36. package/dist/utils/token-counter.js +2 -2
  37. package/package.json +10 -10
  38. package/templates/index.ts +1 -1
@@ -78,6 +78,16 @@ const toolCallBlockSchema = z.object({
78
78
  subagentPort: z.number().optional(),
79
79
  subagentSessionId: z.string().optional(),
80
80
  subagentMessages: z.array(subagentMessageSchema).optional(),
81
+ _meta: z
82
+ .object({
83
+ truncationWarning: z.string().optional(),
84
+ compactionAction: z.enum(["compacted", "truncated"]).optional(),
85
+ originalTokens: z.number().optional(),
86
+ finalTokens: z.number().optional(),
87
+ originalContentPreview: z.string().optional(),
88
+ originalContentPath: z.string().optional(),
89
+ })
90
+ .optional(),
81
91
  });
82
92
  const contentBlockSchema = z.discriminatedUnion("type", [
83
93
  textBlockSchema,
@@ -114,7 +124,6 @@ const contextEntrySchema = z.object({
114
124
  toolInputTokens: z.number(),
115
125
  toolResultsTokens: z.number(),
116
126
  totalEstimated: z.number(),
117
- llmReportedInputTokens: z.number().optional(),
118
127
  modelContextWindow: z.number().optional(),
119
128
  }),
120
129
  });
@@ -131,7 +140,8 @@ const storedSessionSchema = z.object({
131
140
  });
132
141
  /**
133
142
  * File-based session storage
134
- * Stores sessions in agents/<agent-name>/.sessions/<session_id>.json
143
+ * Stores sessions in agents/<agent-name>/.sessions/<session_id>/session.json
144
+ * (Legacy: agents/<agent-name>/.sessions/<session_id>.json)
135
145
  */
136
146
  export class SessionStorage {
137
147
  sessionsDir;
@@ -146,17 +156,25 @@ export class SessionStorage {
146
156
  this.agentName = agentName;
147
157
  }
148
158
  /**
149
- * Ensure the .sessions directory exists
159
+ * Ensure the session directory exists
150
160
  */
151
- ensureSessionsDir() {
152
- if (!existsSync(this.sessionsDir)) {
153
- mkdirSync(this.sessionsDir, { recursive: true });
161
+ ensureSessionDir(sessionId) {
162
+ const sessionDir = join(this.sessionsDir, sessionId);
163
+ if (!existsSync(sessionDir)) {
164
+ mkdirSync(sessionDir, { recursive: true });
154
165
  }
155
166
  }
156
167
  /**
157
168
  * Get the file path for a session
158
169
  */
159
170
  getSessionPath(sessionId) {
171
+ return join(this.sessionsDir, sessionId, "session.json");
172
+ }
173
+ /**
174
+ * Get the legacy file path for a session (for backwards compatibility)
175
+ * Legacy format: .sessions/<session_id>.json (flat file, not directory)
176
+ */
177
+ getLegacySessionPath(sessionId) {
160
178
  return join(this.sessionsDir, `${sessionId}.json`);
161
179
  }
162
180
  /**
@@ -164,7 +182,7 @@ export class SessionStorage {
164
182
  * Uses atomic write (write to temp file, then rename)
165
183
  */
166
184
  async saveSession(sessionId, messages, context) {
167
- this.ensureSessionsDir();
185
+ this.ensureSessionDir(sessionId);
168
186
  const sessionPath = this.getSessionPath(sessionId);
169
187
  const tempPath = `${sessionPath}.tmp`;
170
188
  const now = new Date().toISOString();
@@ -207,14 +225,24 @@ export class SessionStorage {
207
225
  }
208
226
  /**
209
227
  * Synchronous session loading (for internal use)
228
+ * Checks new location first, falls back to legacy location
210
229
  */
211
230
  loadSessionSync(sessionId) {
212
231
  const sessionPath = this.getSessionPath(sessionId);
232
+ const legacyPath = this.getLegacySessionPath(sessionId);
233
+ // Check new location first
234
+ let actualPath = sessionPath;
213
235
  if (!existsSync(sessionPath)) {
214
- return null;
236
+ // Fall back to legacy location
237
+ if (existsSync(legacyPath)) {
238
+ actualPath = legacyPath;
239
+ }
240
+ else {
241
+ return null;
242
+ }
215
243
  }
216
244
  try {
217
- const content = readFileSync(sessionPath, "utf-8");
245
+ const content = readFileSync(actualPath, "utf-8");
218
246
  const parsed = JSON.parse(content);
219
247
  // Validate with zod
220
248
  const validated = storedSessionSchema.parse(parsed);
@@ -225,39 +253,64 @@ export class SessionStorage {
225
253
  }
226
254
  }
227
255
  /**
228
- * Check if a session exists
256
+ * Check if a session exists (checks both new and legacy locations)
229
257
  */
230
258
  sessionExists(sessionId) {
231
- return existsSync(this.getSessionPath(sessionId));
259
+ return (existsSync(this.getSessionPath(sessionId)) ||
260
+ existsSync(this.getLegacySessionPath(sessionId)));
232
261
  }
233
262
  /**
234
- * Delete a session
263
+ * Delete a session (deletes entire session directory or legacy file)
235
264
  */
236
265
  async deleteSession(sessionId) {
237
- const sessionPath = this.getSessionPath(sessionId);
238
- if (!existsSync(sessionPath)) {
239
- return false;
240
- }
266
+ const sessionDir = join(this.sessionsDir, sessionId);
267
+ const legacyPath = this.getLegacySessionPath(sessionId);
268
+ let deleted = false;
241
269
  try {
242
- unlinkSync(sessionPath);
243
- return true;
270
+ // Delete session directory (.sessions/<sessionId>/)
271
+ if (existsSync(sessionDir)) {
272
+ // Recursively delete the session directory
273
+ const { rmSync } = await import("node:fs");
274
+ rmSync(sessionDir, { recursive: true, force: true });
275
+ deleted = true;
276
+ }
277
+ // Also delete legacy location if it exists (.sessions/<sessionId>.json)
278
+ if (existsSync(legacyPath)) {
279
+ unlinkSync(legacyPath);
280
+ deleted = true;
281
+ }
282
+ return deleted;
244
283
  }
245
284
  catch (error) {
246
285
  throw new Error(`Failed to delete session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
247
286
  }
248
287
  }
249
288
  /**
250
- * List all session IDs
289
+ * List all session IDs (checks both new and legacy locations)
251
290
  */
252
291
  async listSessions() {
253
- if (!existsSync(this.sessionsDir)) {
254
- return [];
255
- }
292
+ const sessionIds = new Set();
256
293
  try {
257
- const files = readdirSync(this.sessionsDir);
258
- return files
259
- .filter((file) => file.endsWith(".json") && !file.endsWith(".tmp"))
260
- .map((file) => file.replace(".json", ""));
294
+ // Check .sessions/ directory
295
+ if (existsSync(this.sessionsDir)) {
296
+ const entries = readdirSync(this.sessionsDir, { withFileTypes: true });
297
+ for (const entry of entries) {
298
+ if (entry.isDirectory()) {
299
+ // New format: .sessions/<sessionId>/session.json
300
+ const sessionJsonPath = join(this.sessionsDir, entry.name, "session.json");
301
+ if (existsSync(sessionJsonPath)) {
302
+ sessionIds.add(entry.name);
303
+ }
304
+ }
305
+ else if (entry.isFile() &&
306
+ entry.name.endsWith(".json") &&
307
+ !entry.name.endsWith(".tmp")) {
308
+ // Legacy format: .sessions/<sessionId>.json
309
+ sessionIds.add(entry.name.replace(".json", ""));
310
+ }
311
+ }
312
+ }
313
+ return Array.from(sessionIds);
261
314
  }
262
315
  catch (error) {
263
316
  throw new Error(`Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`);
@@ -271,43 +324,84 @@ export class SessionStorage {
271
324
  * Returns sessions sorted by updatedAt (most recent first)
272
325
  */
273
326
  async listSessionsWithMetadata() {
274
- if (!existsSync(this.sessionsDir)) {
275
- return [];
276
- }
277
- try {
278
- const files = readdirSync(this.sessionsDir);
279
- const sessionFiles = files.filter((file) => file.endsWith(".json") && !file.endsWith(".tmp"));
280
- const sessions = [];
281
- for (const file of sessionFiles) {
282
- const sessionId = file.replace(".json", "");
283
- try {
284
- const session = this.loadSessionSync(sessionId);
285
- if (session) {
286
- // Find the first user message for preview
287
- const firstUserMsg = session.messages.find((m) => m.role === "user");
288
- const firstUserText = firstUserMsg?.content.find((c) => c.type === "text");
289
- const entry = {
290
- sessionId: session.sessionId,
291
- createdAt: session.metadata.createdAt,
292
- updatedAt: session.metadata.updatedAt,
293
- messageCount: session.messages.length,
294
- };
295
- if (firstUserText && "text" in firstUserText) {
296
- entry.firstUserMessage = firstUserText.text.slice(0, 100);
297
- }
298
- sessions.push(entry);
327
+ // Get all session IDs from both locations
328
+ const sessionIds = await this.listSessions();
329
+ const sessions = [];
330
+ for (const sessionId of sessionIds) {
331
+ try {
332
+ const session = this.loadSessionSync(sessionId);
333
+ if (session) {
334
+ // Find the first user message for preview
335
+ const firstUserMsg = session.messages.find((m) => m.role === "user");
336
+ const firstUserText = firstUserMsg?.content.find((c) => c.type === "text");
337
+ const entry = {
338
+ sessionId: session.sessionId,
339
+ createdAt: session.metadata.createdAt,
340
+ updatedAt: session.metadata.updatedAt,
341
+ messageCount: session.messages.length,
342
+ };
343
+ if (firstUserText && "text" in firstUserText) {
344
+ entry.firstUserMessage = firstUserText.text.slice(0, 100);
299
345
  }
300
- }
301
- catch {
302
- // Skip invalid sessions
346
+ sessions.push(entry);
303
347
  }
304
348
  }
305
- // Sort by updatedAt, most recent first
306
- sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
307
- return sessions;
349
+ catch {
350
+ // Skip invalid sessions
351
+ }
308
352
  }
309
- catch (error) {
310
- throw new Error(`Failed to list sessions: ${error instanceof Error ? error.message : String(error)}`);
353
+ // Sort by updatedAt, most recent first
354
+ sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
355
+ return sessions;
356
+ }
357
+ /**
358
+ * Get the directory for storing large content files for a session (artifacts folder)
359
+ */
360
+ getArtifactsDir(sessionId) {
361
+ return join(this.sessionsDir, sessionId, "artifacts");
362
+ }
363
+ /**
364
+ * Get the file path for a tool's original content
365
+ * Follows the pattern: artifacts/tool-<ToolName>/<toolCallId>.original.txt
366
+ */
367
+ getToolOriginalPath(sessionId, toolName, toolCallId) {
368
+ return join(this.getArtifactsDir(sessionId), `tool-${toolName}`, `${toolCallId}.original.txt`);
369
+ }
370
+ /**
371
+ * Save original tool response to a separate file
372
+ * Follows the pattern: artifacts/tool-<ToolName>/<toolCallId>.original.txt
373
+ * @param toolName - The name of the tool (e.g., "Read", "Grep")
374
+ * @returns Relative file path (for storage in _meta.originalContentPath)
375
+ */
376
+ saveToolOriginal(sessionId, toolName, toolCallId, content) {
377
+ const toolDir = join(this.getArtifactsDir(sessionId), `tool-${toolName}`);
378
+ if (!existsSync(toolDir)) {
379
+ mkdirSync(toolDir, { recursive: true });
311
380
  }
381
+ const filePath = this.getToolOriginalPath(sessionId, toolName, toolCallId);
382
+ writeFileSync(filePath, content, "utf-8");
383
+ // Return relative path for storage in metadata
384
+ return `${sessionId}/artifacts/tool-${toolName}/${toolCallId}.original.txt`;
385
+ }
386
+ /**
387
+ * Load original tool response from separate file
388
+ */
389
+ loadToolOriginal(sessionId, toolName, toolCallId) {
390
+ const filePath = this.getToolOriginalPath(sessionId, toolName, toolCallId);
391
+ if (!existsSync(filePath)) {
392
+ return null;
393
+ }
394
+ try {
395
+ return readFileSync(filePath, "utf-8");
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ }
401
+ /**
402
+ * Check if original content exists for a tool call
403
+ */
404
+ hasToolOriginal(sessionId, toolName, toolCallId) {
405
+ return existsSync(this.getToolOriginalPath(sessionId, toolName, toolCallId));
312
406
  }
313
407
  }
@@ -21,7 +21,7 @@ export declare const HookConfigSchema: z.ZodObject<{
21
21
  setting: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
22
22
  threshold: z.ZodNumber;
23
23
  }, z.core.$strip>, z.ZodObject<{
24
- maxContextThreshold: z.ZodOptional<z.ZodNumber>;
24
+ maxTokensSize: z.ZodOptional<z.ZodNumber>;
25
25
  responseTruncationThreshold: z.ZodOptional<z.ZodNumber>;
26
26
  }, z.core.$strip>]>>;
27
27
  callback: z.ZodString;
@@ -78,7 +78,7 @@ export declare const AgentDefinitionSchema: z.ZodObject<{
78
78
  setting: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
79
79
  threshold: z.ZodNumber;
80
80
  }, z.core.$strip>, z.ZodObject<{
81
- maxContextThreshold: z.ZodOptional<z.ZodNumber>;
81
+ maxTokensSize: z.ZodOptional<z.ZodNumber>;
82
82
  responseTruncationThreshold: z.ZodOptional<z.ZodNumber>;
83
83
  }, z.core.$strip>]>>;
84
84
  callback: z.ZodString;
@@ -64,7 +64,7 @@ export const HookConfigSchema = z.object({
64
64
  }),
65
65
  // For tool_response hooks
66
66
  z.object({
67
- maxContextThreshold: z.number().min(0).max(100).optional(),
67
+ maxTokensSize: z.number().min(0).optional(),
68
68
  responseTruncationThreshold: z.number().min(0).max(100).optional(),
69
69
  }),
70
70
  ])
@@ -9,7 +9,7 @@ export declare const zAgentRunnerParams: z.ZodObject<{
9
9
  suggestedPrompts: z.ZodOptional<z.ZodArray<z.ZodString>>;
10
10
  systemPrompt: z.ZodNullable<z.ZodString>;
11
11
  model: z.ZodString;
12
- tools: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"town_web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">, z.ZodLiteral<"browser">]>, z.ZodObject<{
12
+ tools: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"artifacts">, z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"town_web_search">, z.ZodLiteral<"filesystem">, z.ZodLiteral<"generate_image">, z.ZodLiteral<"town_generate_image">, z.ZodLiteral<"browser">]>, z.ZodObject<{
13
13
  type: z.ZodLiteral<"custom">;
14
14
  modulePath: z.ZodString;
15
15
  }, z.core.$strip>, z.ZodObject<{
@@ -48,7 +48,7 @@ export declare const zAgentRunnerParams: z.ZodObject<{
48
48
  setting: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
49
49
  threshold: z.ZodNumber;
50
50
  }, z.core.$strip>, z.ZodObject<{
51
- maxContextThreshold: z.ZodOptional<z.ZodNumber>;
51
+ maxTokensSize: z.ZodOptional<z.ZodNumber>;
52
52
  responseTruncationThreshold: z.ZodOptional<z.ZodNumber>;
53
53
  }, z.core.$strip>]>>;
54
54
  callback: z.ZodString;
@@ -74,6 +74,8 @@ export interface ConfigOverrides {
74
74
  }
75
75
  export type InvokeRequest = Omit<PromptRequest, "_meta"> & {
76
76
  messageId: string;
77
+ /** Agent directory path for session-scoped file storage */
78
+ agentDir?: string;
77
79
  sessionMeta?: Record<string, unknown>;
78
80
  contextMessages?: SessionMessage[];
79
81
  configOverrides?: ConfigOverrides;
@@ -41,6 +41,7 @@ export declare class HookExecutor {
41
41
  }): Promise<{
42
42
  modifiedOutput?: Record<string, unknown>;
43
43
  truncationWarning?: string;
44
+ metadata?: Record<string, unknown>;
44
45
  notifications: HookNotification[];
45
46
  }>;
46
47
  /**
@@ -137,6 +137,9 @@ export class HookExecutor {
137
137
  if (result.truncationWarning !== undefined) {
138
138
  response.truncationWarning = result.truncationWarning;
139
139
  }
140
+ if (result.metadata !== undefined) {
141
+ response.metadata = result.metadata;
142
+ }
140
143
  return response;
141
144
  }
142
145
  }
@@ -152,7 +155,11 @@ export class HookExecutor {
152
155
  const notifications = [];
153
156
  // Get threshold from settings
154
157
  const settings = hook.setting;
155
- const threshold = settings?.maxContextThreshold ?? 80;
158
+ // For notifications, calculate a percentage based on maxTokensSize relative to maxTokens
159
+ // Default to 20000 tokens, which we'll convert to a percentage for display
160
+ const maxTokensSize = settings?.maxTokensSize ?? 20000;
161
+ // Calculate approximate percentage: maxTokensSize / maxTokens * 100
162
+ const threshold = Math.min(100, Math.round((maxTokensSize / maxTokens) * 100));
156
163
  // Capture start time and emit hook_triggered BEFORE callback runs
157
164
  // This allows the UI to show the loading state immediately
158
165
  const triggeredAt = Date.now();
@@ -185,8 +192,15 @@ export class HookExecutor {
185
192
  toolResponse,
186
193
  };
187
194
  const result = await callback(hookContext);
195
+ logger.info("Hook callback result", {
196
+ hasMetadata: !!result.metadata,
197
+ metadataAction: result.metadata?.action,
198
+ hasModifiedOutput: !!result.metadata?.modifiedOutput,
199
+ modifiedOutputType: typeof result.metadata?.modifiedOutput,
200
+ toolCallId: toolResponse.toolCallId,
201
+ });
188
202
  // Extract modified output and warnings from metadata
189
- if (result.metadata && result.metadata.modifiedOutput) {
203
+ if (result.metadata?.modifiedOutput) {
190
204
  // Hook took action - emit completed notification
191
205
  const response = { notifications };
192
206
  response.modifiedOutput = result.metadata.modifiedOutput;
@@ -194,6 +208,8 @@ export class HookExecutor {
194
208
  response.truncationWarning = result.metadata
195
209
  .truncationWarning;
196
210
  }
211
+ // Include full metadata for persistence
212
+ response.metadata = result.metadata;
197
213
  // Notify completion
198
214
  this.emitNotification({
199
215
  type: "hook_completed",
@@ -11,7 +11,7 @@ export const compactionTool = async (ctx) => {
11
11
  logger.info("Compaction tool triggered", {
12
12
  currentTokens: ctx.currentTokens,
13
13
  maxTokens: ctx.maxTokens,
14
- percentage: ctx.percentage.toFixed(2) + "%",
14
+ percentage: `${ctx.percentage.toFixed(2)}%`,
15
15
  contextEntries: ctx.session.context.length,
16
16
  totalMessages: ctx.session.messages.length,
17
17
  model: ctx.model,
@@ -107,7 +107,8 @@ Please provide your summary based on the conversation above, following this stru
107
107
  .join("\n")
108
108
  : "Failed to extract summary";
109
109
  // Extract token usage from LLM response
110
- const responseUsage = response.usage_metadata;
110
+ const responseUsage = response
111
+ .usage_metadata;
111
112
  const summaryTokens = responseUsage?.output_tokens ?? 0;
112
113
  const inputTokensUsed = responseUsage?.input_tokens ?? ctx.currentTokens;
113
114
  logger.info("Generated compaction summary", {
@@ -1,6 +1,2 @@
1
1
  import type { HookCallback } from "../types.js";
2
- /**
3
- * Tool response compaction hook - compacts or truncates large tool responses
4
- * to prevent context overflow
5
- */
6
2
  export declare const toolResponseCompactor: HookCallback;
@@ -15,6 +15,8 @@ const COMPACTION_MODEL_CONTEXT = 200000; // Haiku context size for calculating t
15
15
  * Tool response compaction hook - compacts or truncates large tool responses
16
16
  * to prevent context overflow
17
17
  */
18
+ // Tools that should never be compacted (internal/small response tools)
19
+ const SKIP_COMPACTION_TOOLS = new Set(["todo_write", "TodoWrite"]);
18
20
  export const toolResponseCompactor = async (ctx) => {
19
21
  // Only process if we have tool response data
20
22
  if (!ctx.toolResponse) {
@@ -22,28 +24,38 @@ export const toolResponseCompactor = async (ctx) => {
22
24
  return { newContextEntry: null };
23
25
  }
24
26
  const { toolCallId, toolName, toolInput, rawOutput, outputTokens } = ctx.toolResponse;
27
+ // Skip compaction for certain internal tools
28
+ if (SKIP_COMPACTION_TOOLS.has(toolName)) {
29
+ logger.debug("Skipping compaction for internal tool", { toolName });
30
+ return { newContextEntry: null };
31
+ }
25
32
  // Get settings from hook configuration
26
33
  const settings = ctx.session.requestParams.hookSettings;
27
- const maxContextThreshold = settings?.maxContextThreshold ?? 80;
34
+ const maxTokensSize = settings?.maxTokensSize ?? 20000; // Default: 20000 tokens
28
35
  const responseTruncationThreshold = settings?.responseTruncationThreshold ?? 30;
29
- // Calculate actual token limits from percentages
30
- const maxAllowedTotal = ctx.maxTokens * (maxContextThreshold / 100);
31
- const availableSpace = maxAllowedTotal - ctx.currentTokens;
32
- const projectedTotal = ctx.currentTokens + outputTokens;
36
+ // Use maxTokensSize directly as it's now in tokens
37
+ const maxAllowedResponseSize = maxTokensSize;
38
+ // Calculate available space in context
39
+ const availableSpace = ctx.maxTokens - ctx.currentTokens;
40
+ // Failsafe: if available space is less than maxTokensSize, use availableSpace - 10%
41
+ const effectiveMaxResponseSize = availableSpace < maxAllowedResponseSize
42
+ ? Math.floor(availableSpace * 0.9)
43
+ : maxAllowedResponseSize;
33
44
  const compactionLimit = COMPACTION_MODEL_CONTEXT * (responseTruncationThreshold / 100);
34
45
  logger.info("Tool response compaction hook triggered", {
35
46
  toolCallId,
36
47
  toolName,
37
48
  outputTokens,
38
49
  currentContext: ctx.currentTokens,
39
- maxAllowedTotal,
50
+ maxTokens: ctx.maxTokens,
51
+ maxAllowedResponseSize,
40
52
  availableSpace,
41
- projectedTotal,
53
+ effectiveMaxResponseSize,
42
54
  compactionLimit,
43
55
  settings,
44
56
  });
45
57
  // Case 0: Small response, no action needed
46
- if (projectedTotal < maxAllowedTotal) {
58
+ if (outputTokens <= effectiveMaxResponseSize) {
47
59
  logger.info("Tool response fits within threshold, no compaction needed");
48
60
  return {
49
61
  newContextEntry: null,
@@ -55,19 +67,20 @@ export const toolResponseCompactor = async (ctx) => {
55
67
  };
56
68
  }
57
69
  // Response would exceed threshold, need to compact or truncate
58
- // Determine target size: fit within available space, but cap at compactionLimit for truncation
59
- // IMPORTANT: If context is already over threshold, availableSpace will be negative
70
+ // Determine target size: use effectiveMaxResponseSize, but cap at compactionLimit for truncation
71
+ // IMPORTANT: If context is already very full, availableSpace might be very small
60
72
  // In that case, use a minimum reasonable target size (e.g., 10% of the output or 1000 tokens)
61
73
  const minTargetSize = Math.max(Math.floor(outputTokens * 0.1), 1000);
62
- const targetSize = availableSpace > 0
63
- ? Math.min(availableSpace, compactionLimit)
74
+ const targetSize = effectiveMaxResponseSize > 0
75
+ ? Math.min(effectiveMaxResponseSize, compactionLimit)
64
76
  : minTargetSize;
65
77
  logger.info("Calculated target size for compaction", {
66
78
  availableSpace,
79
+ effectiveMaxResponseSize,
67
80
  compactionLimit,
68
81
  minTargetSize,
69
82
  targetSize,
70
- contextAlreadyOverThreshold: availableSpace <= 0,
83
+ contextAlreadyOverThreshold: availableSpace <= maxAllowedResponseSize,
71
84
  });
72
85
  // Case 2: Huge response, must truncate (too large for LLM compaction)
73
86
  if (outputTokens >= compactionLimit) {
@@ -89,7 +102,7 @@ export const toolResponseCompactor = async (ctx) => {
89
102
  // Try more aggressive truncation (70% of target as emergency measure)
90
103
  const emergencySize = Math.floor(targetSize * 0.7);
91
104
  const emergencyTruncated = truncateToolResponse(rawOutput, emergencySize);
92
- let emergencyTokens = countToolResultTokens(emergencyTruncated);
105
+ const emergencyTokens = countToolResultTokens(emergencyTruncated);
93
106
  // Final safety check - if emergency truncation STILL exceeded target, use ultra-conservative fallback
94
107
  if (emergencyTokens > targetSize) {
95
108
  logger.error("Emergency truncation STILL exceeded target - using ultra-conservative fallback", {
@@ -133,7 +146,7 @@ export const toolResponseCompactor = async (ctx) => {
133
146
  originalTokens: outputTokens,
134
147
  finalTokens,
135
148
  modifiedOutput: truncated,
136
- truncationWarning: `Tool response was truncated from ${outputTokens.toLocaleString()} to ${finalTokens.toLocaleString()} tokens to fit within context limit (available space: ${availableSpace.toLocaleString()} tokens)`,
149
+ truncationWarning: `Tool response was truncated from ${outputTokens.toLocaleString()} to ${finalTokens.toLocaleString()} tokens to fit within max response size limit (max allowed: ${effectiveMaxResponseSize.toLocaleString()} tokens)`,
137
150
  },
138
151
  };
139
152
  }
@@ -141,6 +154,7 @@ export const toolResponseCompactor = async (ctx) => {
141
154
  logger.info("Tool response requires intelligent compaction", {
142
155
  outputTokens,
143
156
  targetSize,
157
+ effectiveMaxResponseSize,
144
158
  availableSpace,
145
159
  compactionLimit,
146
160
  });
@@ -151,7 +165,7 @@ export const toolResponseCompactor = async (ctx) => {
151
165
  .map((msg) => {
152
166
  const text = msg.content
153
167
  .filter((b) => b.type === "text")
154
- .map((b) => b.text)
168
+ .map((b) => (b.type === "text" ? b.text : ""))
155
169
  .join("\n");
156
170
  return `${msg.role}: ${text}`;
157
171
  })
@@ -18,11 +18,11 @@ export interface ContextSizeSettings {
18
18
  */
19
19
  export interface ToolResponseSettings {
20
20
  /**
21
- * Maximum % of main model context that tool response + current context can reach
22
- * If adding the tool response would exceed this, compaction is triggered
23
- * Default: 80
21
+ * Maximum size of a tool response in tokens.
22
+ * Tool responses larger than this will trigger compaction.
23
+ * Default: 20000
24
24
  */
25
- maxContextThreshold?: number | undefined;
25
+ maxTokensSize?: number | undefined;
26
26
  /**
27
27
  * Maximum % of compaction model context (Haiku: 200k) that a tool response can be
28
28
  * to attempt LLM-based compaction. Larger responses are truncated instead.
@@ -141,7 +141,6 @@ export declare function createContextEntry(messages: Array<{
141
141
  toolInputTokens: number;
142
142
  toolResultsTokens: number;
143
143
  totalEstimated: number;
144
- llmReportedInputTokens?: number | undefined;
145
144
  }): ContextEntry;
146
145
  /**
147
146
  * Helper function to create a full message entry for context
@@ -13,5 +13,6 @@ export declare class LangchainAgent implements AgentRunner {
13
13
  constructor(params: CreateAgentRunnerParams);
14
14
  invoke(req: InvokeRequest): AsyncGenerator<ExtendedSessionUpdate, PromptResponse, undefined>;
15
15
  private invokeInternal;
16
+ private invokeWithContext;
16
17
  }
17
18
  export { makeSubagentsTool } from "./tools/subagent.js";