@townco/agent 0.1.50 → 0.1.51

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.
@@ -3,7 +3,7 @@ import type { SessionMessage } from "../agent-runner";
3
3
  /**
4
4
  * Hook types supported by the agent system
5
5
  */
6
- export type HookType = "context_size";
6
+ export type HookType = "context_size" | "tool_response";
7
7
  /**
8
8
  * Settings for context_size hook
9
9
  */
@@ -13,6 +13,24 @@ export interface ContextSizeSettings {
13
13
  */
14
14
  threshold: number;
15
15
  }
16
+ /**
17
+ * Settings for tool_response hook
18
+ */
19
+ export interface ToolResponseSettings {
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
24
+ */
25
+ maxContextThreshold?: number | undefined;
26
+ /**
27
+ * Maximum % of compaction model context (Haiku: 200k) that a tool response can be
28
+ * to attempt LLM-based compaction. Larger responses are truncated instead.
29
+ * The truncation limit is also this percentage.
30
+ * Default: 30
31
+ */
32
+ responseTruncationThreshold?: number | undefined;
33
+ }
16
34
  /**
17
35
  * Hook configuration in agent definition
18
36
  */
@@ -24,7 +42,7 @@ export interface HookConfig {
24
42
  /**
25
43
  * Optional hook-specific settings
26
44
  */
27
- setting?: ContextSizeSettings | undefined;
45
+ setting?: ContextSizeSettings | ToolResponseSettings | undefined;
28
46
  /**
29
47
  * Callback reference - either a predefined hook name or a file path
30
48
  * Examples: "compaction_tool" or "./hooks/my_compaction_tool.ts"
@@ -72,6 +90,16 @@ export interface HookContext {
72
90
  * The model being used
73
91
  */
74
92
  model: string;
93
+ /**
94
+ * Tool response data (only for tool_response hooks)
95
+ */
96
+ toolResponse?: {
97
+ toolCallId: string;
98
+ toolName: string;
99
+ toolInput: Record<string, unknown>;
100
+ rawOutput: Record<string, unknown>;
101
+ outputTokens: number;
102
+ };
75
103
  }
76
104
  /**
77
105
  * Result returned by hook callbacks
@@ -106,7 +134,15 @@ export declare function createContextEntry(messages: Array<{
106
134
  } | {
107
135
  type: "full";
108
136
  message: SessionMessage;
109
- }>, timestamp?: string, compactedUpTo?: number, inputTokens?: number): ContextEntry;
137
+ }>, timestamp?: string, compactedUpTo?: number, context_size?: {
138
+ systemPromptTokens: number;
139
+ userMessagesTokens: number;
140
+ assistantMessagesTokens: number;
141
+ toolInputTokens: number;
142
+ toolResultsTokens: number;
143
+ totalEstimated: number;
144
+ llmReportedInputTokens?: number | undefined;
145
+ }): ContextEntry;
110
146
  /**
111
147
  * Helper function to create a full message entry for context
112
148
  * Use this when hooks need to inject new messages into context
@@ -2,17 +2,22 @@
2
2
  * Helper function to create a new context entry
3
3
  * Use this when hooks want to create a new context snapshot
4
4
  */
