@northflare/runner 0.0.9 → 0.0.10
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/components/claude-sdk-manager.d.ts.map +1 -1
- package/dist/components/claude-sdk-manager.js +5 -4
- package/dist/components/claude-sdk-manager.js.map +1 -1
- package/dist/components/codex-sdk-manager.d.ts +2 -0
- package/dist/components/codex-sdk-manager.d.ts.map +1 -1
- package/dist/components/codex-sdk-manager.js +91 -10
- package/dist/components/codex-sdk-manager.js.map +1 -1
- package/dist/components/message-handler-sse.d.ts.map +1 -1
- package/dist/components/message-handler-sse.js +15 -14
- package/dist/components/message-handler-sse.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/runner-sse.d.ts.map +1 -1
- package/dist/runner-sse.js +25 -112
- package/dist/runner-sse.js.map +1 -1
- package/dist/runner.js +3 -3
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +1 -0
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/console.d.ts.map +1 -1
- package/dist/utils/console.js +2 -1
- package/dist/utils/console.js.map +1 -1
- package/dist/utils/debug.d.ts +2 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +19 -0
- package/dist/utils/debug.js.map +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +6 -4
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/status-line.d.ts +0 -8
- package/dist/utils/status-line.d.ts.map +1 -1
- package/dist/utils/status-line.js +4 -3
- package/dist/utils/status-line.js.map +1 -1
- package/dist/utils/tool-response-sanitizer.d.ts +9 -0
- package/dist/utils/tool-response-sanitizer.d.ts.map +1 -0
- package/dist/utils/tool-response-sanitizer.js +122 -0
- package/dist/utils/tool-response-sanitizer.js.map +1 -0
- package/exceptions.log +2 -0
- package/package.json +1 -1
- package/rejections.log +3 -0
- package/src/components/claude-sdk-manager.ts +5 -4
- package/src/components/codex-sdk-manager.ts +111 -10
- package/src/components/message-handler-sse.ts +15 -14
- package/src/index.ts +4 -2
- package/src/runner-sse.ts +29 -133
- package/src/types/index.ts +1 -0
- package/src/utils/config.ts +1 -0
- package/src/utils/console.ts +4 -2
- package/src/utils/debug.ts +18 -0
- package/src/utils/logger.ts +8 -5
- package/src/utils/status-line.ts +3 -2
- package/src/utils/tool-response-sanitizer.ts +160 -0
- package/tests/tool-response-sanitizer.test.ts +63 -0
package/src/runner-sse.ts
CHANGED
|
@@ -18,137 +18,24 @@ import { EnhancedRepositoryManager } from "./components/enhanced-repository-mana
|
|
|
18
18
|
import { StateManager } from "./utils/StateManager";
|
|
19
19
|
import { createLogger } from "./utils/logger";
|
|
20
20
|
import { statusLineManager } from "./utils/status-line";
|
|
21
|
+
import { isRunnerDebugEnabled } from "./utils/debug";
|
|
21
22
|
import fs from "fs/promises";
|
|
22
23
|
import path from "path";
|
|
23
24
|
import computerName from "computer-name";
|
|
25
|
+
import {
|
|
26
|
+
sanitizeToolResponsePayload,
|
|
27
|
+
TOOL_RESPONSE_BYTE_LIMIT,
|
|
28
|
+
} from "./utils/tool-response-sanitizer";
|
|
24
29
|
|
|
25
30
|
const logger = createLogger("RunnerApp");
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
const TOOL_RESPONSE_SUFFIX = "... [truncated]";
|
|
29
|
-
|
|
30
|
-
function sanitizeToolResponsePayload(
|
|
31
|
-
method: string | undefined,
|
|
32
|
-
params: any
|
|
33
|
-
): any {
|
|
34
|
-
if (method !== "message.agent" || !params) {
|
|
35
|
-
return params;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (!isToolResultPayload(params)) {
|
|
39
|
-
return params;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const [sanitized, changed] = truncateStringsRecursively(params);
|
|
43
|
-
if (!changed) {
|
|
44
|
-
return params;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const toolUseId = extractToolUseId(params);
|
|
32
|
+
function logToolResponseTruncation(toolUseId?: string) {
|
|
48
33
|
const message = `Tool response exceeded ${TOOL_RESPONSE_BYTE_LIMIT} bytes and was truncated`;
|
|
49
34
|
if (toolUseId) {
|
|
50
35
|
logger.warn(message, { toolUseId });
|
|
51
36
|
} else {
|
|
52
37
|
logger.warn(message);
|
|
53
38
|
}
|
|
54
|
-
return sanitized;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isToolResultPayload(payload: any): boolean {
|
|
58
|
-
if (!payload) {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
if (payload.type === "tool_result" || payload.subtype === "tool_result") {
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
if (Array.isArray(payload.content)) {
|
|
65
|
-
return payload.content.some((entry: any) => isToolResultBlock(entry));
|
|
66
|
-
}
|
|
67
|
-
return isToolResultBlock(payload.content);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function isToolResultBlock(block: any): boolean {
|
|
71
|
-
if (!block) {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
return (
|
|
75
|
-
block.type === "tool_result" ||
|
|
76
|
-
block.subtype === "tool_result" ||
|
|
77
|
-
typeof block.tool_use_id === "string"
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function extractToolUseId(payload: any): string | undefined {
|
|
82
|
-
if (!payload) {
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
if (typeof payload.tool_use_id === "string") {
|
|
86
|
-
return payload.tool_use_id;
|
|
87
|
-
}
|
|
88
|
-
if (Array.isArray(payload.content)) {
|
|
89
|
-
const match = payload.content.find(
|
|
90
|
-
(entry: any) => entry && typeof entry.tool_use_id === "string"
|
|
91
|
-
);
|
|
92
|
-
if (match) {
|
|
93
|
-
return match.tool_use_id;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (payload.content && typeof payload.content === "object") {
|
|
97
|
-
return extractToolUseId(payload.content);
|
|
98
|
-
}
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function truncateStringsRecursively(value: any): [any, boolean] {
|
|
103
|
-
if (typeof value === "string") {
|
|
104
|
-
return truncateStringValue(value);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (Array.isArray(value)) {
|
|
108
|
-
let mutated = false;
|
|
109
|
-
let result: any[] = value;
|
|
110
|
-
for (let i = 0; i < value.length; i++) {
|
|
111
|
-
const [newItem, changed] = truncateStringsRecursively(value[i]);
|
|
112
|
-
if (changed) {
|
|
113
|
-
if (!mutated) {
|
|
114
|
-
result = value.slice();
|
|
115
|
-
mutated = true;
|
|
116
|
-
}
|
|
117
|
-
result[i] = newItem;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return [result, mutated];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (value && typeof value === "object") {
|
|
124
|
-
let mutated = false;
|
|
125
|
-
let result: any = value;
|
|
126
|
-
for (const key of Object.keys(value)) {
|
|
127
|
-
const [newVal, changed] = truncateStringsRecursively(value[key]);
|
|
128
|
-
if (changed) {
|
|
129
|
-
if (!mutated) {
|
|
130
|
-
result = { ...value };
|
|
131
|
-
mutated = true;
|
|
132
|
-
}
|
|
133
|
-
result[key] = newVal;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return [result, mutated];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return [value, false];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function truncateStringValue(value: string): [string, boolean] {
|
|
143
|
-
const byteLength = Buffer.byteLength(value, "utf8");
|
|
144
|
-
if (byteLength <= TOOL_RESPONSE_BYTE_LIMIT) {
|
|
145
|
-
return [value, false];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const suffixBytes = Buffer.byteLength(TOOL_RESPONSE_SUFFIX, "utf8");
|
|
149
|
-
const maxBytes = Math.max(0, TOOL_RESPONSE_BYTE_LIMIT - suffixBytes);
|
|
150
|
-
const truncatedBuffer = Buffer.from(value, "utf8").subarray(0, maxBytes);
|
|
151
|
-
return [truncatedBuffer.toString("utf8") + TOOL_RESPONSE_SUFFIX, true];
|
|
152
39
|
}
|
|
153
40
|
|
|
154
41
|
export class RunnerApp implements IRunnerApp {
|
|
@@ -271,7 +158,7 @@ export class RunnerApp implements IRunnerApp {
|
|
|
271
158
|
await this.registerWithRetry();
|
|
272
159
|
|
|
273
160
|
// Log debug info after registration
|
|
274
|
-
if (
|
|
161
|
+
if (isRunnerDebugEnabled()) {
|
|
275
162
|
logger.debug("Runner initialized with ownership details", {
|
|
276
163
|
runnerId: this.config.runnerId,
|
|
277
164
|
runnerUid: this.runnerUid,
|
|
@@ -325,7 +212,13 @@ export class RunnerApp implements IRunnerApp {
|
|
|
325
212
|
|
|
326
213
|
async notify(method: string, params: any): Promise<void> {
|
|
327
214
|
try {
|
|
328
|
-
const
|
|
215
|
+
const sanitization = sanitizeToolResponsePayload(method, params);
|
|
216
|
+
if (sanitization.truncated) {
|
|
217
|
+
logToolResponseTruncation(sanitization.toolUseId);
|
|
218
|
+
}
|
|
219
|
+
const safeParams = sanitization.truncated
|
|
220
|
+
? sanitization.params
|
|
221
|
+
: params;
|
|
329
222
|
// Log RPC notification in debug mode
|
|
330
223
|
logger.debug(`[RPC] Sending notification: ${method}`, {
|
|
331
224
|
method,
|
|
@@ -357,17 +250,20 @@ export class RunnerApp implements IRunnerApp {
|
|
|
357
250
|
}
|
|
358
251
|
|
|
359
252
|
async sendToOrchestrator(message: JsonRpcMessage): Promise<any> {
|
|
360
|
-
const
|
|
253
|
+
const sanitization = sanitizeToolResponsePayload(
|
|
361
254
|
message.method,
|
|
362
255
|
message.params
|
|
363
256
|
);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
257
|
+
if (sanitization.truncated) {
|
|
258
|
+
logToolResponseTruncation(sanitization.toolUseId);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const messageToSend = sanitization.truncated
|
|
262
|
+
? {
|
|
263
|
+
...message,
|
|
264
|
+
params: sanitization.params,
|
|
265
|
+
}
|
|
266
|
+
: message;
|
|
371
267
|
|
|
372
268
|
try {
|
|
373
269
|
// Log RPC request in debug mode
|
|
@@ -507,7 +403,7 @@ export class RunnerApp implements IRunnerApp {
|
|
|
507
403
|
logger.info(`Registration response:`, JSON.stringify(response, null, 2));
|
|
508
404
|
|
|
509
405
|
// Log the runnerRepos being sent and received
|
|
510
|
-
if (
|
|
406
|
+
if (isRunnerDebugEnabled()) {
|
|
511
407
|
logger.debug(
|
|
512
408
|
"Registration runnerRepos sent:",
|
|
513
409
|
JSON.stringify(filteredRunnerRepos)
|
|
@@ -569,7 +465,7 @@ export class RunnerApp implements IRunnerApp {
|
|
|
569
465
|
}
|
|
570
466
|
|
|
571
467
|
// Debug logging for registration details
|
|
572
|
-
if (
|
|
468
|
+
if (isRunnerDebugEnabled()) {
|
|
573
469
|
logger.debug("Registration complete with details", {
|
|
574
470
|
runnerId: this.config.runnerId,
|
|
575
471
|
runnerUid: this.runnerUid,
|
|
@@ -976,7 +872,7 @@ export class RunnerApp implements IRunnerApp {
|
|
|
976
872
|
logger.error("Failed to persist active status:", error);
|
|
977
873
|
});
|
|
978
874
|
|
|
979
|
-
if (
|
|
875
|
+
if (isRunnerDebugEnabled()) {
|
|
980
876
|
logger.debug("Active runner status changed", {
|
|
981
877
|
previous: previousState,
|
|
982
878
|
new: active,
|
|
@@ -990,7 +886,7 @@ export class RunnerApp implements IRunnerApp {
|
|
|
990
886
|
const previousTimestamp = this.lastProcessedAt;
|
|
991
887
|
this.lastProcessedAt = timestamp;
|
|
992
888
|
|
|
993
|
-
if (
|
|
889
|
+
if (isRunnerDebugEnabled()) {
|
|
994
890
|
logger.debug("LastProcessedAt updated", {
|
|
995
891
|
previous: previousTimestamp?.toISOString() || "null",
|
|
996
892
|
new: timestamp?.toISOString() || "null",
|
package/src/types/index.ts
CHANGED
package/src/utils/config.ts
CHANGED
|
@@ -294,6 +294,7 @@ export class ConfigManager {
|
|
|
294
294
|
NORTHFLARE_RUNNER_TOKEN: process.env["NORTHFLARE_RUNNER_TOKEN"]!,
|
|
295
295
|
NORTHFLARE_WORKSPACE_DIR: process.env["NORTHFLARE_WORKSPACE_DIR"]!,
|
|
296
296
|
NORTHFLARE_ORCHESTRATOR_URL: process.env["NORTHFLARE_ORCHESTRATOR_URL"]!,
|
|
297
|
+
NORTHFLARE_RUNNER_DEBUG: process.env["NORTHFLARE_RUNNER_DEBUG"],
|
|
297
298
|
DEBUG: process.env["DEBUG"],
|
|
298
299
|
};
|
|
299
300
|
}
|
package/src/utils/console.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Console wrapper that suppresses output when not in debug mode
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { isRunnerDebugEnabled } from "./debug";
|
|
6
|
+
|
|
7
|
+
const isDebug = isRunnerDebugEnabled();
|
|
6
8
|
|
|
7
9
|
export const console = {
|
|
8
10
|
log: isDebug ? global.console.log.bind(global.console) : () => {},
|
|
@@ -10,4 +12,4 @@ export const console = {
|
|
|
10
12
|
error: global.console.error.bind(global.console), // Always show errors
|
|
11
13
|
info: isDebug ? global.console.info.bind(global.console) : () => {},
|
|
12
14
|
debug: isDebug ? global.console.debug.bind(global.console) : () => {},
|
|
13
|
-
};
|
|
15
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function isRunnerDebugEnabled(): boolean {
|
|
2
|
+
const explicitFlag = process.env["NORTHFLARE_RUNNER_DEBUG"];
|
|
3
|
+
if (typeof explicitFlag === "string") {
|
|
4
|
+
return isTruthy(explicitFlag);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const legacyFlag = process.env["DEBUG"];
|
|
8
|
+
if (typeof legacyFlag === "string") {
|
|
9
|
+
return isTruthy(legacyFlag);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isTruthy(value: string): boolean {
|
|
16
|
+
const normalized = value.trim().toLowerCase();
|
|
17
|
+
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
|
18
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import winston from "winston";
|
|
6
6
|
import path from "path";
|
|
7
|
+
import { isRunnerDebugEnabled } from "./debug";
|
|
7
8
|
|
|
8
9
|
// Custom log levels
|
|
9
10
|
const levels = {
|
|
@@ -29,6 +30,8 @@ const colors = {
|
|
|
29
30
|
|
|
30
31
|
winston.addColors(colors);
|
|
31
32
|
|
|
33
|
+
const debugEnabled = isRunnerDebugEnabled();
|
|
34
|
+
|
|
32
35
|
// Format for console output
|
|
33
36
|
const consoleFormat = winston.format.combine(
|
|
34
37
|
winston.format.colorize({ all: true }),
|
|
@@ -36,7 +39,7 @@ const consoleFormat = winston.format.combine(
|
|
|
36
39
|
winston.format.printf(({ timestamp, level, message, stack, ...metadata }) => {
|
|
37
40
|
let msg = `${timestamp} [${level}]: ${message}`;
|
|
38
41
|
// Only show stack traces and metadata in debug mode
|
|
39
|
-
if (
|
|
42
|
+
if (debugEnabled) {
|
|
40
43
|
if (stack) {
|
|
41
44
|
msg += `\n${stack}`;
|
|
42
45
|
}
|
|
@@ -59,13 +62,13 @@ const fileFormat = winston.format.combine(
|
|
|
59
62
|
|
|
60
63
|
// Create logger instance
|
|
61
64
|
export const logger = winston.createLogger({
|
|
62
|
-
level:
|
|
65
|
+
level: debugEnabled ? "debug" : "info",
|
|
63
66
|
levels,
|
|
64
67
|
transports: [
|
|
65
68
|
// Always include console transport for errors
|
|
66
69
|
new winston.transports.Console({
|
|
67
70
|
format: consoleFormat,
|
|
68
|
-
level:
|
|
71
|
+
level: debugEnabled ? "debug" : "error",
|
|
69
72
|
}),
|
|
70
73
|
],
|
|
71
74
|
});
|
|
@@ -94,7 +97,7 @@ export function configureFileLogging(logDir: string): void {
|
|
|
94
97
|
);
|
|
95
98
|
|
|
96
99
|
// Debug log file (only in debug mode)
|
|
97
|
-
if (
|
|
100
|
+
if (debugEnabled) {
|
|
98
101
|
logger.add(
|
|
99
102
|
new winston.transports.File({
|
|
100
103
|
filename: path.join(logDir, "debug.log"),
|
|
@@ -128,4 +131,4 @@ logger.rejections.handle(
|
|
|
128
131
|
);
|
|
129
132
|
|
|
130
133
|
// Export default logger
|
|
131
|
-
export default logger;
|
|
134
|
+
export default logger;
|
package/src/utils/status-line.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* StatusLineManager - Manages persistent status line output for active agent processes
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* This component maintains a single-line status output that updates every minute to show
|
|
5
5
|
* the count of active agent processes. It prevents the machine from entering idle
|
|
6
6
|
* state while processes are running and provides clear visual feedback without cluttering
|
|
7
7
|
* the terminal with multiple log lines.
|
|
8
8
|
*/
|
|
9
|
+
import { isRunnerDebugEnabled } from "./debug";
|
|
9
10
|
|
|
10
11
|
export class StatusLineManager {
|
|
11
12
|
private intervalId?: NodeJS.Timeout;
|
|
@@ -15,7 +16,7 @@ export class StatusLineManager {
|
|
|
15
16
|
|
|
16
17
|
constructor() {
|
|
17
18
|
// Only enable status line when not in debug mode
|
|
18
|
-
this.isEnabled =
|
|
19
|
+
this.isEnabled = !isRunnerDebugEnabled();
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export const TOOL_RESPONSE_BYTE_LIMIT = 500 * 1024; // 500 KiB cap per tool response payload
|
|
2
|
+
export const TOOL_RESPONSE_SUFFIX = "... [truncated]";
|
|
3
|
+
|
|
4
|
+
export interface ToolResponseSanitizationResult {
|
|
5
|
+
params: any;
|
|
6
|
+
truncated: boolean;
|
|
7
|
+
toolUseId?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface TruncationContext {
|
|
11
|
+
bytesRemaining: number;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sanitizeToolResponsePayload(
|
|
16
|
+
method: string | undefined,
|
|
17
|
+
params: any
|
|
18
|
+
): ToolResponseSanitizationResult {
|
|
19
|
+
if (method !== "message.agent" || !params) {
|
|
20
|
+
return { params, truncated: false };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!isToolResultPayload(params)) {
|
|
24
|
+
return { params, truncated: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const context: TruncationContext = {
|
|
28
|
+
bytesRemaining: TOOL_RESPONSE_BYTE_LIMIT,
|
|
29
|
+
truncated: false,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const [sanitized, changed] = truncateStringsRecursively(params, context);
|
|
33
|
+
|
|
34
|
+
if (!changed && !context.truncated) {
|
|
35
|
+
return { params, truncated: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
params: sanitized,
|
|
40
|
+
truncated: true,
|
|
41
|
+
toolUseId: extractToolUseId(params),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isToolResultPayload(payload: any): boolean {
|
|
46
|
+
if (!payload) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (payload.type === "tool_result" || payload.subtype === "tool_result") {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(payload.content)) {
|
|
53
|
+
return payload.content.some((entry: any) => isToolResultBlock(entry));
|
|
54
|
+
}
|
|
55
|
+
return isToolResultBlock(payload.content);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isToolResultBlock(block: any): boolean {
|
|
59
|
+
if (!block) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return (
|
|
63
|
+
block.type === "tool_result" ||
|
|
64
|
+
block.subtype === "tool_result" ||
|
|
65
|
+
typeof block.tool_use_id === "string"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractToolUseId(payload: any): string | undefined {
|
|
70
|
+
if (!payload) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
if (typeof payload.tool_use_id === "string") {
|
|
74
|
+
return payload.tool_use_id;
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(payload.content)) {
|
|
77
|
+
const match = payload.content.find(
|
|
78
|
+
(entry: any) => entry && typeof entry.tool_use_id === "string"
|
|
79
|
+
);
|
|
80
|
+
if (match) {
|
|
81
|
+
return match.tool_use_id;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (payload.content && typeof payload.content === "object") {
|
|
85
|
+
return extractToolUseId(payload.content);
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function truncateStringsRecursively(
|
|
91
|
+
value: any,
|
|
92
|
+
context: TruncationContext
|
|
93
|
+
): [any, boolean] {
|
|
94
|
+
if (typeof value === "string") {
|
|
95
|
+
return truncateStringValue(value, context);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
let mutated = false;
|
|
100
|
+
let result: any[] = value;
|
|
101
|
+
for (let i = 0; i < value.length; i++) {
|
|
102
|
+
const [newItem, changed] = truncateStringsRecursively(value[i], context);
|
|
103
|
+
if (changed) {
|
|
104
|
+
if (!mutated) {
|
|
105
|
+
result = value.slice();
|
|
106
|
+
mutated = true;
|
|
107
|
+
}
|
|
108
|
+
result[i] = newItem;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [result, mutated];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (value && typeof value === "object") {
|
|
115
|
+
let mutated = false;
|
|
116
|
+
let result: Record<string, any> = value;
|
|
117
|
+
for (const key of Object.keys(value)) {
|
|
118
|
+
const [newVal, changed] = truncateStringsRecursively(
|
|
119
|
+
value[key],
|
|
120
|
+
context
|
|
121
|
+
);
|
|
122
|
+
if (changed) {
|
|
123
|
+
if (!mutated) {
|
|
124
|
+
result = { ...value };
|
|
125
|
+
mutated = true;
|
|
126
|
+
}
|
|
127
|
+
result[key] = newVal;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [result, mutated];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [value, false];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function truncateStringValue(
|
|
137
|
+
value: string,
|
|
138
|
+
context: TruncationContext
|
|
139
|
+
): [string, boolean] {
|
|
140
|
+
if (context.bytesRemaining <= 0) {
|
|
141
|
+
if (value.length === 0) {
|
|
142
|
+
return [value, false];
|
|
143
|
+
}
|
|
144
|
+
context.truncated = true;
|
|
145
|
+
return ["", true];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const byteLength = Buffer.byteLength(value, "utf8");
|
|
149
|
+
if (byteLength <= context.bytesRemaining) {
|
|
150
|
+
context.bytesRemaining -= byteLength;
|
|
151
|
+
return [value, false];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const suffixBytes = Buffer.byteLength(TOOL_RESPONSE_SUFFIX, "utf8");
|
|
155
|
+
const maxBytes = Math.max(0, context.bytesRemaining - suffixBytes);
|
|
156
|
+
const truncatedBuffer = Buffer.from(value, "utf8").subarray(0, maxBytes);
|
|
157
|
+
context.bytesRemaining = 0;
|
|
158
|
+
context.truncated = true;
|
|
159
|
+
return [truncatedBuffer.toString("utf8") + TOOL_RESPONSE_SUFFIX, true];
|
|
160
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
sanitizeToolResponsePayload,
|
|
4
|
+
TOOL_RESPONSE_BYTE_LIMIT,
|
|
5
|
+
TOOL_RESPONSE_SUFFIX,
|
|
6
|
+
} from "../src/utils/tool-response-sanitizer";
|
|
7
|
+
|
|
8
|
+
describe("sanitizeToolResponsePayload", () => {
|
|
9
|
+
it("returns original params for non message.agent notifications", () => {
|
|
10
|
+
const payload = { type: "tool_result", content: [] };
|
|
11
|
+
const result = sanitizeToolResponsePayload("runner.heartbeat", payload);
|
|
12
|
+
expect(result.truncated).toBe(false);
|
|
13
|
+
expect(result.params).toBe(payload);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("truncates oversized tool_result strings", () => {
|
|
17
|
+
const longText = "x".repeat(TOOL_RESPONSE_BYTE_LIMIT + 128);
|
|
18
|
+
const payload = {
|
|
19
|
+
type: "tool_result",
|
|
20
|
+
tool_use_id: "tool-123",
|
|
21
|
+
content: [{ type: "text", text: longText }],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const result = sanitizeToolResponsePayload("message.agent", payload);
|
|
25
|
+
expect(result.truncated).toBe(true);
|
|
26
|
+
expect(result.toolUseId).toBe("tool-123");
|
|
27
|
+
|
|
28
|
+
const sanitizedText = result.params.content[0].text as string;
|
|
29
|
+
expect(sanitizedText.endsWith(TOOL_RESPONSE_SUFFIX)).toBe(true);
|
|
30
|
+
expect(Buffer.byteLength(sanitizedText, "utf8")).toBeLessThanOrEqual(
|
|
31
|
+
TOOL_RESPONSE_BYTE_LIMIT + Buffer.byteLength(TOOL_RESPONSE_SUFFIX, "utf8")
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Ensure original payload was not mutated
|
|
35
|
+
expect(payload.content[0].text).toBe(longText);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("enforces the byte budget across multiple string blocks", () => {
|
|
39
|
+
const firstBlockBytes = 400 * 1024;
|
|
40
|
+
const secondBlockBytes = 200 * 1024;
|
|
41
|
+
const payload = {
|
|
42
|
+
type: "tool_result",
|
|
43
|
+
content: [
|
|
44
|
+
{ type: "text", text: "a".repeat(firstBlockBytes) },
|
|
45
|
+
{ type: "text", text: "b".repeat(secondBlockBytes) },
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const result = sanitizeToolResponsePayload("message.agent", payload);
|
|
50
|
+
expect(result.truncated).toBe(true);
|
|
51
|
+
|
|
52
|
+
const first = result.params.content[0].text as string;
|
|
53
|
+
const second = result.params.content[1].text as string;
|
|
54
|
+
|
|
55
|
+
expect(Buffer.byteLength(first, "utf8")).toBe(firstBlockBytes);
|
|
56
|
+
expect(second.endsWith(TOOL_RESPONSE_SUFFIX)).toBe(true);
|
|
57
|
+
const combinedBytes =
|
|
58
|
+
Buffer.byteLength(first, "utf8") + Buffer.byteLength(second, "utf8");
|
|
59
|
+
expect(combinedBytes).toBeLessThanOrEqual(
|
|
60
|
+
TOOL_RESPONSE_BYTE_LIMIT + Buffer.byteLength(TOOL_RESPONSE_SUFFIX, "utf8")
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|