Skip to content

Commit 4456edb

Browse files
authored
Merge PR #7: fix upstream timeout, tools cache, audit serialization, stderr limits, and filter perf
fix: address 6 review findings — timeout, audit, cache, stderr, filter, comments
2 parents f06a3d6 + 0cbdabf commit 4456edb

7 files changed

Lines changed: 66 additions & 7 deletions

File tree

src/config/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const stdioServerSchema = z.object({
99
maxRestarts: z.number().int().min(0).default(5),
1010
/** Initial backoff in ms, doubles up to 30s. */
1111
restartBackoffMs: z.number().int().min(0).default(500),
12+
/** Per-upstream stderr log budget in bytes per minute (0 = unlimited). */
13+
stderrLogBytesPerMinute: z.number().int().min(0).default(10_000),
1214
/** Per-server description-shrink override. */
1315
shrink: z
1416
.object({
@@ -144,6 +146,8 @@ export const tooltrimConfigSchema = z.object({
144146
observability: observabilitySchema,
145147
policy: policySchema,
146148
logLevel: z.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]).default("info"),
149+
/** Timeout in ms for upstream listTools calls (default 30s). */
150+
upstreamTimeoutMs: z.number().int().min(1000).default(30_000),
147151
});
148152

149153
export type StdioServerConfig = z.infer<typeof stdioServerSchema>;

src/core/aggregator.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export class Aggregator {
5454
private readonly promptRoute = new Map<string, { upstreamId: string; original: string }>();
5555
/** uri -> upstreamId for resources (URIs are unique upstream-side; we keep first wins on collision). */
5656
private readonly resourceRoute = new Map<string, string>();
57+
/** Short-lived cache for collectTools() to debounce redundant fan-out. */
58+
private toolsCache: { tools: unknown[]; ts: number } | null = null;
59+
private readonly TOOLS_CACHE_TTL_MS = 2000;
5760

5861
constructor(deps: AggregatorDeps) {
5962
this.deps = deps;
@@ -210,12 +213,24 @@ export class Aggregator {
210213
* and return the merged list.
211214
*/
212215
async collectTools(): Promise<unknown[]> {
216+
// Return cached result if fresh enough to avoid redundant upstream fan-out.
217+
if (this.toolsCache && Date.now() - this.toolsCache.ts < this.TOOLS_CACHE_TTL_MS) {
218+
return this.toolsCache.tools;
219+
}
220+
213221
this.toolRoute.clear();
214222
const out: Record<string, unknown>[] = [];
223+
const timeoutMs = this.deps.cfg.upstreamTimeoutMs ?? 30_000;
224+
215225
for (const [id, conn] of this.deps.upstream.connections) {
216226
if (conn.status !== "connected" || !conn.capabilities?.tools) continue;
227+
let timer: ReturnType<typeof setTimeout> | undefined;
217228
try {
218-
const result = await conn.client.listTools();
229+
const timeout = new Promise<never>((_, reject) => {
230+
timer = setTimeout(() => reject(new Error(`upstream "${id}" tools/list timed out after ${timeoutMs}ms`)), timeoutMs);
231+
});
232+
const result = await Promise.race([conn.client.listTools(), timeout]);
233+
clearTimeout(timer);
219234
for (const t of result.tools ?? []) {
220235
const namespaced = this.namespace(id, t.name);
221236
if (!this.deps.filter.isAllowed(namespaced, "tool")) continue;
@@ -246,9 +261,11 @@ export class Aggregator {
246261
});
247262
}
248263
} catch (err) {
264+
clearTimeout(timer);
249265
this.log.warn({ id, err: errMsg(err) }, "upstream tools/list failed");
250266
}
251267
}
268+
this.toolsCache = { tools: out, ts: Date.now() };
252269
return out;
253270
}
254271

src/core/filter.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export class ToolFilter {
2626
private readonly applyTools: boolean;
2727
private readonly applyResources: boolean;
2828
private readonly applyPrompts: boolean;
29+
/** Pre-compiled matchers for batch glob evaluation. */
30+
private readonly allowMatcher: ((name: string) => boolean) | null;
31+
private readonly denyMatcher: ((name: string) => boolean) | null;
2932

3033
constructor(opts: FilterOptions) {
3134
this.allow = opts.allow;
@@ -34,6 +37,8 @@ export class ToolFilter {
3437
this.applyTools = apply.tools ?? true;
3538
this.applyResources = apply.resources ?? true;
3639
this.applyPrompts = apply.prompts ?? true;
40+
this.allowMatcher = this.allow.length > 0 ? micromatch.matcher(this.allow) : null;
41+
this.denyMatcher = this.deny.length > 0 ? micromatch.matcher(this.deny) : null;
3742
}
3843

3944
static fromConfig(cfg: TooltrimConfig): ToolFilter {
@@ -49,10 +54,10 @@ export class ToolFilter {
4954
if (kind === "resource" && !this.applyResources) return true;
5055
if (kind === "prompt" && !this.applyPrompts) return true;
5156

52-
if (this.allow.length > 0 && !micromatch.isMatch(namespacedName, this.allow)) {
57+
if (this.allowMatcher && !this.allowMatcher(namespacedName)) {
5358
return false;
5459
}
55-
if (this.deny.length > 0 && micromatch.isMatch(namespacedName, this.deny)) {
60+
if (this.denyMatcher && this.denyMatcher(namespacedName)) {
5661
return false;
5762
}
5863
return true;

src/core/shrinker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,10 @@ export class Shrinker {
180180
.join(" ");
181181
s = s.replace(/\s+/g, " ").trim();
182182

183-
// 6. capitalise first letter for readability
183+
// 8. capitalise first letter for readability
184184
if (s.length > 0) s = s[0]!.toUpperCase() + s.slice(1);
185185

186-
// 7. truncate at the first sentence boundary past `maxChars`
186+
// 9. truncate at the first sentence boundary past `maxChars`
187187
if (s.length > maxChars) {
188188
const sliceEnd = this.findSentenceEnd(s, maxChars);
189189
s = s.slice(0, sliceEnd).trimEnd();

src/observability/audit.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export class AuditLogger {
2424
private readonly enabled: boolean;
2525
private readonly filePath: string;
2626
private dirEnsured = false;
27+
/** Serializes appendFile calls to prevent interleaved NDJSON lines. */
28+
private writeQueue = Promise.resolve();
2729

2830
constructor(enabled: boolean, filePath: string) {
2931
this.enabled = enabled;
@@ -41,6 +43,10 @@ export class AuditLogger {
4143
this.dirEnsured = true;
4244
}
4345
const line = JSON.stringify({ ts: new Date().toISOString(), ...ev });
44-
await appendFile(this.filePath, line + "\n", "utf8");
46+
// Serialize writes to prevent interleaving under concurrent requests.
47+
this.writeQueue = this.writeQueue.then(() =>
48+
appendFile(this.filePath, line + "\n", "utf8"),
49+
);
50+
return this.writeQueue;
4551
}
4652
}

src/types/micromatch.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
declare module "micromatch" {
2+
function micromatch(
3+
patterns: string | string[],
4+
options?: Record<string, unknown>,
5+
): (str: string) => boolean;
6+
7+
namespace micromatch {
8+
function matcher(
9+
patterns: string | string[],
10+
options?: Record<string, unknown>,
11+
): (str: string) => boolean;
12+
}
13+
14+
export = micromatch;
15+
}

src/upstream/manager.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,20 @@ export class UpstreamManager {
161161
cwd: cfg.cwd,
162162
stderr: "pipe",
163163
});
164+
let stderrBytesThisMinute = 0;
165+
let stderrMinuteStart = Date.now();
166+
const stderrBudget = cfg.stderrLogBytesPerMinute ?? 10_000;
167+
164168
transport.stderr?.on("data", (chunk: Buffer) => {
165-
this.log.debug({ id, chunk: chunk.toString("utf8").trim() }, "upstream stderr");
169+
const now = Date.now();
170+
if (now - stderrMinuteStart > 60_000) {
171+
stderrBytesThisMinute = 0;
172+
stderrMinuteStart = now;
173+
}
174+
if (stderrBudget === 0 || stderrBytesThisMinute < stderrBudget) {
175+
this.log.debug({ id, chunk: chunk.toString("utf8").trim() }, "upstream stderr");
176+
stderrBytesThisMinute += chunk.length;
177+
}
166178
});
167179
await client.connect(transport);
168180
}

0 commit comments

Comments
 (0)