@townco/agent 0.1.83 → 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.
@@ -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) {
@@ -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
  });
@@ -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
@@ -2,7 +2,7 @@ import { mkdir } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
4
4
  import { context, propagation, trace } from "@opentelemetry/api";
5
- import { getShedAuth } from "@townco/core/auth";
5
+ import { ensureAuthenticated } from "@townco/core/auth";
6
6
  import { AIMessageChunk, createAgent, ToolMessage, tool, } from "langchain";
7
7
  import { z } from "zod";
8
8
  import { SUBAGENT_MODE_KEY } from "../../acp-server/adapter";
@@ -356,7 +356,8 @@ export class LangchainAgent {
356
356
  // MCP tools - calculate overhead separately
357
357
  let mcpOverheadTokens = 0;
358
358
  if ((this.definition.mcps?.length ?? 0) > 0) {
359
- const mcpTools = await makeMcpToolsClient(this.definition.mcps).getTools();
359
+ const client = await makeMcpToolsClient(this.definition.mcps);
360
+ const mcpTools = await client.getTools();
360
361
  const mcpToolMetadata = mcpTools.map(extractToolMetadata);
361
362
  mcpOverheadTokens = estimateAllToolsOverhead(mcpToolMetadata);
362
363
  enabledTools.push(...mcpTools);
@@ -447,9 +448,25 @@ export class LangchainAgent {
447
448
  reduction: `${((1 - compactedTokens / outputTokens) * 100).toFixed(1)}%`,
448
449
  totalCumulativeTokens: cumulativeToolOutputTokens,
449
450
  });
450
- return typeof result === "string"
451
- ? modifiedOutput.content
452
- : JSON.stringify(modifiedOutput);
451
+ // Include compaction metadata in the output for the adapter to extract
452
+ // Also include original content so adapter can store it
453
+ const originalContentStr = typeof rawOutput === "object" &&
454
+ rawOutput !== null &&
455
+ "content" in rawOutput
456
+ ? String(rawOutput.content)
457
+ : JSON.stringify(rawOutput);
458
+ const outputWithMeta = {
459
+ ...modifiedOutput,
460
+ _compactionMeta: {
461
+ action: hookResult.metadata.action,
462
+ originalTokens: hookResult.metadata.originalTokens,
463
+ finalTokens: hookResult.metadata.finalTokens,
464
+ tokensSaved: hookResult.metadata.tokensSaved,
465
+ originalContent: originalContentStr,
466
+ },
467
+ };
468
+ // Always return JSON string to preserve metadata
469
+ return JSON.stringify(outputWithMeta);
453
470
  }
454
471
  // No compaction happened, count original size
455
472
  cumulativeToolOutputTokens += outputTokens;
@@ -1037,6 +1054,40 @@ export class LangchainAgent {
1037
1054
  _meta: { messageId: req.messageId },
1038
1055
  });
1039
1056
  // Buffer tool output separately
1057
+ // Check if the content contains compaction metadata and extract it
1058
+ let rawOutput = {
1059
+ content: aiMessage.content,
1060
+ };
1061
+ let compactionMeta;
1062
+ try {
1063
+ const parsed = JSON.parse(aiMessage.content);
1064
+ if (typeof parsed === "object" &&
1065
+ parsed !== null &&
1066
+ "_compactionMeta" in parsed) {
1067
+ // Extract compaction metadata to top level of rawOutput
1068
+ const { _compactionMeta, ...contentWithoutMeta } = parsed;
1069
+ compactionMeta = _compactionMeta;
1070
+ rawOutput = {
1071
+ content: JSON.stringify(contentWithoutMeta),
1072
+ _compactionMeta,
1073
+ };
1074
+ }
1075
+ }
1076
+ catch {
1077
+ // Not valid JSON, use original content
1078
+ }
1079
+ // For content display, use cleaned version if compaction occurred
1080
+ let displayContent = aiMessage.content;
1081
+ if (compactionMeta) {
1082
+ try {
1083
+ const parsed = JSON.parse(aiMessage.content);
1084
+ const { _compactionMeta: _, ...cleanParsed } = parsed;
1085
+ displayContent = JSON.stringify(cleanParsed);
1086
+ }
1087
+ catch {
1088
+ // Keep original if parsing fails
1089
+ }
1090
+ }
1040
1091
  pendingToolCallNotifications.push({
1041
1092
  sessionUpdate: "tool_output",
1042
1093
  toolCallId: aiMessage.tool_call_id,
@@ -1045,11 +1096,11 @@ export class LangchainAgent {
1045
1096
  type: "content",
1046
1097
  content: {
1047
1098
  type: "text",
1048
- text: aiMessage.content,
1099
+ text: displayContent,
1049
1100
  },
1050
1101
  },
1051
1102
  ],
1052
- rawOutput: { content: aiMessage.content },
1103
+ rawOutput,
1053
1104
  _meta: { messageId: req.messageId },
1054
1105
  });
