@townco/agent 0.1.49 → 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.
Files changed (43) hide show
  1. package/dist/acp-server/adapter.d.ts +15 -0
  2. package/dist/acp-server/adapter.js +445 -67
  3. package/dist/acp-server/http.js +8 -1
  4. package/dist/acp-server/session-storage.d.ts +19 -0
  5. package/dist/acp-server/session-storage.js +9 -0
  6. package/dist/definition/index.d.ts +16 -4
  7. package/dist/definition/index.js +17 -4
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.js +10 -1
  10. package/dist/runner/agent-runner.d.ts +13 -2
  11. package/dist/runner/agent-runner.js +4 -0
  12. package/dist/runner/hooks/executor.d.ts +18 -1
  13. package/dist/runner/hooks/executor.js +74 -62
  14. package/dist/runner/hooks/predefined/compaction-tool.js +19 -3
  15. package/dist/runner/hooks/predefined/tool-response-compactor.d.ts +6 -0
  16. package/dist/runner/hooks/predefined/tool-response-compactor.js +461 -0
  17. package/dist/runner/hooks/registry.js +2 -0
  18. package/dist/runner/hooks/types.d.ts +39 -3
  19. package/dist/runner/hooks/types.js +9 -1
  20. package/dist/runner/langchain/index.d.ts +1 -0
  21. package/dist/runner/langchain/index.js +523 -321
  22. package/dist/runner/langchain/model-factory.js +1 -1
  23. package/dist/runner/langchain/otel-callbacks.d.ts +18 -0
  24. package/dist/runner/langchain/otel-callbacks.js +123 -0
  25. package/dist/runner/langchain/tools/subagent.js +21 -1
  26. package/dist/scaffold/link-local.d.ts +1 -0
  27. package/dist/scaffold/link-local.js +54 -0
  28. package/dist/scaffold/project-scaffold.js +1 -0
  29. package/dist/telemetry/index.d.ts +83 -0
  30. package/dist/telemetry/index.js +172 -0
  31. package/dist/telemetry/setup.d.ts +22 -0
  32. package/dist/telemetry/setup.js +141 -0
  33. package/dist/templates/index.d.ts +7 -0
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/dist/utils/context-size-calculator.d.ts +29 -0
  36. package/dist/utils/context-size-calculator.js +78 -0
  37. package/dist/utils/index.d.ts +2 -0
  38. package/dist/utils/index.js +2 -0
  39. package/dist/utils/token-counter.d.ts +19 -0
  40. package/dist/utils/token-counter.js +44 -0
  41. package/index.ts +16 -1
  42. package/package.json +24 -7
  43. package/templates/index.ts +18 -6
