@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.
- package/dist/acp-server/adapter.d.ts +15 -0
- package/dist/acp-server/adapter.js +445 -67
- package/dist/acp-server/http.js +8 -1
- package/dist/acp-server/session-storage.d.ts +19 -0
- package/dist/acp-server/session-storage.js +9 -0
- package/dist/definition/index.d.ts +16 -4
- package/dist/definition/index.js +17 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +10 -1
- package/dist/runner/agent-runner.d.ts +13 -2
- package/dist/runner/agent-runner.js +4 -0
- package/dist/runner/hooks/executor.d.ts +18 -1
- package/dist/runner/hooks/executor.js +74 -62
- package/dist/runner/hooks/predefined/compaction-tool.js +19 -3
- package/dist/runner/hooks/predefined/tool-response-compactor.d.ts +6 -0
- package/dist/runner/hooks/predefined/tool-response-compactor.js +461 -0
- package/dist/runner/hooks/registry.js +2 -0
- package/dist/runner/hooks/types.d.ts +39 -3
- package/dist/runner/hooks/types.js +9 -1
- package/dist/runner/langchain/index.d.ts +1 -0
- package/dist/runner/langchain/index.js +523 -321
- package/dist/runner/langchain/model-factory.js +1 -1
- package/dist/runner/langchain/otel-callbacks.d.ts +18 -0
- package/dist/runner/langchain/otel-callbacks.js +123 -0
- package/dist/runner/langchain/tools/subagent.js +21 -1
- package/dist/scaffold/link-local.d.ts +1 -0
- package/dist/scaffold/link-local.js +54 -0
- package/dist/scaffold/project-scaffold.js +1 -0
- package/dist/telemetry/index.d.ts +83 -0
- package/dist/telemetry/index.js +172 -0
- package/dist/telemetry/setup.d.ts +22 -0
- package/dist/telemetry/setup.js +141 -0
- package/dist/templates/index.d.ts +7 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/context-size-calculator.d.ts +29 -0
- package/dist/utils/context-size-calculator.js +78 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/token-counter.d.ts +19 -0
- package/dist/utils/token-counter.js +44 -0
- package/index.ts +16 -1
- package/package.json +24 -7
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
}
|