5
- export function createContextEntry(messages, timestamp, compactedUpTo, inputTokens) {
5
+ export function createContextEntry(messages, timestamp, compactedUpTo, context_size) {
6
6
  const entry = {
7
7
  timestamp: timestamp || new Date().toISOString(),
8
8
  messages,
9
+ context_size: context_size || {
10
+ systemPromptTokens: 0,
11
+ userMessagesTokens: 0,
12
+ assistantMessagesTokens: 0,
13
+ toolInputTokens: 0,
14
+ toolResultsTokens: 0,
15
+ totalEstimated: 0,
16
+ },
9
17
  };
10
18
  if (compactedUpTo !== undefined) {
11
19
  entry.compactedUpTo = compactedUpTo;
12
20
  }
13
- if (inputTokens !== undefined) {
14
- entry.inputTokens = inputTokens;
15
- }
16
21
  return entry;
17
22
  }
18
23
  /**
@@ -161,11 +161,103 @@ export class LangchainAgent {
161
161
  if ((this.definition.mcps?.length ?? 0) > 0) {
162
162
  enabledTools.push(...(await makeMcpToolsClient(this.definition.mcps).getTools()));
163
163
  }
164
+ // Wrap tools with response compaction if hook is configured
165
+ const hooks = this.definition.hooks ?? [];
166
+ const hasToolResponseHook = hooks.some((h) => h.type === "tool_response");
167
+ const noSession = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true; // Subagents don't have session storage
168
+ // Track cumulative tool output tokens in this turn for proper context calculation
169
+ let cumulativeToolOutputTokens = 0;
170
+ let wrappedTools = enabledTools;
171
+ if (hasToolResponseHook && !noSession) {
172
+ const { countToolResultTokens } = await import("../../utils/token-counter.js");
173
+ const { toolResponseCompactor } = await import("../hooks/predefined/tool-response-compactor.js");
174
+ wrappedTools = enabledTools.map((originalTool) => {
175
+ const wrappedFunc = async (input) => {
176
+ // Execute the original tool
177
+ const result = await originalTool.invoke(input);
178
+ // Check if result should be compacted
179
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
180
+ const rawOutput = { content: resultStr };
181
+ const outputTokens = countToolResultTokens(rawOutput);
182
+ // Skip compaction for small results (under 10k tokens)
183
+ if (outputTokens < 10000) {
184
+ // Still track this in cumulative total
185
+ cumulativeToolOutputTokens += outputTokens;
186
+ return result;
187
+ }
188
+ _logger.info("Tool wrapper: compacting large tool result", {
189
+ toolName: originalTool.name,
190
+ originalTokens: outputTokens,
191
+ cumulativeToolOutputTokens,
192
+ });
193
+ // Calculate current context including all tool outputs so far in this turn
194
+ // This ensures we account for multiple large tool calls in the same turn
195
+ const baseContextTokens = turnTokenUsage.inputTokens || 10000;
196
+ const currentTokens = baseContextTokens + cumulativeToolOutputTokens;
197
+ const maxTokens = 200000; // Claude's limit
198
+ // Build proper hook context with all required fields
199
+ const hookContext = {
200
+ session: {
201
+ messages: req.contextMessages || [],
202
+ context: [],
203
+ requestParams: {
204
+ hookSettings: hooks.find((h) => h.type === "tool_response")
205
+ ?.setting,
206
+ },
207
+ },
208
+ currentTokens,
209
+ maxTokens,
210
+ percentage: (currentTokens / maxTokens) * 100,
211
+ model: this.definition.model,
212
+ toolResponse: {
213
+ toolCallId: "pending",
214
+ toolName: originalTool.name,
215
+ toolInput: input,
216
+ rawOutput,
217
+ outputTokens,
218
+ },
219
+ };
220
+ // Call the tool response compactor directly
221
+ const hookResult = await toolResponseCompactor(hookContext);
222
+ // Extract modified output from metadata
223
+ if (hookResult?.metadata?.modifiedOutput) {
224
+ const modifiedOutput = hookResult.metadata
225
+ .modifiedOutput;
226
+ const compactedTokens = countToolResultTokens(modifiedOutput);
227
+ // Update cumulative total with the compacted size (not original!)
228
+ cumulativeToolOutputTokens += compactedTokens;
229
+ _logger.info("Tool wrapper: compaction complete", {
230
+ toolName: originalTool.name,
231
+ originalTokens: outputTokens,
232
+ compactedTokens,
233
+ reduction: `${((1 - compactedTokens / outputTokens) * 100).toFixed(1)}%`,
234
+ totalCumulativeTokens: cumulativeToolOutputTokens,
235
+ });
236
+ return typeof result === "string"
237
+ ? modifiedOutput.content
238
+ : JSON.stringify(modifiedOutput);
239
+ }
240
+ // No compaction happened, count original size
241
+ cumulativeToolOutputTokens += outputTokens;
242
+ return result;
243
+ };
244
+ // Create new tool with wrapped function
245
+ const wrappedTool = tool(wrappedFunc, {
246
+ name: originalTool.name,
247
+ description: originalTool.description,
248
+ schema: originalTool.schema,
249
+ });
250
+ // Preserve metadata
251
+ wrappedTool.prettyName = originalTool.prettyName;
252
+ wrappedTool.icon = originalTool.icon;
253
+ return wrappedTool;
254
+ });
255
+ }
164
256
  // Filter tools if running in subagent mode
165
257
  const isSubagent = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true;
166
258
  const finalTools = isSubagent
167
- ? enabledTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
168
- : enabledTools;
259
+ ? wrappedTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
260
+ : wrappedTools;
169
261
  // Create the model instance using the factory
170
262
  // This detects the provider from the model string:
171
263
  // - "gemini-2.0-flash" → Google Generative AI
@@ -187,79 +279,6 @@ export class LangchainAgent {
187
279
  const agent = createAgent(agentConfig);
188
280
  // Add logging callbacks for model requests
189
281
  const provider = detectProvider(this.definition.model);
190
- const loggingCallback = {
191
- handleChatModelStart: async (_llm, messages, runId, parentRunId, extraParams) => {
192
- _logger.info("Model request started", {
193
- provider,
194
- model: this.definition.model,
195
- runId,
196
- parentRunId,
197
- messageCount: messages.length,
198
- extraParams,
199
- });
200
- },
201
- handleLLMEnd: async (output, runId, parentRunId, tags, extraParams) => {
202
- // Extract token usage from output
203
- const llmResult = output;
204
- _logger.info("Model request completed", {
205
- provider,
206
- model: this.definition.model,
207
- runId,
208
- parentRunId,
209
- tags,
210
- tokenUsage: llmResult.llmOutput?.tokenUsage,
211
- generationCount: llmResult.generations?.length,
212
- extraParams,
213
- });
214
- },
215
- handleLLMError: async (error, runId, parentRunId, tags) => {
216
- _logger.error("Model request failed", {
217
- provider,
218
- model: this.definition.model,
219
- runId,
220
- parentRunId,
221
- tags,
222
- error: error.message,
223
- stack: error.stack,
224
- });
225
- },
226
- handleToolStart: async (_tool, input, runId, parentRunId, tags, metadata, runName) => {
227
- if (process.env.DEBUG_TELEMETRY === "true") {
228
- console.log(`[handleToolStart] runId=${runId}, runName=${runName}, parentRunId=${parentRunId}`);
229
- console.log(`[handleToolStart] Active context span:`, trace.getSpan(context.active())?.spanContext());
230
- }
231
- _logger.info("Tool started", {
232
- runId,
233
- parentRunId,
234
- runName,
235
- tags,
236
- metadata,
237
- input: input.substring(0, 200), // Truncate for logging
238
- });
239
- },
240
- handleToolEnd: async (_output, runId, parentRunId, tags) => {
241
- if (process.env.DEBUG_TELEMETRY === "true") {
242
- console.log(`[handleToolEnd] runId=${runId}, parentRunId=${parentRunId}`);
243
- }
244
- _logger.info("Tool completed", {
245
- runId,
246
- parentRunId,
247
- tags,
248
- });
249
- },
250
- handleToolError: async (error, runId, parentRunId, tags) => {
251
- if (process.env.DEBUG_TELEMETRY === "true") {
252
- console.log(`[handleToolError] runId=${runId}, error=${error.message}`);
253
- }
254
- _logger.error("Tool failed", {
255
- runId,
256
- parentRunId,
257
- tags,
258
- error: error.message,
259
- stack: error.stack,
260
- });
261
- },
262
- };
263
282
  // Build messages from context history if available, otherwise use just the prompt
264
283
  let messages;
265
284
  if (req.contextMessages && req.contextMessages.length > 0) {
@@ -303,7 +322,7 @@ export class LangchainAgent {
303
322
  const stream = context.with(invocationContext, () => agent.stream({ messages }, {
304
323
  streamMode: ["updates", "messages"],
305
324
  recursionLimit: 200,
306
- callbacks: [loggingCallback, otelCallbacks],
325
+ callbacks: [otelCallbacks],
307
326
  }));
308
327
  for await (const streamItem of await stream) {
309
328
  const [streamMode, chunk] = streamItem;
@@ -0,0 +1 @@
1
+ export declare function linkLocalPackages(projectPath: string): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import { exists } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { $ } from "bun";
4
+ const PACKAGE_PATHS = {
5
+ "@townco/ui": "packages/ui",
6
+ "@townco/core": "packages/core",
7
+ "@townco/tsconfig": "packages/tsconfig",
8
+ "@townco/tui-template": "apps/tui",
9
+ "@townco/gui-template": "apps/gui",
10
+ "@townco/secret": "packages/secret",
11
+ "@townco/agent": "packages/agent",
12
+ "@townco/cli": "apps/cli",
13
+ };
14
+ async function getMonorepoRoot() {
15
+ try {
16
+ // 1. Get git repo root
17
+ const result = await $ `git rev-parse --show-toplevel`.quiet();
18
+ const repoRoot = result.text().trim();
19
+ // 2. Check package.json name === "town"
20
+ const pkgJsonPath = join(repoRoot, "package.json");
21
+ const pkgJson = await Bun.file(pkgJsonPath).json();
22
+ if (pkgJson.name !== "town")
23
+ return null;
24
+ // 3. Check packages/agent and packages/ui exist
25
+ const agentExists = await exists(join(repoRoot, "packages/agent"));
26
+ const uiExists = await exists(join(repoRoot, "packages/ui"));
27
+ if (!agentExists || !uiExists)
28
+ return null;
29
+ return repoRoot;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export async function linkLocalPackages(projectPath) {
36
+ const repoRoot = await getMonorepoRoot();
37
+ if (!repoRoot)
38
+ return; // Not in monorepo, no-op
39
+ console.log("Detected town monorepo, linking local packages...");
40
+ // 1. Register each local package globally
41
+ for (const [, localPath] of Object.entries(PACKAGE_PATHS)) {
42
+ const pkgPath = join(repoRoot, localPath);
43
+ await $ `bun link`.cwd(pkgPath).quiet();
44
+ }
45
+ // 2. Parse project's package.json for @townco/* deps
46
+ const pkgJson = await Bun.file(join(projectPath, "package.json")).json();
47
+ const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
48
+ const towncoPackages = Object.keys(deps).filter((name) => name.startsWith("@townco/"));
49
+ // 3. Link each package in the project
50
+ for (const pkgName of towncoPackages) {
51
+ await $ `bun link ${pkgName}`.cwd(projectPath);
52
+ console.log(`Linked ${pkgName}`);
53
+ }
54
+ }
@@ -21,6 +21,7 @@ function generateProjectPackageJson() {
21
21
  "@radix-ui/react-select": "^2.2.6",
22
22
  "@radix-ui/react-slot": "^1.2.4",
23
23
  "@radix-ui/react-tabs": "^1.1.13",
24
+ "@townco/core": "~0.0.23",
24
25
  "@townco/ui": "^0.1.0",
25
26
  "@townco/agent": "^0.1.20",
26
27
  "class-variance-authority": "^0.7.1",
@@ -23,6 +23,13 @@ export interface TemplateVars {
23
23
  threshold: number;
24
24
  } | undefined;
25
25
  callback: string;
26
+ } | {
27
+ type: "tool_response";
28
+ setting?: {
29
+ maxContextThreshold?: number | undefined;
30
+ responseTruncationThreshold?: number | undefined;
31
+ } | undefined;
32
+ callback: string;
26
33
  }> | undefined;
27
34
  }
28
35
  export declare function getTemplateVars(name: string, definition: AgentDefinition): TemplateVars;