@@ -19,12 +19,27 @@ export declare class AgentAcpAdapter implements acp.Agent {
19
19
  private storage;
20
20
  private noSession;
21
21
  private agentDir;
22
+ private agentName;
23
+ private agentDisplayName;
24
+ private agentVersion;
25
+ private agentDescription;
26
+ private agentSuggestedPrompts;
22
27
  constructor(agent: AgentRunner, connection: acp.AgentSideConnection, agentDir?: string, agentName?: string);
28
+ /**
29
+ * Helper to save session to disk
30
+ * Call this after any modification to session.messages or session.context
31
+ */
32
+ private saveSessionToDisk;
23
33
  initialize(_params: acp.InitializeRequest): Promise<acp.InitializeResponse>;
24
34
  newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse>;
25
35
  loadSession(params: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse>;
26
36
  authenticate(_params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | undefined>;
27
37
  setSessionMode(_params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse>;
28
38
  prompt(params: acp.PromptRequest): Promise<acp.PromptResponse>;
39
+ /**
40
+ * Execute hooks if configured for this agent
41
+ * Returns new context entries that should be appended to session.context
42
+ */
43
+ private executeHooksIfConfigured;
29
44
  cancel(params: acp.CancelNotification): Promise<void>;
30
45
  }
@@ -1,6 +1,8 @@
1
1
  import * as acp from "@agentclientprotocol/sdk";
2
2
  import { createLogger } from "@townco/core";
3
3
  import { HookExecutor, loadHookCallback } from "../runner/hooks";
4
+ import { calculateContextSize, } from "../utils/context-size-calculator.js";
5
+ import { countToolResultTokens } from "../utils/token-counter.js";
4
6
  import { SessionStorage, } from "./session-storage.js";
5
7
  const logger = createLogger("adapter");
6
8
  /**
@@ -12,7 +14,7 @@ export const SUBAGENT_MODE_KEY = "town.com/isSubagent";
12
14
  * Create a context snapshot based on the previous context
13
15
  * Preserves full messages from previous context and adds new pointers
14
16
  */
15
- function createContextSnapshot(messageCount, timestamp, previousContext) {
17
+ function createContextSnapshot(messageCount, timestamp, previousContext, context_size) {
16
18
  const messages = [];
17
19
  if (previousContext) {
18
20
  // Start with all messages from previous context
@@ -45,7 +47,19 @@ function createContextSnapshot(messageCount, timestamp, previousContext) {
45
47
  messages.push({ type: "pointer", index: i });
46
48
  }
47
49
  }
48
- return { timestamp, messages };
50
+ return {
51
+ timestamp,
52
+ messages,
53
+ compactedUpTo: previousContext?.compactedUpTo,
54
+ context_size: context_size || {
55
+ systemPromptTokens: 0,
56
+ userMessagesTokens: 0,
57
+ assistantMessagesTokens: 0,
58
+ toolInputTokens: 0,
59
+ toolResultsTokens: 0,
60
+ totalEstimated: 0,
61
+ },
62
+ };
49
63
  }
50
64
  /**
51
65
  * Resolve context entries to session messages
@@ -84,11 +98,21 @@ export class AgentAcpAdapter {
84
98
  storage;
85
99
  noSession;
86
100
  agentDir;
101
+ agentName;
102
+ agentDisplayName;
103
+ agentVersion;
104
+ agentDescription;
105
+ agentSuggestedPrompts;
87
106
  constructor(agent, connection, agentDir, agentName) {
88
107
  this.connection = connection;
89
108
  this.sessions = new Map();
90
109
  this.agent = agent;
91
110
  this.agentDir = agentDir;
111
+ this.agentName = agentName;
112
+ this.agentDisplayName = agent.definition.displayName;
113
+ this.agentVersion = agent.definition.version;
114
+ this.agentDescription = agent.definition.description;
115
+ this.agentSuggestedPrompts = agent.definition.suggestedPrompts;
92
116
  this.noSession = process.env.TOWN_NO_SESSION === "true";
93
117
  this.storage =
94
118
  agentDir && agentName && !this.noSession
@@ -97,18 +121,65 @@ export class AgentAcpAdapter {
97
121
  logger.info("Initialized with", {
98
122
  agentDir,
99
123
  agentName,
124
+ agentDisplayName: this.agentDisplayName,
125
+ agentVersion: this.agentVersion,
126
+ agentDescription: this.agentDescription,
127
+ suggestedPrompts: this.agentSuggestedPrompts,
100
128
  noSession: this.noSession,
101
129
  hasStorage: this.storage !== null,
102
130
  sessionStoragePath: this.storage ? `${agentDir}/.sessions` : null,
103
131
  });
104
132
  }
133
+ /**
134
+ * Helper to save session to disk
135
+ * Call this after any modification to session.messages or session.context
136
+ */
137
+ async saveSessionToDisk(sessionId, session) {
138
+ if (!this.noSession && this.storage) {
139
+ try {
140
+ await this.storage.saveSession(sessionId, session.messages, session.context);
141
+ logger.debug("Saved session to disk", {
142
+ sessionId,
143
+ messageCount: session.messages.length,
144
+ contextCount: session.context.length,
145
+ });
146
+ }
147
+ catch (error) {
148
+ logger.error(`Failed to save session ${sessionId}`, {
149
+ error: error instanceof Error ? error.message : String(error),
150
+ });
151
+ }
152
+ }
153
+ }
105
154
  async initialize(_params) {
106
- return {
155
+ const response = {
107
156
  protocolVersion: acp.PROTOCOL_VERSION,
108
157
  agentCapabilities: {
109
158
  loadSession: !this.noSession && this.storage !== null,
110
159
  },
111
160
  };
161
+ if (this.agentName) {
162
+ response.agentInfo = {
163
+ name: this.agentName,
164
+ version: this.agentVersion ?? "0.0.0",
165
+ // title is the ACP field for human-readable display name
166
+ ...(this.agentDisplayName ? { title: this.agentDisplayName } : {}),
167
+ };
168
+ }
169
+ // Pass description and suggestedPrompts via _meta extension point
170
+ // since Implementation doesn't support these fields
171
+ if (this.agentDescription || this.agentSuggestedPrompts) {
172
+ response._meta = {
173
+ ...response._meta,
174
+ ...(this.agentDescription
175
+ ? { agentDescription: this.agentDescription }
176
+ : {}),
177
+ ...(this.agentSuggestedPrompts
178
+ ? { suggestedPrompts: this.agentSuggestedPrompts }
179
+ : {}),
180
+ };
181
+ }
182
+ return response;
112
183
  }
113
184
  async newSession(params) {
114
185
  const sessionId = Math.random().toString(36).substring(2);
@@ -211,6 +282,37 @@ export class AgentAcpAdapter {
211
282
  }
212
283
  }
213
284
  }
285
+ // After replay completes, send the latest context size to the UI
286
+ const latestContext = storedSession.context.length > 0
287
+ ? storedSession.context[storedSession.context.length - 1]
288
+ : undefined;
289
+ if (latestContext?.context_size) {
290
+ logger.info("Sending context size to UI after session replay", {
291
+ sessionId: params.sessionId,
292
+ contextSize: latestContext.context_size,
293
+ });
294
+ this.connection.sessionUpdate({
295
+ sessionId: params.sessionId,
296
+ update: {
297
+ sessionUpdate: "agent_message_chunk",
298
+ content: {
299
+ type: "text",
300
+ text: "",
301
+ },
302
+ _meta: {
303
+ context_size: latestContext.context_size,
304
+ isReplay: true,
305
+ },
306
+ },
307
+ });
308
+ }
309
+ else {
310
+ logger.warn("No context size available after session replay", {
311
+ sessionId: params.sessionId,
312
+ hasLatestContext: !!latestContext,
313
+ contextLength: storedSession.context.length,
314
+ });
315
+ }
214
316
  return {};
215
317
  }
216
318
  async authenticate(_params) {
@@ -256,12 +358,49 @@ export class AgentAcpAdapter {
256
358
  timestamp: new Date().toISOString(),
257
359
  };
258
360
  session.messages.push(userMessage);
361
+ logger.debug("User message added to session", {
362
+ sessionId: params.sessionId,
363
+ messageCount: session.messages.length,
364
+ });
365
+ // Save immediately after user message, even before context calculation
366
+ // This ensures the session file exists if anything crashes below
367
+ await this.saveSessionToDisk(params.sessionId, session);
368
+ logger.debug("Session saved after user message", {
369
+ sessionId: params.sessionId,
370
+ });
259
371
  // Create context snapshot based on previous context
372
+ logger.debug("Starting context snapshot creation", {
373
+ sessionId: params.sessionId,
374
+ });
260
375
  const previousContext = session.context.length > 0
261
376
  ? session.context[session.context.length - 1]
262
377
  : undefined;
263
- const contextSnapshot = createContextSnapshot(session.messages.length, new Date().toISOString(), previousContext);
378
+ // Calculate context size for this snapshot
379
+ // Build message pointers for the new context (previous messages + new user message)
380
+ const messageEntries = previousContext
381
+ ? [
382
+ ...previousContext.messages,
383
+ { type: "pointer", index: session.messages.length - 1 },
384
+ ]
385
+ : [{ type: "pointer", index: 0 }];
386
+ // Resolve message entries to actual messages
387
+ const contextMessages = [];
388
+ for (const entry of messageEntries) {
389
+ if (entry.type === "pointer") {
390
+ const message = session.messages[entry.index];
391
+ if (message) {
392
+ contextMessages.push(message);
393
+ }
394
+ }
395
+ else if (entry.type === "full") {
396
+ contextMessages.push(entry.message);
397
+ }
398
+ }
399
+ // Calculate context size - no LLM call yet, so only estimated values
400
+ const context_size = calculateContextSize(contextMessages, this.agent.definition.systemPrompt ?? undefined, undefined);
401
+ const contextSnapshot = createContextSnapshot(session.messages.length, new Date().toISOString(), previousContext, context_size);
264
402
  session.context.push(contextSnapshot);
403
+ await this.saveSessionToDisk(params.sessionId, session);
265
404
  }
266
405
  // Build ordered content blocks for the assistant response
267
406
  const contentBlocks = [];
@@ -273,55 +412,39 @@ export class AgentAcpAdapter {
273
412
  pendingText = "";
274
413
  }
275
414
  };
415
+ // Declare agentResponse and turnTokenUsage outside try block so they're accessible after catch
416
+ let agentResponse;
417
+ // Track accumulated token usage during the turn
418
+ const turnTokenUsage = {
419
+ inputTokens: 0,
420
+ outputTokens: 0,
421
+ totalTokens: 0,
422
+ };
276
423
  try {
277
- // Execute hooks before agent invocation
278
- const hooks = this.agent.definition.hooks;
279
- logger.info("Checking hooks", {
280
- noSession: this.noSession,
281
- hasHooks: !!hooks,
282
- hooksLength: hooks?.length ?? 0,
283
- contextEntries: session.context.length,
284
- totalMessages: session.messages.length,
285
- });
286
- if (!this.noSession && hooks && hooks.length > 0) {
287
- logger.info("Executing hooks before agent invocation");
288
- const hookExecutor = new HookExecutor(hooks, this.agent.definition.model, (callbackRef) => loadHookCallback(callbackRef, this.agentDir));
289
- // Create read-only session view for hooks
290
- const readonlySession = {
291
- messages: session.messages,
292
- context: session.context,
293
- requestParams: session.requestParams,
294
- };
295
- const hookResult = await hookExecutor.executeHooks(readonlySession);
296
- // Send hook notifications to client
297
- for (const notification of hookResult.notifications) {
298
- this.connection.sessionUpdate({
299
- sessionId: params.sessionId,
300
- update: notification,
301
- });
302
- }
303
- // Append new context entries returned by hooks
304
- if (hookResult.newContextEntries.length > 0) {
305
- logger.info(`Appending ${hookResult.newContextEntries.length} new context entries from hooks`);
306
- session.context.push(...hookResult.newContextEntries);
307
- // Save session immediately after hooks to persist compacted context
308
- if (this.storage) {
309
- try {
310
- await this.storage.saveSession(params.sessionId, session.messages, session.context);
311
- logger.info("Session saved after hook execution with new context entries");
312
- }
313
- catch (error) {
314
- logger.error(`Failed to save session ${params.sessionId} after hook execution`, {
315
- error: error instanceof Error ? error.message : String(error),
316
- });
317
- }
318
- }
319
- }
424
+ // Execute hooks before agent invocation (turn start)
425
+ const turnStartContextEntries = await this.executeHooksIfConfigured(session, params.sessionId, "turn_start");
426
+ // Append new context entries returned by hooks (e.g., compaction)
427
+ if (turnStartContextEntries.length > 0) {
428
+ logger.info(`Appending ${turnStartContextEntries.length} new context entries from turn_start hooks`);
429
+ session.context.push(...turnStartContextEntries);
430
+ await this.saveSessionToDisk(params.sessionId, session);
320
431
  }
321
432
  // Resolve context to messages for agent invocation
322
433
  const contextMessages = this.noSession
323
434
  ? []
324
435
  : resolveContextToMessages(session.context, session.messages);
436
+ logger.debug("Resolved context messages for agent invocation", {
437
+ sessionId: params.sessionId,
438
+ contextMessageCount: contextMessages.length,
439
+ totalSessionMessages: session.messages.length,
440
+ latestContextEntry: session.context.length > 0 &&
441
+ session.context[session.context.length - 1]
442
+ ? {
443
+ messageCount: session.context[session.context.length - 1].messages.length,
444
+ contextSize: session.context[session.context.length - 1].context_size,
445
+ }
446
+ : null,
447
+ });
325
448
  const invokeParams = {
326
449
  prompt: params.prompt,
327
450
  sessionId: params.sessionId,
@@ -333,7 +456,43 @@ export class AgentAcpAdapter {
333
456
  if (session.requestParams._meta) {
334
457
  invokeParams.sessionMeta = session.requestParams._meta;
335
458
  }
336
- for await (const msg of this.agent.invoke(invokeParams)) {
459
+ const generator = this.agent.invoke(invokeParams);
460
+ // Manually iterate to capture the return value
461
+ let iterResult = await generator.next();
462
+ while (!iterResult.done) {
463
+ const msg = iterResult.value;
464
+ // Extract and accumulate token usage from message chunks
465
+ if ("sessionUpdate" in msg &&
466
+ msg.sessionUpdate === "agent_message_chunk" &&
467
+ "_meta" in msg &&
468
+ msg._meta &&
469
+ typeof msg._meta === "object" &&
470
+ "tokenUsage" in msg._meta) {
471
+ const tokenUsage = msg._meta.tokenUsage;
472
+ if (tokenUsage) {
473
+ // Only update inputTokens if we receive a positive value
474
+ // (subsequent messages may have inputTokens: 0 for output-only chunks)
475
+ if (tokenUsage.inputTokens !== undefined &&
476
+ tokenUsage.inputTokens > 0) {
477
+ turnTokenUsage.inputTokens = tokenUsage.inputTokens;
478
+ // Update the LAST context entry with LLM-reported tokens
479
+ if (!this.noSession && session.context.length > 0) {
480
+ const lastContext = session.context[session.context.length - 1];
481
+ if (lastContext) {
482
+ lastContext.context_size.llmReportedInputTokens =
483
+ tokenUsage.inputTokens;
484
+ logger.debug("Updated context entry with LLM-reported tokens", {
485
+ contextIndex: session.context.length - 1,
486
+ llmReportedTokens: tokenUsage.inputTokens,
487
+ estimatedTokens: lastContext.context_size.totalEstimated,
488
+ });
489
+ }
490
+ }
491
+ }
492
+ turnTokenUsage.outputTokens += tokenUsage.outputTokens ?? 0;
493
+ turnTokenUsage.totalTokens += tokenUsage.totalTokens ?? 0;
494
+ }
495
+ }
337
496
  // Accumulate text content from message chunks
338
497
  if ("sessionUpdate" in msg &&
339
498
  msg.sessionUpdate === "agent_message_chunk") {
@@ -398,24 +557,175 @@ export class AgentAcpAdapter {
398
557
  const outputMsg = msg;
399
558
  const toolCallBlock = contentBlocks.find((block) => block.type === "tool_call" && block.id === outputMsg.toolCallId);
400
559
  if (toolCallBlock) {
401
- // Store both rawOutput and output from tool_output
402
- if (outputMsg.rawOutput) {
403
- toolCallBlock.rawOutput = outputMsg.rawOutput;
560
+ // Get the raw output
561
+ let rawOutput = outputMsg.rawOutput || outputMsg.output;
562
+ let truncationWarning;
563
+ if (rawOutput && !this.noSession) {
564
+ // Execute tool_response hooks if configured
565
+ const hooks = this.agent.definition.hooks ?? [];
566
+ if (hooks.some((h) => h.type === "tool_response")) {
567
+ const latestContext = session.context[session.context.length - 1];
568
+ const currentContextTokens = latestContext?.context_size.llmReportedInputTokens ??
569
+ latestContext?.context_size.totalEstimated ??
570
+ 0;
571
+ const outputTokens = countToolResultTokens(rawOutput);
572
+ const hookExecutor = new HookExecutor(hooks, this.agent.definition.model, (callbackRef) => loadHookCallback(callbackRef, this.agentDir));
573
+ const hookResult = await hookExecutor.executeToolResponseHooks({
574
+ messages: session.messages,
575
+ context: session.context,
576
+ requestParams: session.requestParams,
577
+ }, currentContextTokens, {
578
+ toolCallId: outputMsg.toolCallId,
579
+ toolName: toolCallBlock.title || "unknown",
580
+ toolInput: toolCallBlock.rawInput || {},
581
+ rawOutput,
582
+ outputTokens,
583
+ });
584
+ // Apply modifications if hook returned them
585
+ if (hookResult.modifiedOutput) {
586
+ rawOutput = hookResult.modifiedOutput;
587
+ logger.info("Tool response modified by hook", {
588
+ toolCallId: outputMsg.toolCallId,
589
+ originalTokens: outputTokens,
590
+ finalTokens: countToolResultTokens(rawOutput),
591
+ });
592
+ }
593
+ truncationWarning = hookResult.truncationWarning;
594
+ }
595
+ }
596
+ // Store the (potentially modified) output
597
+ if (rawOutput) {
598
+ toolCallBlock.rawOutput = rawOutput;
404
599
  }
405
- else if (outputMsg.output) {
406
- toolCallBlock.rawOutput = outputMsg.output;
600
+ // Store truncation warning if present (for UI display)
601
+ if (truncationWarning) {
602
+ if (!toolCallBlock._meta) {
603
+ toolCallBlock._meta = {};
604
+ }
605
+ toolCallBlock._meta.truncationWarning = truncationWarning;
407
606
  }
408
607
  // Note: content blocks are handled by the transport for display
409
608
  // We store the raw output here for session persistence
609
+ // Create mid-turn context snapshot after tool completes
610
+ if (!this.noSession) {
611
+ flushPendingText(); // Ensure all text is captured
612
+ // Update or create the partial assistant message in the messages array
613
+ const partialAssistantMessage = {
614
+ role: "assistant",
615
+ content: [...contentBlocks], // Clone current content blocks
616
+ timestamp: new Date().toISOString(),
617
+ };
618
+ // Check if we already have a partial assistant message in messages
619
+ const lastMessage = session.messages[session.messages.length - 1];
620
+ let partialMessageIndex;
621
+ if (lastMessage && lastMessage.role === "assistant") {
622
+ // Update existing partial message
623
+ session.messages[session.messages.length - 1] =
624
+ partialAssistantMessage;
625
+ partialMessageIndex = session.messages.length - 1;
626
+ }
627
+ else {
628
+ // Add new partial message
629
+ session.messages.push(partialAssistantMessage);
630
+ partialMessageIndex = session.messages.length - 1;
631
+ }
632
+ // Get the latest context
633
+ const latestContext = session.context.length > 0
634
+ ? session.context[session.context.length - 1]
635
+ : undefined;
636
+ // Build message entries for the new context
637
+ // Check if we already have a pointer to this message (during mid-turn updates)
638
+ const existingMessages = latestContext?.messages ?? [];
639
+ const lastEntry = existingMessages[existingMessages.length - 1];
640
+ const alreadyHasPointer = lastEntry?.type === "pointer" &&
641
+ lastEntry.index === partialMessageIndex;
642
+ const messageEntries = alreadyHasPointer
643
+ ? existingMessages // Don't add duplicate pointer
644
+ : [
645
+ ...existingMessages,
646
+ { type: "pointer", index: partialMessageIndex },
647
+ ];
648
+ // Resolve message entries to actual messages
649
+ const contextMessages = [];
650
+ for (const entry of messageEntries) {
651
+ if (entry.type === "pointer") {
652
+ const message = session.messages[entry.index];
653
+ if (message) {
654
+ contextMessages.push(message);
655
+ }
656
+ }
657
+ else if (entry.type === "full") {
658
+ contextMessages.push(entry.message);
659
+ }
660
+ }
661
+ // Calculate context size - tool result is now in the message, but hasn't been sent to LLM yet
662
+ const context_size = calculateContextSize(contextMessages, this.agent.definition.systemPrompt ?? undefined, undefined);
663
+ // Create snapshot with a pointer to the partial message (not a full copy!)
664
+ const midTurnSnapshot = {
665
+ timestamp: new Date().toISOString(),
666
+ messages: messageEntries,
667
+ compactedUpTo: latestContext?.compactedUpTo,
668
+ context_size,
669
+ };
670
+ session.context.push(midTurnSnapshot);
671
+ await this.saveSessionToDisk(params.sessionId, session);
672
+ logger.debug("Created mid-turn context snapshot after tool output", {
673
+ toolCallId: outputMsg.toolCallId,
674
+ contentBlocks: contentBlocks.length,
675
+ partialMessageIndex,
676
+ totalEstimated: midTurnSnapshot.context_size.totalEstimated,
677
+ toolResultsTokens: midTurnSnapshot.context_size.toolResultsTokens,
678
+ });
679
+ // Execute hooks mid-turn to check if compaction is needed
680
+ const midTurnContextEntries = await this.executeHooksIfConfigured(session, params.sessionId, "mid_turn");
681
+ // Append new context entries returned by hooks (e.g., compaction)
682
+ if (midTurnContextEntries.length > 0) {
683
+ logger.info(`Appending ${midTurnContextEntries.length} new context entries from mid_turn hooks`, {
684
+ toolCallId: outputMsg.toolCallId,
685
+ });
686
+ session.context.push(...midTurnContextEntries);
687
+ await this.saveSessionToDisk(params.sessionId, session);
688
+ }
689
+ }
410
690
  }
411
691
  }
412
692
  // The agent may emit extended types (like tool_output) that aren't in ACP SDK yet
413
693
  // The http transport will handle routing these appropriately
694
+ // Add context input tokens to messages with token usage metadata
695
+ let enhancedMsg = msg;
696
+ if (!this.noSession &&
697
+ "_meta" in msg &&
698
+ msg._meta &&
699
+ typeof msg._meta === "object" &&
700
+ "tokenUsage" in msg._meta) {
701
+ // Get latest context entry and send its full context_size breakdown to GUI
702
+ const latestContext = session.context.length > 0
703
+ ? session.context[session.context.length - 1]
704
+ : undefined;
705
+ if (latestContext?.context_size) {
706
+ enhancedMsg = {
707
+ ...msg,
708
+ _meta: {
709
+ ...msg._meta,
710
+ context_size: latestContext.context_size,
711
+ },
712
+ };
713
+ }
714
+ else {
715
+ logger.warn("⚠️ No context_size to send to GUI", {
716
+ hasLatestContext: !!latestContext,
717
+ contextLength: session.context.length,
718
+ });
719
+ }
720
+ }
414
721
  this.connection.sessionUpdate({
415
722
  sessionId: params.sessionId,
416
- update: msg,
723
+ update: enhancedMsg,
417
724
  });
725
+ iterResult = await generator.next();
418
726
  }
727
+ // Capture the return value (PromptResponse with tokenUsage)
728
+ agentResponse = iterResult.value;
419
729
  // Flush any remaining pending text
420
730
  flushPendingText();
421
731
  }
@@ -433,30 +743,98 @@ export class AgentAcpAdapter {
433
743
  content: contentBlocks,
434
744
  timestamp: new Date().toISOString(),
435
745
  };
436
- session.messages.push(assistantMessage);
746
+ // Check if we already have a partial assistant message from mid-turn updates
747
+ const lastMessage = session.messages[session.messages.length - 1];
748
+ if (lastMessage && lastMessage.role === "assistant") {
749
+ // Update the existing message instead of adding a duplicate
750
+ session.messages[session.messages.length - 1] = assistantMessage;
751
+ }
752
+ else {
753
+ // Add new message (no mid-turn updates occurred)
754
+ session.messages.push(assistantMessage);
755
+ }
437
756
  // Create context snapshot based on previous context
438
757
  const previousContext = session.context.length > 0
439
758
  ? session.context[session.context.length - 1]
440
759
  : undefined;
441
- const contextSnapshot = createContextSnapshot(session.messages.length, new Date().toISOString(), previousContext);
442
- session.context.push(contextSnapshot);
443
- }
444
- // Save session to disk if storage is configured and session persistence is enabled
445
- if (!this.noSession && this.storage) {
446
- try {
447
- await this.storage.saveSession(params.sessionId, session.messages, session.context);
448
- }
449
- catch (error) {
450
- logger.error(`Failed to save session ${params.sessionId}`, {
451
- error: error instanceof Error ? error.message : String(error),
452
- });
760
+ // Calculate final context size
761
+ // Build message pointers for the new context
762
+ const messageEntries = previousContext
763
+ ? [
764
+ ...previousContext.messages,
765
+ { type: "pointer", index: session.messages.length - 1 },
766
+ ]
767
+ : [{ type: "pointer", index: session.messages.length - 1 }];
768
+ // Resolve message entries to actual messages
769
+ const contextMessages = [];
770
+ for (const entry of messageEntries) {
771
+ if (entry.type === "pointer") {
772
+ const message = session.messages[entry.index];
773
+ if (message) {
774
+ contextMessages.push(message);
775
+ }
776
+ }
777
+ else if (entry.type === "full") {
778
+ contextMessages.push(entry.message);
779
+ }
453
780
  }
781
+ // Calculate context size with LLM-reported tokens from this turn
782
+ const context_size = calculateContextSize(contextMessages, this.agent.definition.systemPrompt ?? undefined, turnTokenUsage.inputTokens);
783
+ const contextSnapshot = createContextSnapshot(session.messages.length, new Date().toISOString(), previousContext, context_size);
784
+ session.context.push(contextSnapshot);
785
+ await this.saveSessionToDisk(params.sessionId, session);
454
786
  }
455
787
  session.pendingPrompt = null;
456
788
  return {
457
789
  stopReason: "end_turn",
458
790
  };
459
791
  }
792
+ /**
793
+ * Execute hooks if configured for this agent
794
+ * Returns new context entries that should be appended to session.context
795
+ */
796
+ async executeHooksIfConfigured(session, sessionId, executionPoint) {
797
+ // Check if hooks are configured and session persistence is enabled
798
+ const hooks = this.agent.definition.hooks;
799
+ if (this.noSession || !hooks || hooks.length === 0) {
800
+ return [];
801
+ }
802
+ logger.info(`Executing hooks at ${executionPoint}`, {
803
+ hooksLength: hooks.length,
804
+ contextEntries: session.context.length,
805
+ totalMessages: session.messages.length,
806
+ });
807
+ const hookExecutor = new HookExecutor(hooks, this.agent.definition.model, (callbackRef) => loadHookCallback(callbackRef, this.agentDir));
808
+ // Create read-only session view for hooks
809
+ const readonlySession = {
810
+ messages: session.messages,
811
+ context: session.context,
812
+ requestParams: session.requestParams,
813
+ };
814
+ // Get actual input token count from latest context entry
815
+ const latestContext = session.context.length > 0
816
+ ? session.context[session.context.length - 1]
817
+ : undefined;
818
+ // Prefer LLM-reported tokens (most accurate), fall back to our estimate
819
+ const actualInputTokens = latestContext?.context_size.llmReportedInputTokens ??
820
+ latestContext?.context_size.totalEstimated ??
821
+ 0;
822
+ logger.debug("Using tokens for hook execution", {
823
+ llmReported: latestContext?.context_size.llmReportedInputTokens,
824
+ estimated: latestContext?.context_size.totalEstimated,
825
+ used: actualInputTokens,
826
+ });
827
+ const hookResult = await hookExecutor.executeHooks(readonlySession, actualInputTokens);
828
+ // Send hook notifications to client
829
+ for (const notification of hookResult.notifications) {
830
+ this.connection.sessionUpdate({
831
+ sessionId,
832
+ update: notification,
833
+ });
834
+ }
835
+ // Return new context entries (will be appended by caller)
836
+ return hookResult.newContextEntries;
837
+ }
460
838
  async cancel(params) {
461
839
  this.sessions.get(params.sessionId)?.pendingPrompt?.abort();
462
840
  }