@dex-ai/devtools-extension 0.1.15
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/package.json +39 -0
- package/skills/introspection/SKILL.md +24 -0
- package/src/index.ts +376 -0
- package/src/logger.ts +143 -0
- package/src/telemetry.ts +371 -0
- package/src/tools.ts +338 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dex-ai/devtools-extension",
|
|
3
|
+
"version": "0.1.15",
|
|
4
|
+
"description": "Devtools Extension for @dex-ai/sdk — logging, telemetry, and self-introspection tools for debugging the agent loop.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"skills"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test": "echo \"(no tests)\"",
|
|
19
|
+
"changeset": "changeset",
|
|
20
|
+
"version": "changeset version",
|
|
21
|
+
"release": "changeset publish"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@dex-ai/sdk": "^0.1.2",
|
|
25
|
+
"@dex-ai/core-extensions": "^0.1.2",
|
|
26
|
+
"zod": "^3.23.8"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"typescript": "^5.6.3",
|
|
30
|
+
"@types/bun": "latest",
|
|
31
|
+
"bun-types": "latest",
|
|
32
|
+
"@changesets/cli": "^2.29.0"
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: devtools.introspection
|
|
3
|
+
description: Tells the agent about available self-debugging tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Devtools
|
|
7
|
+
|
|
8
|
+
You have access to self-debugging tools prefixed with `devtools_`. Use them to:
|
|
9
|
+
- Inspect your own extensions, tools, and configuration (`devtools_extensions`, `devtools_tools`, `devtools_config`)
|
|
10
|
+
- Check token usage and performance telemetry (`devtools_telemetry`)
|
|
11
|
+
- View recent logs and errors from your agent loop (`devtools_logs`)
|
|
12
|
+
- Inspect extension state (`devtools_state`)
|
|
13
|
+
- Debug session info — message count, history shape (`devtools_session`)
|
|
14
|
+
- Capture what's visible on the terminal screen (`devtools_screenshot`)
|
|
15
|
+
|
|
16
|
+
Call these tools when you need to understand your own runtime state, diagnose issues with tool execution, or verify your environment is configured correctly.
|
|
17
|
+
|
|
18
|
+
### Terminal Screenshot
|
|
19
|
+
|
|
20
|
+
Use `devtools_screenshot` to see what the user sees on their terminal. This returns the full rendered TUI frame as plain text. Useful for:
|
|
21
|
+
- Verifying the UI looks correct after making TUI changes
|
|
22
|
+
- Seeing info panel content, status bars, and error displays
|
|
23
|
+
- Debugging layout issues in the terminal UI
|
|
24
|
+
- Understanding what the user is looking at when they describe a visual issue
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dex-ai/devtools-extension — logging, telemetry, and self-introspection.
|
|
3
|
+
*
|
|
4
|
+
* Provides structured event logging, timing/usage telemetry, and tools
|
|
5
|
+
* that let the agent introspect its own loop (extensions, tools, config,
|
|
6
|
+
* session state, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Integrates with @dex-ai/context when loaded — pulls live context
|
|
9
|
+
* pressure metrics, knowledge base stats, and compression activity
|
|
10
|
+
* into the unified telemetry snapshot.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { devtoolsExtension } from '@dex-ai/devtools-extension';
|
|
14
|
+
*
|
|
15
|
+
* const agent = await Agent.create({
|
|
16
|
+
* extensions: [...otherExtensions, devtoolsExtension()],
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
Extension,
|
|
22
|
+
ExtensionEvents,
|
|
23
|
+
Skill,
|
|
24
|
+
AnyTool,
|
|
25
|
+
AgentContext,
|
|
26
|
+
GenerateContext,
|
|
27
|
+
ModelRequest,
|
|
28
|
+
ToolCall,
|
|
29
|
+
ToolResult,
|
|
30
|
+
ErrorSource,
|
|
31
|
+
Message,
|
|
32
|
+
} from "@dex-ai/sdk";
|
|
33
|
+
import { loadSkill } from "@dex-ai/core-extensions/skills";
|
|
34
|
+
import { Logger, type LogLevel, type LogOutput } from "./logger";
|
|
35
|
+
import { Telemetry, type ContextStats } from "./telemetry";
|
|
36
|
+
import { createDevtoolsTools } from "./tools";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
/* Public types */
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
|
|
43
|
+
export type { LogEntry, LogLevel, LogOutput } from "./logger";
|
|
44
|
+
export type { TelemetryStats, ToolStats, ContextStats } from "./telemetry";
|
|
45
|
+
|
|
46
|
+
export interface DevtoolsOptions {
|
|
47
|
+
/** Minimum log level. Default: "debug". */
|
|
48
|
+
readonly logLevel?: LogLevel;
|
|
49
|
+
/** Maximum log entries in ring buffer. Default: 500. */
|
|
50
|
+
readonly maxLogEntries?: number;
|
|
51
|
+
/** Expose introspection tools to the agent. Default: true. */
|
|
52
|
+
readonly enableTools?: boolean;
|
|
53
|
+
/** Inject a skill describing devtools capabilities. Default: true. */
|
|
54
|
+
readonly enableSkill?: boolean;
|
|
55
|
+
/** Log output target. Default: "stderr". */
|
|
56
|
+
readonly output?: LogOutput;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ------------------------------------------------------------------ */
|
|
60
|
+
/* Skill content */
|
|
61
|
+
/* ------------------------------------------------------------------ */
|
|
62
|
+
|
|
63
|
+
const DEVTOOLS_SKILL: Skill = loadSkill(
|
|
64
|
+
join(import.meta.dir, "../skills/introspection"),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------------------ */
|
|
68
|
+
/* Extension factory */
|
|
69
|
+
/* ------------------------------------------------------------------ */
|
|
70
|
+
|
|
71
|
+
export function devtoolsExtension(opts: DevtoolsOptions = {}): Extension {
|
|
72
|
+
const logLevel = opts.logLevel ?? "debug";
|
|
73
|
+
const maxLogEntries = opts.maxLogEntries ?? 500;
|
|
74
|
+
const enableTools = opts.enableTools ?? true;
|
|
75
|
+
const enableSkill = opts.enableSkill ?? true;
|
|
76
|
+
const output = opts.output ?? "stderr";
|
|
77
|
+
|
|
78
|
+
const logger = new Logger({
|
|
79
|
+
level: logLevel,
|
|
80
|
+
maxEntries: maxLogEntries,
|
|
81
|
+
output,
|
|
82
|
+
});
|
|
83
|
+
const telemetry = new Telemetry();
|
|
84
|
+
|
|
85
|
+
// Build tools list
|
|
86
|
+
const tools: AnyTool[] = enableTools
|
|
87
|
+
? createDevtoolsTools(logger, telemetry)
|
|
88
|
+
: [];
|
|
89
|
+
|
|
90
|
+
// Build skills list
|
|
91
|
+
const skills: Skill[] = enableSkill ? [DEVTOOLS_SKILL] : [];
|
|
92
|
+
|
|
93
|
+
/* ---------------------------------------------------------------- */
|
|
94
|
+
/* Event handlers */
|
|
95
|
+
/* ---------------------------------------------------------------- */
|
|
96
|
+
|
|
97
|
+
const on: ExtensionEvents = {
|
|
98
|
+
"session-start": async (_actx: AgentContext) => {
|
|
99
|
+
logger.info("session-start", {
|
|
100
|
+
agent: _actx.name,
|
|
101
|
+
provider: _actx.providerName,
|
|
102
|
+
model: _actx.modelId,
|
|
103
|
+
extensionCount: _actx.extensions.length,
|
|
104
|
+
});
|
|
105
|
+
return undefined;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
"session-stop": async (_actx: AgentContext) => {
|
|
109
|
+
logger.info("session-stop", {
|
|
110
|
+
stats: telemetry.getStats(),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
"generate-start": async (gctx: GenerateContext) => {
|
|
115
|
+
telemetry.onGenerateStart();
|
|
116
|
+
logger.debug("generate-start", {
|
|
117
|
+
maxSteps: gctx.maxSteps,
|
|
118
|
+
messageCount: gctx.agent.messages.length,
|
|
119
|
+
});
|
|
120
|
+
return undefined;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
"generate-stop": async (gctx: GenerateContext) => {
|
|
124
|
+
telemetry.onGenerateStop(
|
|
125
|
+
gctx.stepCount + 1,
|
|
126
|
+
gctx.usage.inputTokens,
|
|
127
|
+
gctx.usage.outputTokens,
|
|
128
|
+
gctx.usage.cachedInputTokens,
|
|
129
|
+
gctx.usage.cacheCreationInputTokens,
|
|
130
|
+
);
|
|
131
|
+
logger.debug("generate-stop", {
|
|
132
|
+
steps: gctx.stepCount + 1,
|
|
133
|
+
usage: gctx.usage,
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
"model-start": async (req: ModelRequest, gctx: GenerateContext) => {
|
|
138
|
+
telemetry.onModelStart();
|
|
139
|
+
logger.debug("model-start", {
|
|
140
|
+
step: gctx.stepCount,
|
|
141
|
+
messageCount: req.messages.length,
|
|
142
|
+
toolCount: req.tools?.length ?? 0,
|
|
143
|
+
});
|
|
144
|
+
return undefined;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
"model-stop": async (_gctx: GenerateContext) => {
|
|
148
|
+
telemetry.onModelStop();
|
|
149
|
+
logger.debug("model-stop");
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
"tool-start": async (call: ToolCall, _gctx: GenerateContext) => {
|
|
153
|
+
telemetry.onToolStart(call.toolCallId, call.toolName);
|
|
154
|
+
logger.debug("tool-start", {
|
|
155
|
+
tool: call.toolName,
|
|
156
|
+
callId: call.toolCallId,
|
|
157
|
+
});
|
|
158
|
+
return undefined;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
"tool-stop": async (result: ToolResult, _gctx: GenerateContext) => {
|
|
162
|
+
const isError =
|
|
163
|
+
result.output.type === "error-text" ||
|
|
164
|
+
result.output.type === "error-json";
|
|
165
|
+
telemetry.onToolStop(result.toolCallId, result.toolName, isError);
|
|
166
|
+
|
|
167
|
+
// Track context search metrics
|
|
168
|
+
if (result.toolName === "ctx_search" && !isError) {
|
|
169
|
+
const text = result.output.type === "text" ? result.output.value : "";
|
|
170
|
+
const queryCount = (text.match(/^## /gm) || []).length || 1;
|
|
171
|
+
const misses = (text.match(/No results found/g) || []).length;
|
|
172
|
+
const resultHeaders = (text.match(/^### /gm) || []).length;
|
|
173
|
+
telemetry.onContextSearch(queryCount, resultHeaders, misses);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Track auto-indexing (pointer creation)
|
|
177
|
+
if (
|
|
178
|
+
!isError &&
|
|
179
|
+
result.output.type === "text" &&
|
|
180
|
+
result.output.value.startsWith("Indexed ") &&
|
|
181
|
+
result.output.value.includes("ctx_search")
|
|
182
|
+
) {
|
|
183
|
+
// This was a pointer-replaced result
|
|
184
|
+
const bytesMatch = result.output.value.match(
|
|
185
|
+
/Original size: ([\d.]+)KB/,
|
|
186
|
+
);
|
|
187
|
+
const bytes = bytesMatch
|
|
188
|
+
? Math.round(parseFloat(bytesMatch[1]!) * 1024)
|
|
189
|
+
: 0;
|
|
190
|
+
telemetry.onContextPointerCreated(bytes);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
logger.debug("tool-stop", {
|
|
194
|
+
tool: result.toolName,
|
|
195
|
+
callId: result.toolCallId,
|
|
196
|
+
isError,
|
|
197
|
+
});
|
|
198
|
+
return undefined;
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
error: async (
|
|
202
|
+
error: unknown,
|
|
203
|
+
source: ErrorSource,
|
|
204
|
+
_gctx: GenerateContext,
|
|
205
|
+
) => {
|
|
206
|
+
telemetry.onError(source.kind);
|
|
207
|
+
logger.error("error", {
|
|
208
|
+
kind: source.kind,
|
|
209
|
+
message: error instanceof Error ? error.message : String(error),
|
|
210
|
+
extensionName: source.extensionName ?? undefined,
|
|
211
|
+
event: source.event ?? undefined,
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
"message-stop": async (message: Message, _gctx: GenerateContext) => {
|
|
216
|
+
logger.debug("message-stop", {
|
|
217
|
+
role: message.role,
|
|
218
|
+
contentParts: message.content.length,
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const ext = {
|
|
224
|
+
name: "devtools",
|
|
225
|
+
description:
|
|
226
|
+
"Development tools — logging, telemetry, and self-introspection for debugging the agent loop.",
|
|
227
|
+
tools,
|
|
228
|
+
skills,
|
|
229
|
+
on,
|
|
230
|
+
|
|
231
|
+
init(actx: AgentContext) {
|
|
232
|
+
// Wire up the context state resolver so telemetry can pull live
|
|
233
|
+
// context metrics when devtools_telemetry is called.
|
|
234
|
+
telemetry.setContextStateResolver(() => {
|
|
235
|
+
return resolveContextStats(actx);
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return ext as Extension;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ------------------------------------------------------------------ */
|
|
244
|
+
/* Context State Resolver */
|
|
245
|
+
/* ------------------------------------------------------------------ */
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Resolve live context stats from the @dex-ai/context extension's state.
|
|
249
|
+
* Returns null if the context extension isn't loaded.
|
|
250
|
+
*/
|
|
251
|
+
function resolveContextStats(actx: AgentContext): ContextStats | null {
|
|
252
|
+
// Get snapshot from context extension
|
|
253
|
+
const snapshotFn = actx.state.get("context:snapshot");
|
|
254
|
+
const storeFn = actx.state.get("context:store");
|
|
255
|
+
const eventsFn = actx.state.get("context:events");
|
|
256
|
+
const metricsFn = actx.state.get("context:metrics");
|
|
257
|
+
|
|
258
|
+
if (!snapshotFn || typeof snapshotFn !== "function") return null;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const snapshot = snapshotFn() as {
|
|
262
|
+
totalTokens: number;
|
|
263
|
+
maxTokens: number;
|
|
264
|
+
usagePercent: number;
|
|
265
|
+
tokensSaved: number;
|
|
266
|
+
compressions: number;
|
|
267
|
+
} | null;
|
|
268
|
+
if (!snapshot) return null;
|
|
269
|
+
|
|
270
|
+
// Get knowledge base stats
|
|
271
|
+
let kbStats = { sources: 0, chunks: 0, totalBytes: 0 };
|
|
272
|
+
if (storeFn && typeof storeFn === "function") {
|
|
273
|
+
try {
|
|
274
|
+
const store = storeFn() as {
|
|
275
|
+
getStats(): { sources: number; chunks: number; totalBytes: number };
|
|
276
|
+
} | null;
|
|
277
|
+
if (store) kbStats = store.getStats();
|
|
278
|
+
} catch {
|
|
279
|
+
/* store not available */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Get context metrics (auto-index, pointer, search stats)
|
|
284
|
+
let ctxMetrics = {
|
|
285
|
+
autoIndexed: 0,
|
|
286
|
+
pointersCreated: 0,
|
|
287
|
+
bytesDiverted: 0,
|
|
288
|
+
searchCalls: 0,
|
|
289
|
+
searchQueries: 0,
|
|
290
|
+
searchMisses: 0,
|
|
291
|
+
searchTotalResults: 0,
|
|
292
|
+
};
|
|
293
|
+
if (metricsFn && typeof metricsFn === "function") {
|
|
294
|
+
try {
|
|
295
|
+
const m = metricsFn() as typeof ctxMetrics | null;
|
|
296
|
+
if (m) ctxMetrics = m;
|
|
297
|
+
} catch {
|
|
298
|
+
/* metrics not available */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Get compression events
|
|
303
|
+
let compWarm = 0,
|
|
304
|
+
compCold = 0,
|
|
305
|
+
compDelete = 0;
|
|
306
|
+
if (eventsFn && typeof eventsFn === "function") {
|
|
307
|
+
try {
|
|
308
|
+
const events = eventsFn() as Array<{
|
|
309
|
+
type: string;
|
|
310
|
+
tokensSaved?: number;
|
|
311
|
+
}> | null;
|
|
312
|
+
if (events) {
|
|
313
|
+
for (const ev of events) {
|
|
314
|
+
switch (ev.type) {
|
|
315
|
+
case "compress-warm":
|
|
316
|
+
compWarm++;
|
|
317
|
+
break;
|
|
318
|
+
case "compress-cold":
|
|
319
|
+
compCold++;
|
|
320
|
+
break;
|
|
321
|
+
case "delete":
|
|
322
|
+
compDelete++;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
/* events not available */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const totalCompressions = compWarm + compCold + compDelete;
|
|
333
|
+
const successfulQueries =
|
|
334
|
+
ctxMetrics.searchQueries - ctxMetrics.searchMisses;
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
usagePercent: snapshot.usagePercent,
|
|
338
|
+
totalTokens: snapshot.totalTokens,
|
|
339
|
+
maxTokens: snapshot.maxTokens,
|
|
340
|
+
tokensSaved: snapshot.tokensSaved,
|
|
341
|
+
compressions: totalCompressions,
|
|
342
|
+
compressionsByType: {
|
|
343
|
+
warm: compWarm,
|
|
344
|
+
cold: compCold,
|
|
345
|
+
delete: compDelete,
|
|
346
|
+
},
|
|
347
|
+
knowledgeBase: {
|
|
348
|
+
sources: kbStats.sources,
|
|
349
|
+
chunks: kbStats.chunks,
|
|
350
|
+
bytesIndexed: kbStats.totalBytes,
|
|
351
|
+
autoIndexed: ctxMetrics.autoIndexed,
|
|
352
|
+
pointersCreated: ctxMetrics.pointersCreated,
|
|
353
|
+
searchCalls: ctxMetrics.searchCalls,
|
|
354
|
+
searchQueries: ctxMetrics.searchQueries,
|
|
355
|
+
searchMisses: ctxMetrics.searchMisses,
|
|
356
|
+
avgResultsPerQuery:
|
|
357
|
+
successfulQueries > 0
|
|
358
|
+
? Math.round(
|
|
359
|
+
(ctxMetrics.searchTotalResults / successfulQueries) * 10,
|
|
360
|
+
) / 10
|
|
361
|
+
: 0,
|
|
362
|
+
},
|
|
363
|
+
bytesDiverted: ctxMetrics.bytesDiverted,
|
|
364
|
+
compressionRatio:
|
|
365
|
+
snapshot.tokensSaved > 0 && snapshot.totalTokens > 0
|
|
366
|
+
? Math.round(
|
|
367
|
+
(snapshot.tokensSaved /
|
|
368
|
+
(snapshot.totalTokens + snapshot.tokensSaved)) *
|
|
369
|
+
100,
|
|
370
|
+
) / 100
|
|
371
|
+
: 0,
|
|
372
|
+
};
|
|
373
|
+
} catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ring-buffer logger for agent loop events.
|
|
3
|
+
*
|
|
4
|
+
* Collects structured LogEntry objects from extension event hooks.
|
|
5
|
+
* Supports level filtering, max size (ring buffer), and configurable output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
9
|
+
|
|
10
|
+
export interface LogEntry {
|
|
11
|
+
readonly timestamp: number;
|
|
12
|
+
readonly level: LogLevel;
|
|
13
|
+
readonly event: string;
|
|
14
|
+
readonly data?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type LogOutput = "stderr" | "none" | ((entry: LogEntry) => void);
|
|
18
|
+
|
|
19
|
+
export interface LoggerOptions {
|
|
20
|
+
readonly level: LogLevel;
|
|
21
|
+
readonly maxEntries: number;
|
|
22
|
+
readonly output: LogOutput;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
26
|
+
debug: 0,
|
|
27
|
+
info: 1,
|
|
28
|
+
warn: 2,
|
|
29
|
+
error: 3,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const LEVEL_COLORS: Record<LogLevel, string> = {
|
|
33
|
+
debug: "\x1b[90m", // gray
|
|
34
|
+
info: "\x1b[36m", // cyan
|
|
35
|
+
warn: "\x1b[33m", // yellow
|
|
36
|
+
error: "\x1b[31m", // red
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const RESET = "\x1b[0m";
|
|
40
|
+
const DIM = "\x1b[2m";
|
|
41
|
+
|
|
42
|
+
function formatEntry(entry: LogEntry): string {
|
|
43
|
+
const time = new Date(entry.timestamp).toISOString().slice(11, 23);
|
|
44
|
+
const color = LEVEL_COLORS[entry.level];
|
|
45
|
+
const lvl = entry.level.toUpperCase().padEnd(5);
|
|
46
|
+
const data = entry.data ? ` ${DIM}${JSON.stringify(entry.data)}${RESET}` : "";
|
|
47
|
+
return `${DIM}${time}${RESET} ${color}${lvl}${RESET} ${entry.event}${data}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ring-buffer logger.
|
|
52
|
+
*
|
|
53
|
+
* Stores up to `maxEntries` log entries. When full, oldest entries are dropped.
|
|
54
|
+
*/
|
|
55
|
+
export class Logger {
|
|
56
|
+
private readonly entries: LogEntry[] = [];
|
|
57
|
+
private readonly level: number;
|
|
58
|
+
private readonly maxEntries: number;
|
|
59
|
+
private readonly output: LogOutput;
|
|
60
|
+
|
|
61
|
+
constructor(opts: LoggerOptions) {
|
|
62
|
+
this.level = LEVEL_ORDER[opts.level];
|
|
63
|
+
this.maxEntries = opts.maxEntries;
|
|
64
|
+
this.output = opts.output;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
log(level: LogLevel, event: string, data?: Record<string, unknown>): void {
|
|
68
|
+
if (LEVEL_ORDER[level] < this.level) return;
|
|
69
|
+
|
|
70
|
+
const entry: LogEntry = {
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
level,
|
|
73
|
+
event,
|
|
74
|
+
...(data !== undefined ? { data } : {}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Ring buffer: drop oldest when full
|
|
78
|
+
if (this.entries.length >= this.maxEntries) {
|
|
79
|
+
this.entries.shift();
|
|
80
|
+
}
|
|
81
|
+
this.entries.push(entry);
|
|
82
|
+
|
|
83
|
+
// Output
|
|
84
|
+
if (this.output === "stderr") {
|
|
85
|
+
process.stderr.write(formatEntry(entry) + "\n");
|
|
86
|
+
} else if (typeof this.output === "function") {
|
|
87
|
+
this.output(entry);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
debug(event: string, data?: Record<string, unknown>): void {
|
|
92
|
+
this.log("debug", event, data);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
info(event: string, data?: Record<string, unknown>): void {
|
|
96
|
+
this.log("info", event, data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
warn(event: string, data?: Record<string, unknown>): void {
|
|
100
|
+
this.log("warn", event, data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
error(event: string, data?: Record<string, unknown>): void {
|
|
104
|
+
this.log("error", event, data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Query log entries with optional filtering.
|
|
109
|
+
*/
|
|
110
|
+
query(opts?: {
|
|
111
|
+
level?: LogLevel;
|
|
112
|
+
event?: string;
|
|
113
|
+
last?: number;
|
|
114
|
+
}): ReadonlyArray<LogEntry> {
|
|
115
|
+
let result: LogEntry[] = [...this.entries];
|
|
116
|
+
|
|
117
|
+
if (opts?.level) {
|
|
118
|
+
const minLevel = LEVEL_ORDER[opts.level];
|
|
119
|
+
result = result.filter((e) => LEVEL_ORDER[e.level] >= minLevel);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (opts?.event) {
|
|
123
|
+
const pattern = opts.event;
|
|
124
|
+
result = result.filter((e) => e.event.includes(pattern));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (opts?.last !== undefined && opts.last > 0) {
|
|
128
|
+
result = result.slice(-opts.last);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Total entry count (may be less than all events if ring buffer has wrapped). */
|
|
135
|
+
get size(): number {
|
|
136
|
+
return this.entries.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Clear all entries. */
|
|
140
|
+
clear(): void {
|
|
141
|
+
this.entries.length = 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry — accumulates timing and usage statistics across the session.
|
|
3
|
+
*
|
|
4
|
+
* Tracks: session lifecycle, token usage, model call performance,
|
|
5
|
+
* per-tool execution stats, error counts, AND context management metrics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ToolStats {
|
|
9
|
+
calls: number;
|
|
10
|
+
errors: number;
|
|
11
|
+
totalDurationMs: number;
|
|
12
|
+
avgDurationMs: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ContextStats {
|
|
16
|
+
/** Current context window utilization (0-100) */
|
|
17
|
+
usagePercent: number;
|
|
18
|
+
/** Total tokens currently in context */
|
|
19
|
+
totalTokens: number;
|
|
20
|
+
/** Maximum token budget */
|
|
21
|
+
maxTokens: number;
|
|
22
|
+
/** Total tokens saved by all compression mechanisms this session */
|
|
23
|
+
tokensSaved: number;
|
|
24
|
+
/** Number of compression events fired */
|
|
25
|
+
compressions: number;
|
|
26
|
+
/** Breakdown by compression type */
|
|
27
|
+
compressionsByType: {
|
|
28
|
+
warm: number;
|
|
29
|
+
cold: number;
|
|
30
|
+
delete: number;
|
|
31
|
+
};
|
|
32
|
+
/** Knowledge base metrics */
|
|
33
|
+
knowledgeBase: {
|
|
34
|
+
/** Total sources indexed */
|
|
35
|
+
sources: number;
|
|
36
|
+
/** Total chunks in FTS5 store */
|
|
37
|
+
chunks: number;
|
|
38
|
+
/** Total bytes indexed (raw content size) */
|
|
39
|
+
bytesIndexed: number;
|
|
40
|
+
/** How many tool results were auto-indexed (>threshold) */
|
|
41
|
+
autoIndexed: number;
|
|
42
|
+
/** How many tool results were replaced with pointers (>full-replace threshold) */
|
|
43
|
+
pointersCreated: number;
|
|
44
|
+
/** Total ctx_search calls */
|
|
45
|
+
searchCalls: number;
|
|
46
|
+
/** Total search queries executed */
|
|
47
|
+
searchQueries: number;
|
|
48
|
+
/** Queries that returned 0 results */
|
|
49
|
+
searchMisses: number;
|
|
50
|
+
/** Average results per successful query */
|
|
51
|
+
avgResultsPerQuery: number;
|
|
52
|
+
};
|
|
53
|
+
/** Bytes that would have been in context but were indexed instead */
|
|
54
|
+
bytesDiverted: number;
|
|
55
|
+
/** Effective compression ratio (bytes saved / bytes that would have been used) */
|
|
56
|
+
compressionRatio: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TelemetryStats {
|
|
60
|
+
session: {
|
|
61
|
+
startedAt: number;
|
|
62
|
+
generates: number;
|
|
63
|
+
totalSteps: number;
|
|
64
|
+
};
|
|
65
|
+
tokens: {
|
|
66
|
+
input: number;
|
|
67
|
+
output: number;
|
|
68
|
+
total: number;
|
|
69
|
+
};
|
|
70
|
+
cache: {
|
|
71
|
+
reads: number;
|
|
72
|
+
writes: number;
|
|
73
|
+
totalInput: number;
|
|
74
|
+
hitRate: number; // 0-1 ratio of cached reads to total input
|
|
75
|
+
};
|
|
76
|
+
models: {
|
|
77
|
+
calls: number;
|
|
78
|
+
totalDurationMs: number;
|
|
79
|
+
avgDurationMs: number;
|
|
80
|
+
};
|
|
81
|
+
tools: Record<string, ToolStats>;
|
|
82
|
+
errors: {
|
|
83
|
+
total: number;
|
|
84
|
+
byKind: Record<string, number>;
|
|
85
|
+
};
|
|
86
|
+
/** Context management and knowledge base metrics. Null if @dex-ai/context not loaded. */
|
|
87
|
+
context: ContextStats | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Telemetry collector. Tracks stats across the agent session.
|
|
92
|
+
*/
|
|
93
|
+
export class Telemetry {
|
|
94
|
+
private readonly sessionStartedAt: number;
|
|
95
|
+
private generateCount = 0;
|
|
96
|
+
private totalSteps = 0;
|
|
97
|
+
|
|
98
|
+
private inputTokens = 0;
|
|
99
|
+
private outputTokens = 0;
|
|
100
|
+
|
|
101
|
+
// Cache metrics
|
|
102
|
+
private cacheReads = 0;
|
|
103
|
+
private cacheWrites = 0;
|
|
104
|
+
private totalInputForCache = 0;
|
|
105
|
+
|
|
106
|
+
private modelCalls = 0;
|
|
107
|
+
private modelTotalDuration = 0;
|
|
108
|
+
private currentModelStart = 0;
|
|
109
|
+
|
|
110
|
+
private readonly toolStats = new Map<
|
|
111
|
+
string,
|
|
112
|
+
{ calls: number; errors: number; totalDurationMs: number }
|
|
113
|
+
>();
|
|
114
|
+
private readonly pendingTools = new Map<string, number>(); // toolCallId → startedAt
|
|
115
|
+
|
|
116
|
+
private errorTotal = 0;
|
|
117
|
+
private readonly errorsByKind = new Map<string, number>();
|
|
118
|
+
|
|
119
|
+
// Context metrics
|
|
120
|
+
private contextAutoIndexed = 0;
|
|
121
|
+
private contextPointersCreated = 0;
|
|
122
|
+
private contextSearchCalls = 0;
|
|
123
|
+
private contextSearchQueries = 0;
|
|
124
|
+
private contextSearchMisses = 0;
|
|
125
|
+
private contextSearchTotalResults = 0;
|
|
126
|
+
private contextBytesDiverted = 0;
|
|
127
|
+
private contextCompressionsWarm = 0;
|
|
128
|
+
private contextCompressionsCold = 0;
|
|
129
|
+
private contextCompressionsDelete = 0;
|
|
130
|
+
|
|
131
|
+
// External state resolver — set from extension wiring
|
|
132
|
+
private contextStateResolver: (() => ContextStats | null) | null = null;
|
|
133
|
+
|
|
134
|
+
constructor() {
|
|
135
|
+
this.sessionStartedAt = Date.now();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
/* Context management metrics */
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Set a resolver function that reads live context state.
|
|
144
|
+
* Called by the devtools extension init() after the context extension
|
|
145
|
+
* has set up its state accessors.
|
|
146
|
+
*/
|
|
147
|
+
setContextStateResolver(resolver: () => ContextStats | null): void {
|
|
148
|
+
this.contextStateResolver = resolver;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Track when a tool result is auto-indexed into the knowledge base. */
|
|
152
|
+
onContextAutoIndex(byteSize: number): void {
|
|
153
|
+
this.contextAutoIndexed++;
|
|
154
|
+
this.contextBytesDiverted += byteSize;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Track when a large tool result is replaced with a pointer. */
|
|
158
|
+
onContextPointerCreated(byteSize: number): void {
|
|
159
|
+
this.contextPointersCreated++;
|
|
160
|
+
// bytesDiverted already counted in onContextAutoIndex
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Track a ctx_search invocation. */
|
|
164
|
+
onContextSearch(
|
|
165
|
+
queryCount: number,
|
|
166
|
+
totalResults: number,
|
|
167
|
+
misses: number,
|
|
168
|
+
): void {
|
|
169
|
+
this.contextSearchCalls++;
|
|
170
|
+
this.contextSearchQueries += queryCount;
|
|
171
|
+
this.contextSearchMisses += misses;
|
|
172
|
+
this.contextSearchTotalResults += totalResults;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Track a compression event. */
|
|
176
|
+
onContextCompression(
|
|
177
|
+
type: "warm" | "cold" | "delete",
|
|
178
|
+
tokensSaved: number,
|
|
179
|
+
): void {
|
|
180
|
+
switch (type) {
|
|
181
|
+
case "warm":
|
|
182
|
+
this.contextCompressionsWarm++;
|
|
183
|
+
break;
|
|
184
|
+
case "cold":
|
|
185
|
+
this.contextCompressionsCold++;
|
|
186
|
+
break;
|
|
187
|
+
case "delete":
|
|
188
|
+
this.contextCompressionsDelete++;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
/* Generate lifecycle */
|
|
195
|
+
/* ------------------------------------------------------------------ */
|
|
196
|
+
|
|
197
|
+
onGenerateStart(): void {
|
|
198
|
+
this.generateCount++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
onGenerateStop(
|
|
202
|
+
steps: number,
|
|
203
|
+
inputTokens: number,
|
|
204
|
+
outputTokens: number,
|
|
205
|
+
cachedInputTokens?: number,
|
|
206
|
+
cacheCreationInputTokens?: number,
|
|
207
|
+
): void {
|
|
208
|
+
this.totalSteps += steps;
|
|
209
|
+
this.inputTokens += inputTokens;
|
|
210
|
+
this.outputTokens += outputTokens;
|
|
211
|
+
this.totalInputForCache += inputTokens;
|
|
212
|
+
if (cachedInputTokens) this.cacheReads += cachedInputTokens;
|
|
213
|
+
if (cacheCreationInputTokens) this.cacheWrites += cacheCreationInputTokens;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ------------------------------------------------------------------ */
|
|
217
|
+
/* Model lifecycle */
|
|
218
|
+
/* ------------------------------------------------------------------ */
|
|
219
|
+
|
|
220
|
+
onModelStart(): void {
|
|
221
|
+
this.modelCalls++;
|
|
222
|
+
this.currentModelStart = Date.now();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
onModelStop(): void {
|
|
226
|
+
if (this.currentModelStart > 0) {
|
|
227
|
+
this.modelTotalDuration += Date.now() - this.currentModelStart;
|
|
228
|
+
this.currentModelStart = 0;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
/* Tool lifecycle */
|
|
234
|
+
/* ------------------------------------------------------------------ */
|
|
235
|
+
|
|
236
|
+
onToolStart(toolCallId: string, _toolName: string): void {
|
|
237
|
+
this.pendingTools.set(toolCallId, Date.now());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
onToolStop(toolCallId: string, toolName: string, isError: boolean): void {
|
|
241
|
+
const startedAt = this.pendingTools.get(toolCallId);
|
|
242
|
+
this.pendingTools.delete(toolCallId);
|
|
243
|
+
const duration = startedAt !== undefined ? Date.now() - startedAt : 0;
|
|
244
|
+
|
|
245
|
+
let stats = this.toolStats.get(toolName);
|
|
246
|
+
if (!stats) {
|
|
247
|
+
stats = { calls: 0, errors: 0, totalDurationMs: 0 };
|
|
248
|
+
this.toolStats.set(toolName, stats);
|
|
249
|
+
}
|
|
250
|
+
stats.calls++;
|
|
251
|
+
if (isError) stats.errors++;
|
|
252
|
+
stats.totalDurationMs += duration;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ------------------------------------------------------------------ */
|
|
256
|
+
/* Errors */
|
|
257
|
+
/* ------------------------------------------------------------------ */
|
|
258
|
+
|
|
259
|
+
onError(kind: string): void {
|
|
260
|
+
this.errorTotal++;
|
|
261
|
+
this.errorsByKind.set(kind, (this.errorsByKind.get(kind) ?? 0) + 1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ------------------------------------------------------------------ */
|
|
265
|
+
/* Snapshot */
|
|
266
|
+
/* ------------------------------------------------------------------ */
|
|
267
|
+
|
|
268
|
+
getStats(): TelemetryStats {
|
|
269
|
+
const tools: Record<string, ToolStats> = {};
|
|
270
|
+
for (const [name, s] of this.toolStats) {
|
|
271
|
+
tools[name] = {
|
|
272
|
+
calls: s.calls,
|
|
273
|
+
errors: s.errors,
|
|
274
|
+
totalDurationMs: s.totalDurationMs,
|
|
275
|
+
avgDurationMs:
|
|
276
|
+
s.calls > 0 ? Math.round(s.totalDurationMs / s.calls) : 0,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const byKind: Record<string, number> = {};
|
|
281
|
+
for (const [k, v] of this.errorsByKind) {
|
|
282
|
+
byKind[k] = v;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Resolve live context stats
|
|
286
|
+
let contextStats: ContextStats | null = null;
|
|
287
|
+
if (this.contextStateResolver) {
|
|
288
|
+
try {
|
|
289
|
+
contextStats = this.contextStateResolver();
|
|
290
|
+
} catch {
|
|
291
|
+
// Context extension may not be loaded or state unavailable
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// If resolver didn't produce live stats, build from our own counters
|
|
296
|
+
if (!contextStats && this.contextAutoIndexed > 0) {
|
|
297
|
+
const successfulQueries =
|
|
298
|
+
this.contextSearchQueries - this.contextSearchMisses;
|
|
299
|
+
contextStats = {
|
|
300
|
+
usagePercent: 0,
|
|
301
|
+
totalTokens: 0,
|
|
302
|
+
maxTokens: 0,
|
|
303
|
+
tokensSaved: 0,
|
|
304
|
+
compressions:
|
|
305
|
+
this.contextCompressionsWarm +
|
|
306
|
+
this.contextCompressionsCold +
|
|
307
|
+
this.contextCompressionsDelete,
|
|
308
|
+
compressionsByType: {
|
|
309
|
+
warm: this.contextCompressionsWarm,
|
|
310
|
+
cold: this.contextCompressionsCold,
|
|
311
|
+
delete: this.contextCompressionsDelete,
|
|
312
|
+
},
|
|
313
|
+
knowledgeBase: {
|
|
314
|
+
sources: 0,
|
|
315
|
+
chunks: 0,
|
|
316
|
+
bytesIndexed: this.contextBytesDiverted,
|
|
317
|
+
autoIndexed: this.contextAutoIndexed,
|
|
318
|
+
pointersCreated: this.contextPointersCreated,
|
|
319
|
+
searchCalls: this.contextSearchCalls,
|
|
320
|
+
searchQueries: this.contextSearchQueries,
|
|
321
|
+
searchMisses: this.contextSearchMisses,
|
|
322
|
+
avgResultsPerQuery:
|
|
323
|
+
successfulQueries > 0
|
|
324
|
+
? Math.round(
|
|
325
|
+
(this.contextSearchTotalResults / successfulQueries) * 10,
|
|
326
|
+
) / 10
|
|
327
|
+
: 0,
|
|
328
|
+
},
|
|
329
|
+
bytesDiverted: this.contextBytesDiverted,
|
|
330
|
+
compressionRatio: 0,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
session: {
|
|
336
|
+
startedAt: this.sessionStartedAt,
|
|
337
|
+
generates: this.generateCount,
|
|
338
|
+
totalSteps: this.totalSteps,
|
|
339
|
+
},
|
|
340
|
+
tokens: {
|
|
341
|
+
input: this.inputTokens,
|
|
342
|
+
output: this.outputTokens,
|
|
343
|
+
total: this.inputTokens + this.outputTokens,
|
|
344
|
+
},
|
|
345
|
+
cache: {
|
|
346
|
+
reads: this.cacheReads,
|
|
347
|
+
writes: this.cacheWrites,
|
|
348
|
+
totalInput: this.totalInputForCache,
|
|
349
|
+
hitRate:
|
|
350
|
+
this.totalInputForCache > 0
|
|
351
|
+
? Math.round((this.cacheReads / this.totalInputForCache) * 100) /
|
|
352
|
+
100
|
|
353
|
+
: 0,
|
|
354
|
+
},
|
|
355
|
+
models: {
|
|
356
|
+
calls: this.modelCalls,
|
|
357
|
+
totalDurationMs: this.modelTotalDuration,
|
|
358
|
+
avgDurationMs:
|
|
359
|
+
this.modelCalls > 0
|
|
360
|
+
? Math.round(this.modelTotalDuration / this.modelCalls)
|
|
361
|
+
: 0,
|
|
362
|
+
},
|
|
363
|
+
tools,
|
|
364
|
+
errors: {
|
|
365
|
+
total: this.errorTotal,
|
|
366
|
+
byKind,
|
|
367
|
+
},
|
|
368
|
+
context: contextStats,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-facing introspection tools.
|
|
3
|
+
*
|
|
4
|
+
* These tools are exposed to the model so it can query its own runtime state.
|
|
5
|
+
* All tools are prefixed with `devtools_` to avoid collisions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { Tool } from "@dex-ai/sdk";
|
|
10
|
+
import type {
|
|
11
|
+
AnyTool,
|
|
12
|
+
AgentContext,
|
|
13
|
+
GenerateContext,
|
|
14
|
+
Extension,
|
|
15
|
+
} from "@dex-ai/sdk";
|
|
16
|
+
import type { Logger, LogLevel } from "./logger";
|
|
17
|
+
import type { Telemetry } from "./telemetry";
|
|
18
|
+
|
|
19
|
+
/* ------------------------------------------------------------------ */
|
|
20
|
+
/* Helpers */
|
|
21
|
+
/* ------------------------------------------------------------------ */
|
|
22
|
+
|
|
23
|
+
function getActx(gctx: GenerateContext): AgentContext {
|
|
24
|
+
return gctx.agent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function collectAllTools(extensions: ReadonlyArray<Extension>): AnyTool[] {
|
|
28
|
+
const tools: AnyTool[] = [];
|
|
29
|
+
for (const ext of extensions) {
|
|
30
|
+
if (!ext.tools) continue;
|
|
31
|
+
const list: ReadonlyArray<AnyTool> = Array.isArray(ext.tools)
|
|
32
|
+
? ext.tools
|
|
33
|
+
: [ext.tools as AnyTool];
|
|
34
|
+
for (const t of list) {
|
|
35
|
+
tools.push(t);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return tools;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
/* Tool factories */
|
|
43
|
+
/* ------------------------------------------------------------------ */
|
|
44
|
+
|
|
45
|
+
export function createDevtoolsTools(
|
|
46
|
+
logger: Logger,
|
|
47
|
+
telemetry: Telemetry,
|
|
48
|
+
): AnyTool[] {
|
|
49
|
+
/* ---------------------------------------------------------------- */
|
|
50
|
+
/* devtools_extensions */
|
|
51
|
+
/* ---------------------------------------------------------------- */
|
|
52
|
+
const extensionsTool = Tool.define({
|
|
53
|
+
name: "devtools_extensions",
|
|
54
|
+
displayName: "Extensions",
|
|
55
|
+
access: "read",
|
|
56
|
+
description:
|
|
57
|
+
"List all installed extensions with their capabilities (tools, skills, events, init/dispose).",
|
|
58
|
+
parameters: z.object({}),
|
|
59
|
+
execute(_input: unknown, gctx: GenerateContext) {
|
|
60
|
+
const actx = getActx(gctx);
|
|
61
|
+
const exts = actx.extensions.map((ext) => ({
|
|
62
|
+
name: ext.name,
|
|
63
|
+
description: ext.description ?? null,
|
|
64
|
+
hasTools: ext.tools !== undefined,
|
|
65
|
+
hasSkills: ext.skills !== undefined,
|
|
66
|
+
hasEvents: ext.on !== undefined,
|
|
67
|
+
events: ext.on ? Object.keys(ext.on) : [],
|
|
68
|
+
hasInit: typeof ext.init === "function",
|
|
69
|
+
hasDispose: typeof ext.dispose === "function",
|
|
70
|
+
hasModels: ext.models !== undefined && ext.models.length > 0,
|
|
71
|
+
}));
|
|
72
|
+
return { type: "json" as const, value: exts };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* ---------------------------------------------------------------- */
|
|
77
|
+
/* devtools_tools */
|
|
78
|
+
/* ---------------------------------------------------------------- */
|
|
79
|
+
const toolsTool = Tool.define({
|
|
80
|
+
name: "devtools_tools",
|
|
81
|
+
displayName: "Tools",
|
|
82
|
+
access: "read",
|
|
83
|
+
description:
|
|
84
|
+
"List all registered tools across all extensions (name, description, parameter summary).",
|
|
85
|
+
parameters: z.object({}),
|
|
86
|
+
execute(_input: unknown, gctx: GenerateContext) {
|
|
87
|
+
const actx = getActx(gctx);
|
|
88
|
+
const tools = collectAllTools(actx.extensions);
|
|
89
|
+
const summary = tools.map((t) => ({
|
|
90
|
+
name: t.name,
|
|
91
|
+
description: t.description ?? null,
|
|
92
|
+
hasOutputStream: typeof t.stream === "function",
|
|
93
|
+
}));
|
|
94
|
+
return { type: "json" as const, value: summary };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/* ---------------------------------------------------------------- */
|
|
99
|
+
/* devtools_config */
|
|
100
|
+
/* ---------------------------------------------------------------- */
|
|
101
|
+
const configTool = Tool.define({
|
|
102
|
+
name: "devtools_config",
|
|
103
|
+
displayName: "Config",
|
|
104
|
+
access: "read",
|
|
105
|
+
description:
|
|
106
|
+
"Show the merged agent configuration (provider, model, workspace, session, etc.).",
|
|
107
|
+
parameters: z.object({}),
|
|
108
|
+
execute(_input: unknown, gctx: GenerateContext) {
|
|
109
|
+
const actx = getActx(gctx);
|
|
110
|
+
const config = actx.state.get("config") ?? null;
|
|
111
|
+
const workspace = actx.state.get("workspace") ?? null;
|
|
112
|
+
const session = actx.state.get("session") ?? null;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
type: "json" as const,
|
|
116
|
+
value: {
|
|
117
|
+
agent: {
|
|
118
|
+
name: actx.name,
|
|
119
|
+
provider: actx.providerName,
|
|
120
|
+
model: actx.modelId,
|
|
121
|
+
},
|
|
122
|
+
config,
|
|
123
|
+
workspace,
|
|
124
|
+
session,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/* ---------------------------------------------------------------- */
|
|
131
|
+
/* devtools_telemetry */
|
|
132
|
+
/* ---------------------------------------------------------------- */
|
|
133
|
+
const telemetryTool = Tool.define({
|
|
134
|
+
name: "devtools_telemetry",
|
|
135
|
+
displayName: "Telemetry",
|
|
136
|
+
access: "read",
|
|
137
|
+
description:
|
|
138
|
+
"Show current session telemetry: token usage, model call performance, tool execution stats, error counts.",
|
|
139
|
+
parameters: z.object({}),
|
|
140
|
+
execute() {
|
|
141
|
+
return { type: "json" as const, value: telemetry.getStats() };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/* ---------------------------------------------------------------- */
|
|
146
|
+
/* devtools_logs */
|
|
147
|
+
/* ---------------------------------------------------------------- */
|
|
148
|
+
const logsTool = Tool.define({
|
|
149
|
+
name: "devtools_logs",
|
|
150
|
+
displayName: "Logs",
|
|
151
|
+
access: "read",
|
|
152
|
+
description:
|
|
153
|
+
"Query recent agent loop log entries. Filter by level, event pattern, or return the last N entries.",
|
|
154
|
+
parameters: z.object({
|
|
155
|
+
level: z
|
|
156
|
+
.enum(["debug", "info", "warn", "error"])
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("Minimum log level to include."),
|
|
159
|
+
event: z
|
|
160
|
+
.string()
|
|
161
|
+
.optional()
|
|
162
|
+
.describe(
|
|
163
|
+
"Filter to entries whose event name contains this substring.",
|
|
164
|
+
),
|
|
165
|
+
last: z
|
|
166
|
+
.number()
|
|
167
|
+
.int()
|
|
168
|
+
.positive()
|
|
169
|
+
.optional()
|
|
170
|
+
.describe("Return only the last N matching entries."),
|
|
171
|
+
}),
|
|
172
|
+
execute(input: { level?: LogLevel; event?: string; last?: number }) {
|
|
173
|
+
const entries = logger.query(input);
|
|
174
|
+
return { type: "json" as const, value: entries };
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/* ---------------------------------------------------------------- */
|
|
179
|
+
/* devtools_state */
|
|
180
|
+
/* ---------------------------------------------------------------- */
|
|
181
|
+
const stateTool = Tool.define({
|
|
182
|
+
name: "devtools_state",
|
|
183
|
+
displayName: "State",
|
|
184
|
+
access: "read",
|
|
185
|
+
description:
|
|
186
|
+
"Inspect the extension state map. List all keys, or dump the value for a specific key.",
|
|
187
|
+
parameters: z.object({
|
|
188
|
+
key: z
|
|
189
|
+
.string()
|
|
190
|
+
.optional()
|
|
191
|
+
.describe(
|
|
192
|
+
"If provided, dump the value stored at this key. Otherwise list all keys.",
|
|
193
|
+
),
|
|
194
|
+
}),
|
|
195
|
+
execute(input: { key?: string }, gctx: GenerateContext) {
|
|
196
|
+
const actx = getActx(gctx);
|
|
197
|
+
if (input.key !== undefined) {
|
|
198
|
+
let value = actx.state.get(input.key);
|
|
199
|
+
if (value === undefined) {
|
|
200
|
+
return {
|
|
201
|
+
type: "text" as const,
|
|
202
|
+
value: `No state found for key "${input.key}".`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Resolve thunks — extensions like context store lazy getters
|
|
206
|
+
if (typeof value === "function") {
|
|
207
|
+
try {
|
|
208
|
+
value = value();
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return {
|
|
211
|
+
type: "text" as const,
|
|
212
|
+
value: `Error resolving state "${input.key}": ${err instanceof Error ? err.message : String(err)}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (value === undefined || value === null) {
|
|
217
|
+
return {
|
|
218
|
+
type: "text" as const,
|
|
219
|
+
value: `State "${input.key}" resolved to ${String(value)}.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (typeof value === "string") {
|
|
223
|
+
return { type: "text" as const, value };
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
return { type: "json" as const, value };
|
|
227
|
+
} catch {
|
|
228
|
+
return { type: "text" as const, value: String(value) };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// List all keys
|
|
232
|
+
const keys = [...actx.state.keys()];
|
|
233
|
+
return { type: "json" as const, value: { keys, count: keys.length } };
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/* ---------------------------------------------------------------- */
|
|
238
|
+
/* devtools_session */
|
|
239
|
+
/* ---------------------------------------------------------------- */
|
|
240
|
+
const sessionTool = Tool.define({
|
|
241
|
+
name: "devtools_session",
|
|
242
|
+
displayName: "Session",
|
|
243
|
+
access: "read",
|
|
244
|
+
description:
|
|
245
|
+
"Show session info: message count, message types breakdown, history size estimate.",
|
|
246
|
+
parameters: z.object({}),
|
|
247
|
+
execute(_input: unknown, gctx: GenerateContext) {
|
|
248
|
+
const actx = getActx(gctx);
|
|
249
|
+
const messages = actx.messages;
|
|
250
|
+
const byRole: Record<string, number> = {};
|
|
251
|
+
let totalContentParts = 0;
|
|
252
|
+
|
|
253
|
+
for (const msg of messages) {
|
|
254
|
+
byRole[msg.role] = (byRole[msg.role] ?? 0) + 1;
|
|
255
|
+
totalContentParts += msg.content.length;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const session = actx.state.get("session") as
|
|
259
|
+
| { id: string; path: string }
|
|
260
|
+
| undefined;
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
type: "json" as const,
|
|
264
|
+
value: {
|
|
265
|
+
sessionId: session?.id ?? null,
|
|
266
|
+
sessionPath: session?.path ?? null,
|
|
267
|
+
messageCount: messages.length,
|
|
268
|
+
byRole,
|
|
269
|
+
totalContentParts,
|
|
270
|
+
currentStep: gctx.stepCount,
|
|
271
|
+
maxSteps: gctx.maxSteps,
|
|
272
|
+
usage: gctx.usage,
|
|
273
|
+
sessionTokens: actx.tokenCount,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
/* ---------------------------------------------------------------- */
|
|
280
|
+
/* devtools_screenshot */
|
|
281
|
+
/* ---------------------------------------------------------------- */
|
|
282
|
+
const screenshotTool = Tool.define({
|
|
283
|
+
name: "devtools_screenshot",
|
|
284
|
+
displayName: "Screenshot",
|
|
285
|
+
access: "read",
|
|
286
|
+
description:
|
|
287
|
+
"Capture the current terminal screen content as plain text. Returns the exact text visible in the terminal UI, with ANSI formatting stripped.",
|
|
288
|
+
parameters: z.object({
|
|
289
|
+
ansi: z
|
|
290
|
+
.boolean()
|
|
291
|
+
.optional()
|
|
292
|
+
.describe(
|
|
293
|
+
"If true, preserve ANSI color codes in output. Default: false (plain text).",
|
|
294
|
+
),
|
|
295
|
+
}),
|
|
296
|
+
execute(input: { ansi?: boolean }, gctx: GenerateContext) {
|
|
297
|
+
const actx = getActx(gctx);
|
|
298
|
+
const frameGetter = actx.state.get("tui:frame");
|
|
299
|
+
if (!frameGetter || typeof frameGetter !== "function") {
|
|
300
|
+
return {
|
|
301
|
+
type: "error-text" as const,
|
|
302
|
+
value:
|
|
303
|
+
"Terminal frame not available. The TUI extension may not be loaded.",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const lines: string[] = frameGetter();
|
|
308
|
+
if (lines.length === 0) {
|
|
309
|
+
return {
|
|
310
|
+
type: "text" as const,
|
|
311
|
+
value: "(empty frame — no content rendered yet)",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (input.ansi) {
|
|
316
|
+
return { type: "text" as const, value: lines.join("\n") };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Strip ANSI escape codes for plain text output
|
|
320
|
+
const plain = lines.map((line) =>
|
|
321
|
+
// eslint-disable-next-line no-control-regex
|
|
322
|
+
line.replace(/\x1b\[[0-9;]*m|\x1b\][^\x1b]*\x1b\\/g, ""),
|
|
323
|
+
);
|
|
324
|
+
return { type: "text" as const, value: plain.join("\n") };
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return [
|
|
329
|
+
extensionsTool,
|
|
330
|
+
toolsTool,
|
|
331
|
+
configTool,
|
|
332
|
+
telemetryTool,
|
|
333
|
+
logsTool,
|
|
334
|
+
stateTool,
|
|
335
|
+
sessionTool,
|
|
336
|
+
screenshotTool,
|
|
337
|
+
];
|
|
338
|
+
}
|