From 36001af5a4a5e845aa2a439175adb5bf75ea87be Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 4 Nov 2026 18:23:22 -0800 Subject: [PATCH] fix(hub): give every agent a read_file for trimmed results A trimmed result's note, and the size cap's, tell the agent to read_file a tool-output URI, but the coordinator, writer, reviewer and presenter had no read_file, so they could not get a trimmed document read back. They now get a read_file of the agent's saved results that reads only tool-output URIs, so it opens no files, and the coder and designer now share this same reader. --- apps/hub/src/builds/sidecar-tools.ts | 26 ++------ apps/hub/src/workflows/entries/coordinator.ts | 2 + apps/hub/src/workflows/entries/presenter.ts | 8 ++- apps/hub/src/workflows/entries/reviewer.ts | 3 +- apps/hub/src/workflows/packaged.test.ts | 12 +++- .../src/workflows/saved-result-tools.test.ts | 29 +++++++++ apps/hub/src/workflows/saved-result-tools.ts | 65 +++++++++++++++++++ apps/hub/src/workflows/writer-package.ts | 2 + 8 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 apps/hub/src/workflows/saved-result-tools.test.ts create mode 100644 apps/hub/src/workflows/saved-result-tools.ts diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index f0586da9..0acad371 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -2,17 +2,12 @@ import { defineTool, type BaseEnv } from "@intx/agent/authoring"; import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; import { readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { createPosixTools } from "@intx/tools-posix"; import { posix } from "@intx/tools-posix/sidecar-bundle"; -import { - createBlobReader, - type ToolCall, - type ToolResult, - type ToolRunner, -} from "@intx/types/runtime"; +import type { ToolCall, ToolResult, ToolRunner } from "@intx/types/runtime"; import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; import { WorkflowHttpError } from "../workflows/http-json.ts"; +import { savedResultTools } from "../workflows/saved-result-tools.ts"; import { APP_STATUS_TOOL, createCodingTools } from "./coding-tools.ts"; import { clearCommandProgress, @@ -218,7 +213,7 @@ export function createSidecarCodingTools(policy: { ], factory: (env) => { let sandbox: ReturnType | undefined; - let spilled: ReturnType | undefined; + const savedResults = savedResultTools(env); // The coder learns whether its passing app reached the person; a failure leaves the build // going but is never silent. const fileCheckedApp = async (workspace: string, signal: AbortSignal) => { @@ -395,19 +390,7 @@ export function createSidecarCodingTools(policy: { typeof path === "string" && path.startsWith("tool-output://") ) { - spilled ??= createPosixTools({ - cwd: workspace, - blobReader: createBlobReader(env.storage), - }); - // Models drop a slash from tool-output:/// and retry the same URI verbatim. - const uri = path.replace( - /^tool-output:\/\/(?!\/)/, - "tool-output:///", - ); - return spilled.run( - { ...call, arguments: { ...call.arguments, path: uri } }, - signal, - ); + return savedResults.run(call, signal); } // The sandbox refuses an outside write with a bare EPERM, which a model retries // verbatim until the doom guard fails the run; name the mistake instead. @@ -641,7 +624,6 @@ export function createSidecarCodingTools(policy: { await opened?.close(AbortSignal.timeout(VERSION_CLOSE_MS)); designCheck?.dispose(); await native.dispose?.(); - await spilled?.dispose(); await browser?.tools.then( (tools) => tools.dispose?.(), () => undefined, diff --git a/apps/hub/src/workflows/entries/coordinator.ts b/apps/hub/src/workflows/entries/coordinator.ts index 36d1356d..66289b1c 100644 --- a/apps/hub/src/workflows/entries/coordinator.ts +++ b/apps/hub/src/workflows/entries/coordinator.ts @@ -6,6 +6,7 @@ import { createSkillTools } from "../../skills/tools.ts"; import { roleInference } from "../role-inference.ts"; import { compactingDirector } from "../director.ts"; import { dropDegenerateTurn, trimHistory } from "../compaction.ts"; +import { savedResultTools } from "../saved-result-tools.ts"; import { nativePackageName } from "../package-name.ts"; const compacting = compactingDirector(nativePackageName("coordinator")); @@ -25,6 +26,7 @@ const agent = createCoordinatorAgent({ createArtifactTools("coordinator"), createDeployTools(), createSkillTools(["problem-brief"]), + savedResultTools, ], }); const workflow = createCoordinatorWorkflow({ diff --git a/apps/hub/src/workflows/entries/presenter.ts b/apps/hub/src/workflows/entries/presenter.ts index b6d62668..72ce9faa 100644 --- a/apps/hub/src/workflows/entries/presenter.ts +++ b/apps/hub/src/workflows/entries/presenter.ts @@ -9,6 +9,7 @@ import { createSkillTools } from "../../skills/tools.ts"; import { roleInference } from "../role-inference.ts"; import { compactingDirector } from "../director.ts"; import { dropDegenerateTurn, trimHistory } from "../compaction.ts"; +import { savedResultTools } from "../saved-result-tools.ts"; import { nativePackageName } from "../package-name.ts"; const compacting = compactingDirector(nativePackageName("presenter")); @@ -23,7 +24,12 @@ const agent = createPresenterAgent({ id: "presenter", capabilities: [], inference: { ...source, parameters }, - tools: [createPackageTools(), mail, createSkillTools(["presenting"])], + tools: [ + createPackageTools(), + mail, + createSkillTools(["presenting"]), + savedResultTools, + ], }); const workflow = createPresenterWorkflow({ id: "corbits-builder-presenter", diff --git a/apps/hub/src/workflows/entries/reviewer.ts b/apps/hub/src/workflows/entries/reviewer.ts index 54a1fd57..66c50b98 100644 --- a/apps/hub/src/workflows/entries/reviewer.ts +++ b/apps/hub/src/workflows/entries/reviewer.ts @@ -4,6 +4,7 @@ import { mail } from "@intx/tools-mail/sidecar-bundle"; import { roleInference } from "../role-inference.ts"; import { compactingDirector } from "../director.ts"; import { dropDegenerateTurn, trimHistory } from "../compaction.ts"; +import { savedResultTools } from "../saved-result-tools.ts"; import { nativePackageName } from "../package-name.ts"; const compacting = compactingDirector(nativePackageName("reviewer")); @@ -17,7 +18,7 @@ const agent = createReviewerAgent({ id: "reviewer", capabilities: [], inference: { ...source, parameters }, - tools: [mail], + tools: [mail, savedResultTools], }); const workflow = createReviewerWorkflow({ id: "corbits-builder-reviewer", diff --git a/apps/hub/src/workflows/packaged.test.ts b/apps/hub/src/workflows/packaged.test.ts index 49abd4cb..d73707a2 100644 --- a/apps/hub/src/workflows/packaged.test.ts +++ b/apps/hub/src/workflows/packaged.test.ts @@ -77,8 +77,18 @@ test("production entries expose concrete native workflows without callback crede coderAnyPackage, designer, presenter, - ]) + ]) { expect(workflow.credentialBindings ?? []).toEqual([]); + // A trimmed result's note names read_file, so every agent needs one. + const tools = Object.values(workflow.steps).flatMap((step) => + "agent" in step + ? step.agent.toolFactories.flatMap( + (factory) => factory.definitions ?? [], + ) + : [], + ); + expect(tools.map((tool) => tool.name)).toContain("read_file"); + } expect( JSON.stringify([ coordinator, diff --git a/apps/hub/src/workflows/saved-result-tools.test.ts b/apps/hub/src/workflows/saved-result-tools.test.ts new file mode 100644 index 00000000..402fb380 --- /dev/null +++ b/apps/hub/src/workflows/saved-result-tools.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { savedResultTools } from "./saved-result-tools.ts"; + +test("the saved-result read_file returns a saved result and refuses every file", async () => { + const workdir = await mkdtemp(join(tmpdir(), "saved-result-")); + await writeFile(join(workdir, "secret.txt"), "SECRET"); + const tools = savedResultTools({ + workdir, + storage: { + readBlob: async (key: string) => new TextEncoder().encode(`blob ${key}`), + }, + } as never); + const read = (path: string) => + tools.run( + { id: "c", name: "read_file", arguments: { path } }, + new AbortController().signal, + ); + expect((await read("tool-output:///abc")).content).toContain("blob abc"); + expect((await read("tool-output://abc")).content).toContain("blob abc"); + for (const path of [join(workdir, "secret.txt"), "secret.txt"]) { + const result = await read(path); + expect(result.isError).toBe(true); + expect(result.content).not.toContain("SECRET"); + } + await rm(workdir, { recursive: true }); +}); diff --git a/apps/hub/src/workflows/saved-result-tools.ts b/apps/hub/src/workflows/saved-result-tools.ts new file mode 100644 index 00000000..fb324cbf --- /dev/null +++ b/apps/hub/src/workflows/saved-result-tools.ts @@ -0,0 +1,65 @@ +import { defineTool } from "@intx/agent/authoring"; +import { createPosixTools, TOOL_NAMES } from "@intx/tools-posix"; +import { createBlobReader } from "@intx/types/runtime"; + +const SAVED_RESULT = "tool-output://"; + +// Named read_file because the trimming note and Interchange's size-cap note both tell the agent to +// read_file a tool-output:/// URI. It reads only those URIs, so an agent without a workspace gets no +// file access from it; the workdir is a required cwd that is never read. +export const savedResultTools = defineTool({ + id: "@corbits-builder/saved-result/tools", + definitions: [{ name: TOOL_NAMES.READ_FILE }], + factory: (env) => { + const posix = createPosixTools({ + cwd: env.workdir, + blobReader: createBlobReader(env.storage), + }); + return { + definitions: [ + { + name: TOOL_NAMES.READ_FILE, + description: + "Read back the whole of an earlier tool result that was trimmed or cut short, with line numbers. Pass the tool-output:///... URI its note gives.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: "The tool-output:///... URI the note gives", + }, + offset: { + type: "number", + description: "Zero-based line offset to start reading from", + }, + limit: { + type: "number", + description: "Maximum number of lines to return", + }, + }, + required: ["path"], + }, + }, + ], + run: async (call, signal) => { + const path = call.arguments["path"]; + if (typeof path !== "string" || !path.startsWith(SAVED_RESULT)) + return { + callId: call.id, + content: + "read_file here reads only a saved result: pass the tool-output:///... URI its note gives.", + isError: true, + }; + // Models drop a slash from tool-output:/// and retry it verbatim, and the URI parser + // reads the callId after two slashes as a host. + const uri = path.startsWith(`${SAVED_RESULT}/`) + ? path + : path.replace(SAVED_RESULT, `${SAVED_RESULT}/`); + return posix.run( + { ...call, arguments: { ...call.arguments, path: uri } }, + signal, + ); + }, + }; + }, +}); diff --git a/apps/hub/src/workflows/writer-package.ts b/apps/hub/src/workflows/writer-package.ts index 27755ec8..637480dd 100644 --- a/apps/hub/src/workflows/writer-package.ts +++ b/apps/hub/src/workflows/writer-package.ts @@ -6,6 +6,7 @@ import { createSkillTools } from "../skills/tools.ts"; import { roleInference } from "./role-inference.ts"; import { compactingDirector } from "./director.ts"; import { dropDegenerateTurn, trimHistory } from "./compaction.ts"; +import { savedResultTools } from "./saved-result-tools.ts"; import { WRITER_PACKAGES } from "@corbits-builder/schemas/installed-workflows"; import { nativePackageName } from "./package-name.ts"; @@ -31,6 +32,7 @@ export function writerPackage(kind: WriterDocumentKind) { "product-requirements", "build-plan", ]), + savedResultTools, ], }); return {