1055
1106
  // Flush tool outputs after buffering
@@ -1119,11 +1170,11 @@ const modelRequestSchema = z.object({
1119
1170
  messages: z.array(z.any()),
1120
1171
  }),
1121
1172
  });
1122
- const makeMcpToolsClient = (mcpConfigs) => {
1123
- const mcpServers = mcpConfigs?.map((config) => {
1173
+ const makeMcpToolsClient = async (mcpConfigs) => {
1174
+ const mcpServers = await Promise.all((mcpConfigs ?? []).map(async (config) => {
1124
1175
  if (typeof config === "string") {
1125
1176
  // String configs use the centralized MCP proxy with auth
1126
- const shedAuth = getShedAuth();
1177
+ const shedAuth = await ensureAuthenticated();
1127
1178
  if (!shedAuth) {
1128
1179
  throw new Error("Not logged in. Run 'town login' or set SHED_API_KEY to use cloud MCP servers.");
1129
1180
  }
@@ -1155,7 +1206,7 @@ const makeMcpToolsClient = (mcpConfigs) => {
1155
1206
  args: config.args ?? [],
1156
1207
  },
1157
1208
  ];
1158
- });
1209
+ }));
1159
1210
  const client = new MultiServerMCPClient({
1160
1211
  // Global tool configuration options
1161
1212
  // Whether to throw on errors if a tool fails to load (optional, default: true)
@@ -177,9 +177,6 @@ async function deleteFromSupabase(storageKey) {
177
177
  throw new Error(`Failed to delete from Supabase Storage: ${error.message}`);
178
178
  }
179
179
  }
180
- /**
181
- * List files in Supabase Storage with optional prefix and recursion
182
- */
183
180
  async function listFilesInSupabase(sessionId, relativePath, recursive = false) {
184
181
  const supabase = getSupabaseClient();
185
182
  const bucket = getBucketName();
@@ -426,9 +423,6 @@ const artifactsUrl = tool(async ({ session_id, path, expires_in = 3600, }) => {
426
423
  .describe("Expiration time in seconds (1-31536000). Default: 3600 (1 hour)"),
427
424
  }),
428
425
  });
429
- // ============================================================================
430
- // Tool Metadata
431
- // ============================================================================
432
426
  // Add metadata for UI display
433
427
  artifactsCp.prettyName = "Copy Artifact";
434
428
  artifactsCp.icon = "Upload";
@@ -437,20 +431,23 @@ artifactsCp.verbiage = {
437
431
  past: "Copied artifact to {destination}",
438
432
  paramKey: "destination",
439
433
  };
440
- artifactsDel.prettyName = "Delete Artifact";
434
+ artifactsDel.prettyName =
435
+ "Delete Artifact";
441
436
  artifactsDel.icon = "Trash";
442
437
  artifactsDel.verbiage = {
443
438
  active: "Deleting artifact {path}",
444
439
  past: "Deleted artifact {path}",
445
440
  paramKey: "path",
446
441
  };
447
- artifactsLs.prettyName = "List Artifacts";
442
+ artifactsLs.prettyName =
443
+ "List Artifacts";
448
444
  artifactsLs.icon = "List";
449
445
  artifactsLs.verbiage = {
450
446
  active: "Listing artifacts",
451
447
  past: "Listed artifacts",
452
448
  };
453
- artifactsUrl.prettyName = "Generate Artifact URL";
449
+ artifactsUrl.prettyName =
450
+ "Generate Artifact URL";
454
451
  artifactsUrl.icon = "Link";
455
452
  artifactsUrl.verbiage = {
456
453
  active: "Generating URL for {path}",
@@ -31,7 +31,7 @@ export interface TemplateVars {
31
31
  } | {
32
32
  type: "tool_response";
33
33
  setting?: {
34
- maxContextThreshold?: number | undefined;
34
+ maxTokensSize?: number | undefined;
35
35
  responseTruncationThreshold?: number | undefined;
36
36
  } | undefined;
37
37
  callback: string;