From 8e8b66fed49950898e55cc99c33687eaac339ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 05:04:59 -0400 Subject: [PATCH] Q22: cap every tool result and bound connector responses Co-Authored-By: Claude Opus 5.5 --- src/main/services/agent-run-loop.ts | 4 +- src/main/services/composio/composio-client.ts | 20 ++++++++- src/main/services/run-context-compaction.ts | 8 ++++ tests/unit/composio-client-limits.test.ts | 43 +++++++++++++++++++ tests/unit/run-context-compaction.test.ts | 9 +++- 5 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 tests/unit/composio-client-limits.test.ts diff --git a/src/main/services/agent-run-loop.ts b/src/main/services/agent-run-loop.ts index fd209985..fb1e24e5 100644 --- a/src/main/services/agent-run-loop.ts +++ b/src/main/services/agent-run-loop.ts @@ -6,7 +6,7 @@ import { executeWorkspaceTool, isWorkspaceTool, WORKSPACE_TOOLS } from "./agent- import { canonicalMentionHandle } from "../../shared/mention-handles"; import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn"; import { isComposioTool, executeComposioTool } from "./composio/composio-tool-bridge"; -import { compactToolHistory } from "./run-context-compaction"; +import { capToolOutput, compactToolHistory } from "./run-context-compaction"; import { fitContextBudget } from "./context-budget"; const MAX_ITERATIONS = 12; @@ -114,7 +114,7 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls), }); } - results.push({ toolCallId: call.id, toolName: call.name, output }); + results.push({ toolCallId: call.id, toolName: call.name, output: capToolOutput(call.name, output) }); } const followUp = input.pollNewMessages?.(); messages.push(followUp ? { role: "tool", results, followUp } : { role: "tool", results }); diff --git a/src/main/services/composio/composio-client.ts b/src/main/services/composio/composio-client.ts index 40d28f53..0d190042 100644 --- a/src/main/services/composio/composio-client.ts +++ b/src/main/services/composio/composio-client.ts @@ -33,6 +33,8 @@ export interface ComposioToolExecutionResult { error: string | null; } +const MAX_RESPONSE_BYTES = 2_000_000; + export class ComposioClient { constructor(private readonly apiKey: string) {} @@ -155,10 +157,24 @@ export class ComposioClient { request.setHeader("Accept", "application/json"); if (body) request.setHeader("Content-Type", "application/json"); - let responseData = ""; + const chunks: Buffer[] = []; + let received = 0; + let aborted = false; request.on("response", (response) => { - response.on("data", (chunk) => { responseData += chunk.toString(); }); + response.on("data", (chunk) => { + if (aborted) return; + received += chunk.length; + if (received > MAX_RESPONSE_BYTES) { + aborted = true; + request.abort(); + reject(new Error(`Composio API ${method} ${url} returned more than ${MAX_RESPONSE_BYTES} bytes; ask for fewer items or narrower fields.`)); + return; + } + chunks.push(chunk); + }); response.on("end", () => { + if (aborted) return; + const responseData = Buffer.concat(chunks).toString("utf8"); if (response.statusCode === 404 && options?.tolerateNotFound) { resolve({}); return; diff --git a/src/main/services/run-context-compaction.ts b/src/main/services/run-context-compaction.ts index d13d6630..ba521c97 100644 --- a/src/main/services/run-context-compaction.ts +++ b/src/main/services/run-context-compaction.ts @@ -3,6 +3,14 @@ import type { AgentTurnMessage } from "./provider-tool-turn"; const KEEP_RECENT_TOOL_TURNS = 3; const ELIDE_ABOVE_CHARS = 600; const PREVIEW_CHARS = 300; +const TOOL_RESULT_CHAR_LIMIT = 26_000; + +/** One tool result never exceeds the per-result cap. */ +export function capToolOutput(toolName: string, output: string): string { + if (output.length <= TOOL_RESULT_CHAR_LIMIT) return output; + console.warn(`[run-loop] ${toolName} returned ${output.length} chars; cut to ${TOOL_RESULT_CHAR_LIMIT}`); + return `${output.slice(0, TOOL_RESULT_CHAR_LIMIT)}\n\n[result of ${toolName} cut at ${TOOL_RESULT_CHAR_LIMIT} of ${output.length} characters; ask for less (filters, pagination, fewer fields)]`; +} /** Older tool results shrink to a preview; the latest turns stay verbatim. */ export function compactToolHistory(messages: AgentTurnMessage[], keepRecent = KEEP_RECENT_TOOL_TURNS): { messages: AgentTurnMessage[]; elided: number } { diff --git a/tests/unit/composio-client-limits.test.ts b/tests/unit/composio-client-limits.test.ts new file mode 100644 index 00000000..c2d2f09d --- /dev/null +++ b/tests/unit/composio-client-limits.test.ts @@ -0,0 +1,43 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, test, vi } from "vitest"; + +const chunksToSend: Buffer[] = []; +let aborted = false; + +vi.mock("electron", () => ({ + net: { + request: () => { + const request = new EventEmitter() as EventEmitter & { setHeader: () => void; write: () => void; end: () => void; abort: () => void }; + request.setHeader = () => undefined; + request.write = () => undefined; + request.abort = () => { aborted = true; }; + request.end = () => { + const response = new EventEmitter() as EventEmitter & { statusCode: number }; + response.statusCode = 200; + request.emit("response", response); + for (const chunk of chunksToSend) response.emit("data", chunk); + response.emit("end"); + }; + return request; + }, + }, +})); + +const { ComposioClient } = await import("../../src/main/services/composio/composio-client"); + +describe("composio client response limits", () => { + test("a multi-byte character split across chunks decodes intact", async () => { + const json = Buffer.from(JSON.stringify({ items: [{ slug: "GMAIL_SEND", description: "发送邮件" }] }), "utf8"); + const split = json.indexOf(Buffer.from("邮", "utf8")) + 1; + chunksToSend.splice(0, chunksToSend.length, json.subarray(0, split), json.subarray(split)); + const tools = await new ComposioClient("ak_test").getTools(["gmail"]); + expect(tools.map((tool) => [tool.name, tool.description])).toEqual([["GMAIL_SEND", "发送邮件"]]); + }); + + test("a response over the byte cap aborts and fails loudly", async () => { + aborted = false; + chunksToSend.splice(0, chunksToSend.length, Buffer.alloc(1_500_000, 32), Buffer.alloc(1_500_000, 32)); + await expect(new ComposioClient("ak_test").getTools(["gmail"])).rejects.toThrow("returned more than 2000000 bytes"); + expect(aborted).toBe(true); + }); +}); diff --git a/tests/unit/run-context-compaction.test.ts b/tests/unit/run-context-compaction.test.ts index cfb7cb6e..590b99e7 100644 --- a/tests/unit/run-context-compaction.test.ts +++ b/tests/unit/run-context-compaction.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { compactToolHistory } from "../../src/main/services/run-context-compaction"; +import { capToolOutput, compactToolHistory } from "../../src/main/services/run-context-compaction"; import { runAgentLoop } from "../../src/main/services/agent-run-loop"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; @@ -83,4 +83,11 @@ describe("tool history compaction", () => { expect(outputs.slice(2).every((output) => output.includes("y".repeat(3000)))).toBe(true); connection.close(); }); + + test("every tool result is capped with a note that names the tool and the full size", () => { + expect(capToolOutput("composio_gmail_list", "short")).toBe("short"); + const capped = capToolOutput("composio_gmail_list", "r".repeat(40_000)); + expect(capped.length).toBeLessThan(26_200); + expect(capped.endsWith("[result of composio_gmail_list cut at 26000 of 40000 characters; ask for less (filters, pagination, fewer fields)]")).toBe(true); + }); });