| author | |
| committer | |
| log | 38c187bfb7a0020db36ccc1479e7c3501b205fb8 |
| tree | b40d8076e0c403fb5c03fd1f3927398e4a2605bc |
| parent | 4f63275ac7885281d245022e65f1e9e8816b1382 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 659 insertions(+), 141 deletions(-)
packages/provider-pi/src/server/adapter.test.ts+294-7| ... | ... | @@ -39,6 +39,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 39 | 39 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 40 | 40 | import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; |
| 41 | 41 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 42 | import { toolOutputImages, compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; | |
| 42 | 43 | import { |
| 43 | 44 | makePiAdapterV2, |
| 44 | 45 | PiAdapterV2Driver, |
| ... | ... | @@ -46,6 +47,8 @@ import { |
| 46 | 47 | type PiAdapterV2Options, |
| 47 | 48 | } from "./adapter.ts"; |
| 48 | 49 | import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; |
| 50 | import { loadMcpBridge } from "./mcpBridge.testkit.ts"; | |
| 51 | import { turnItemOutputText } from "../../../client-runtime/src/work-log/itemDetail.ts"; | |
| 49 | 52 | |
| 50 | 53 | const layerTest = Layer.mergeAll( |
| 51 | 54 | NodeServices.layer, |
| ... | ... | @@ -77,7 +80,7 @@ const modelSelection = (model: string): ModelSelection => ({ |
| 77 | 80 | |
| 78 | 81 | interface FakePi { |
| 79 | 82 | readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 80 | readonly emit: (record: PiRpcRecord) => Effect.Effect<void>; | |
| 83 | readonly emit: (record: PiRpcRecord, chunkBytes?: number) => Effect.Effect<void>; | |
| 81 | 84 | readonly takeRequest: (type: string) => Effect.Effect<PiRpcRecord>; |
| 82 | 85 | /** Data returned by the next `get_entries` acks, consumed in order. */ |
| 83 | 86 | readonly queueEntries: (data: unknown) => void; |
| ... | ... | @@ -153,10 +156,14 @@ const makeFakePi: Effect.Effect<FakePi> = Effect.gen(function* () { |
| 153 | 156 | let models: ReadonlyArray<unknown> = []; |
| 154 | 157 | let stdinBuffer = ""; |
| 155 | 158 | |
| 156 | const emit = (record: PiRpcRecord) => | |
| 157 | Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)).pipe( | |
| 158 | Effect.asVoid, | |
| 159 | ); | |
| 159 | const emit = (record: PiRpcRecord, chunkBytes?: number) => | |
| 160 | Effect.gen(function* () { | |
| 161 | const bytes = new TextEncoder().encode(`${encodeJsonLine(record)}\n`); | |
| 162 | const size = chunkBytes ?? bytes.byteLength; | |
| 163 | for (let offset = 0; offset < bytes.byteLength; offset += size) { | |
| 164 | yield* Queue.offer(stdout, bytes.subarray(offset, offset + size)); | |
| 165 | } | |
| 166 | }); | |
| 160 | 167 | |
| 161 | 168 | const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { |
| 162 | 169 | if (typeof record["id"] !== "string") return null; |
| ... | ... | @@ -1852,6 +1859,284 @@ describe("PiAdapterV2", () => { |
| 1852 | 1859 | }).pipe(Effect.scoped, Effect.provide(layerTest)), |
| 1853 | 1860 | ); |
| 1854 | 1861 | |
| 1862 | it.effect( | |
| 1863 | "keeps native and MCP images available to the shared asset reader with typed results", | |
| 1864 | () => | |
| 1865 | Effect.gen(function* () { | |
| 1866 | const fake = yield* makeFakePi; | |
| 1867 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1868 | const providerThread = yield* runtime.ensureThread({ | |
| 1869 | threadId: THREAD_ID, | |
| 1870 | modelSelection: modelSelection("default"), | |
| 1871 | runtimePolicy, | |
| 1872 | }); | |
| 1873 | yield* startTurn(runtime, providerThread); | |
| 1874 | yield* fake.takeRequest("prompt"); | |
| 1875 | yield* fake.emit({ type: "agent_start" }); | |
| 1876 | const image = { | |
| 1877 | type: "image", | |
| 1878 | mimeType: "image/png", | |
| 1879 | data: Buffer.alloc(64 * 1024, 0x61).toString("base64"), | |
| 1880 | }; | |
| 1881 | const note = "Read image file [image/png]"; | |
| 1882 | const content = [{ type: "text", text: note }, image]; | |
| 1883 | const structured = { count: 2, ready: true, paths: ["one.png", "two.png"] }; | |
| 1884 | const scriptResult = { content, structuredContent: { threadId: "child-thread" } }; | |
| 1885 | const cases = [ | |
| 1886 | { | |
| 1887 | toolName: "read", | |
| 1888 | result: { content, structuredContent: { ...image, note } }, | |
| 1889 | expected: { content }, | |
| 1890 | }, | |
| 1891 | { | |
| 1892 | toolName: "image_generate", | |
| 1893 | result: { content, structuredContent: structured }, | |
| 1894 | expected: { content, structuredContent: structured }, | |
| 1895 | }, | |
| 1896 | { | |
| 1897 | toolName: "mcp__t3-code__preview_snapshot", | |
| 1898 | result: { | |
| 1899 | content, | |
| 1900 | structuredContent: scriptResult, | |
| 1901 | details: { server: "t3-code", tool: "preview_snapshot" }, | |
| 1902 | }, | |
| 1903 | expected: { content, structuredContent: { threadId: "child-thread" } }, | |
| 1904 | }, | |
| 1905 | { | |
| 1906 | toolName: "structured_tool", | |
| 1907 | result: { content: [], structuredContent: [false, 2, null, { ready: true }] }, | |
| 1908 | expected: { content: [], structuredContent: [false, 2, null, { ready: true }] }, | |
| 1909 | }, | |
| 1910 | { | |
| 1911 | toolName: "mcp__custom__extension", | |
| 1912 | result: { content, structuredContent: { content: ["domain content"], ready: true } }, | |
| 1913 | expected: { content, structuredContent: { content: ["domain content"], ready: true } }, | |
| 1914 | }, | |
| 1915 | { | |
| 1916 | toolName: "mcp__t3-code__preview_snapshot", | |
| 1917 | result: { | |
| 1918 | content: [ | |
| 1919 | { type: "text", text: "first" }, | |
| 1920 | { type: "text", text: "second" }, | |
| 1921 | ], | |
| 1922 | structuredContent: { | |
| 1923 | content: [ | |
| 1924 | { type: "text", text: "first" }, | |
| 1925 | { type: "text", text: "second" }, | |
| 1926 | ], | |
| 1927 | }, | |
| 1928 | details: { server: "t3-code", tool: "preview_snapshot" }, | |
| 1929 | }, | |
| 1930 | expected: { | |
| 1931 | content: [ | |
| 1932 | { type: "text", text: "first" }, | |
| 1933 | { type: "text", text: "second" }, | |
| 1934 | ], | |
| 1935 | }, | |
| 1936 | }, | |
| 1937 | ]; | |
| 1938 | for (const [index, test] of cases.entries()) { | |
| 1939 | for (const phase of ["update", "end"] as const) { | |
| 1940 | yield* fake.emit({ | |
| 1941 | type: `tool_execution_${phase}`, | |
| 1942 | toolCallId: `image-${index}`, | |
| 1943 | toolName: test.toolName, | |
| 1944 | ...(phase === "end" ? { result: test.result } : { partialResult: test.result }), | |
| 1945 | isError: false, | |
| 1946 | }); | |
| 1947 | const event = yield* takeEvent( | |
| 1948 | (event) => | |
| 1949 | event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", | |
| 1950 | ); | |
| 1951 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") | |
| 1952 | return yield* Effect.die("Expected an image tool item"); | |
| 1953 | assert.equal(event.turnItem.status, phase === "end" ? "completed" : "running"); | |
| 1954 | assert.deepEqual(event.turnItem.output, test.expected); | |
| 1955 | assert.deepEqual( | |
| 1956 | toolOutputImages(event.turnItem.output), | |
| 1957 | test.expected.content.some((block) => block.type === "image") | |
| 1958 | ? [{ mimeType: "image/png", data: image.data }] | |
| 1959 | : [], | |
| 1960 | ); | |
| 1961 | if (index === 0) assert.equal(turnItemOutputText(event.turnItem), note); | |
| 1962 | if (index === 5) assert.equal(turnItemOutputText(event.turnItem), "first\nsecond"); | |
| 1963 | if (index === 2) { | |
| 1964 | assert.deepEqual(compactDynamicToolOutput(event.turnItem.output), { | |
| 1965 | threadId: "child-thread", | |
| 1966 | }); | |
| 1967 | assert.equal(JSON.stringify(event.turnItem.output).split(image.data).length - 1, 1); | |
| 1968 | } | |
| 1969 | } | |
| 1970 | } | |
| 1971 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 1972 | ); | |
| 1973 | ||
| 1974 | it.effect( | |
| 1975 | "keeps large tool text readable, drops identical read mirrors, and bounds structured values", | |
| 1976 | () => | |
| 1977 | Effect.gen(function* () { | |
| 1978 | const fake = yield* makeFakePi; | |
| 1979 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 1980 | const providerThread = yield* runtime.ensureThread({ | |
| 1981 | threadId: THREAD_ID, | |
| 1982 | modelSelection: modelSelection("default"), | |
| 1983 | runtimePolicy, | |
| 1984 | }); | |
| 1985 | yield* startTurn(runtime, providerThread); | |
| 1986 | yield* fake.takeRequest("prompt"); | |
| 1987 | yield* fake.emit({ type: "agent_start" }); | |
| 1988 | const text = "hello\nworld\n" + "🙂".repeat(40_000); | |
| 1989 | const content = [{ type: "text", text }]; | |
| 1990 | const cases = [ | |
| 1991 | { content, structuredContent: text }, | |
| 1992 | { content, structuredContent: { oversized: "x".repeat(128 * 1024) } }, | |
| 1993 | { content, structuredContent: Array.from({ length: 10_000 }, () => 1) }, | |
| 1994 | { | |
| 1995 | content, | |
| 1996 | structuredContent: Array.from({ length: 40 }).reduce<unknown>( | |
| 1997 | (nested) => ({ nested }), | |
| 1998 | null, | |
| 1999 | ), | |
| 2000 | }, | |
| 2001 | ]; | |
| 2002 | for (const [index, result] of cases.entries()) { | |
| 2003 | yield* fake.emit({ | |
| 2004 | type: "tool_execution_end", | |
| 2005 | toolCallId: `structured-${index}`, | |
| 2006 | toolName: "read", | |
| 2007 | result, | |
| 2008 | isError: true, | |
| 2009 | }); | |
| 2010 | const event = yield* takeEvent( | |
| 2011 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", | |
| 2012 | ); | |
| 2013 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") | |
| 2014 | return yield* Effect.die("Expected a tool item"); | |
| 2015 | assert.equal(event.turnItem.status, "failed"); | |
| 2016 | assert.notProperty(event.turnItem.output, "structuredContent"); | |
| 2017 | const displayed = turnItemOutputText(event.turnItem); | |
| 2018 | if (index === 0) { | |
| 2019 | assert.equal(event.turnItem.output, text); | |
| 2020 | assert.equal(displayed, text); | |
| 2021 | } else { | |
| 2022 | assert.equal( | |
| 2023 | displayed, | |
| 2024 | text + "\nStructured output omitted because it exceeds the stored result limit.", | |
| 2025 | ); | |
| 2026 | assert.deepEqual(Object.keys(event.turnItem.output as object), ["content"]); | |
| 2027 | } | |
| 2028 | } | |
| 2029 | // Distinct typed metadata remains stored even when readable text exists. | |
| 2030 | yield* fake.emit({ | |
| 2031 | type: "tool_execution_end", | |
| 2032 | toolCallId: "distinct-metadata", | |
| 2033 | toolName: "read", | |
| 2034 | result: { content, structuredContent: { path: "read.ts", ready: true } }, | |
| 2035 | }); | |
| 2036 | const event = yield* takeEvent( | |
| 2037 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", | |
| 2038 | ); | |
| 2039 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") | |
| 2040 | return yield* Effect.die("Expected a tool item"); | |
| 2041 | assert.deepEqual(event.turnItem.output, { | |
| 2042 | content, | |
| 2043 | structuredContent: { path: "read.ts", ready: true }, | |
| 2044 | }); | |
| 2045 | assert.equal(turnItemOutputText(event.turnItem), text); | |
| 2046 | const note = "Read image file [image/png]"; | |
| 2047 | const image = { type: "image", data: "AAAA", mimeType: "image/png" }; | |
| 2048 | const imageContent = [{ type: "text", text: note }, image]; | |
| 2049 | // An extension can attach distinct typed metadata to a read result. | |
| 2050 | for (const structuredContent of [ | |
| 2051 | { ...image, note, width: 1200 }, | |
| 2052 | { ...image, note: "A distinct note" }, | |
| 2053 | { ...image, note, data: "AQID" }, | |
| 2054 | ]) { | |
| 2055 | yield* fake.emit({ | |
| 2056 | type: "tool_execution_end", | |
| 2057 | toolCallId: "distinct-image-metadata", | |
| 2058 | toolName: "read", | |
| 2059 | result: { content: imageContent, structuredContent }, | |
| 2060 | }); | |
| 2061 | const event = yield* takeEvent( | |
| 2062 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", | |
| 2063 | ); | |
| 2064 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") | |
| 2065 | return yield* Effect.die("Expected a read image tool item"); | |
| 2066 | assert.deepEqual(event.turnItem.output, { content: imageContent, structuredContent }); | |
| 2067 | assert.equal(turnItemOutputText(event.turnItem), note); | |
| 2068 | } | |
| 2069 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2070 | ); | |
| 2071 | ||
| 2072 | it.effect( | |
| 2073 | "delivers realistic MCP screenshots through the bridge, JSONL transport, and adapter", | |
| 2074 | () => | |
| 2075 | Effect.gen(function* () { | |
| 2076 | const fake = yield* makeFakePi; | |
| 2077 | const { runtime, takeEvent } = yield* openRuntime(fake); | |
| 2078 | const providerThread = yield* runtime.ensureThread({ | |
| 2079 | threadId: THREAD_ID, | |
| 2080 | modelSelection: modelSelection("default"), | |
| 2081 | runtimePolicy, | |
| 2082 | }); | |
| 2083 | yield* startTurn(runtime, providerThread); | |
| 2084 | yield* fake.takeRequest("prompt"); | |
| 2085 | yield* fake.emit({ type: "agent_start" }); | |
| 2086 | for (const size of [3.25 * 1024 * 1024, 10 * 1024 * 1024]) { | |
| 2087 | const pixels = Buffer.alloc(size, 0x61); | |
| 2088 | const data = pixels.toString("base64"); | |
| 2089 | const content = [ | |
| 2090 | { type: "text", text: "Screenshot captured" }, | |
| 2091 | { type: "image", data, mimeType: "image/png" }, | |
| 2092 | ]; | |
| 2093 | const metadata = { screenshot: { width: 1200, height: 800 }, ready: true }; | |
| 2094 | const bridge = yield* Effect.promise(() => | |
| 2095 | loadMcpBridge({ modern: true, result: { content, structuredContent: metadata } }), | |
| 2096 | ); | |
| 2097 | const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!; | |
| 2098 | const result = yield* Effect.promise(() => tool.execute("screenshot", {})); | |
| 2099 | assert.deepEqual(result.structuredContent, { content, structuredContent: metadata }); | |
| 2100 | yield* fake.emit( | |
| 2101 | { | |
| 2102 | type: "tool_execution_end", | |
| 2103 | toolCallId: "screenshot", | |
| 2104 | toolName: tool.name, | |
| 2105 | result, | |
| 2106 | isError: false, | |
| 2107 | }, | |
| 2108 | 64 * 1024, | |
| 2109 | ); | |
| 2110 | // A following small completion proves a dropped screenshot immediately, | |
| 2111 | // without waiting for a timeout when framing loses the large event. | |
| 2112 | yield* fake.emit({ | |
| 2113 | type: "tool_execution_end", | |
| 2114 | toolCallId: "after-screenshot", | |
| 2115 | toolName: "marker", | |
| 2116 | result: { content: [{ type: "text", text: "after" }] }, | |
| 2117 | }); | |
| 2118 | const event = yield* takeEvent( | |
| 2119 | (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", | |
| 2120 | ); | |
| 2121 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") | |
| 2122 | return yield* Effect.die("Expected a screenshot tool item"); | |
| 2123 | assert.equal(event.turnItem.toolName, tool.name); | |
| 2124 | assert.equal(event.turnItem.status, "completed"); | |
| 2125 | assert.deepEqual(event.turnItem.output, { content, structuredContent: metadata }); | |
| 2126 | assert.equal(turnItemOutputText(event.turnItem), "Screenshot captured"); | |
| 2127 | const image = toolOutputImages(event.turnItem.output)[0]; | |
| 2128 | assert.equal(image?.mimeType, "image/png"); | |
| 2129 | assert.deepEqual(Buffer.from(image!.data!, "base64"), pixels); | |
| 2130 | yield* takeEvent( | |
| 2131 | (event) => | |
| 2132 | event.type === "turn_item.updated" && | |
| 2133 | event.turnItem.type === "dynamic_tool" && | |
| 2134 | event.turnItem.toolName === "marker", | |
| 2135 | ); | |
| 2136 | } | |
| 2137 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | |
| 2138 | ); | |
| 2139 | ||
| 1855 | 2140 | it.effect("observes official subagent results without inventing child threads", () => |
| 1856 | 2141 | Effect.gen(function* () { |
| 1857 | 2142 | const fake = yield* makeFakePi; |
| ... | ... | @@ -3512,8 +3797,10 @@ describe("PiRpc framing", () => { |
| 3512 | 3797 | yield* push('{"type":"agent_'); |
| 3513 | 3798 | yield* push('start"}\r\n{"type":"agent_settled"}\nnot json\n{"type":"queue_update"}\n'); |
| 3514 | 3799 | |
| 3515 | yield* push("x".repeat(8 * 1024 * 1024)); | |
| 3516 | yield* push('x{"type":"must_not_emit"}\n{"type":"after_oversized"}\n'); | |
| 3800 | yield* push('{"type":"must_not_emit","text":"'); | |
| 3801 | const chunk = new TextEncoder().encode("x".repeat(8 * 1024 * 1024)); | |
| 3802 | for (let index = 0; index < 32; index++) yield* Queue.offer(stdout, chunk); | |
| 3803 | yield* push('"}\n{"type":"after_oversized"}\n'); | |
| 3517 | 3804 | |
| 3518 | 3805 | const first = yield* Queue.take(connection.events); |
| 3519 | 3806 | assert.equal(first["type"], "agent_start"); |
packages/provider-pi/src/server/adapter.ts+102-1| ... | ... | @@ -26,6 +26,8 @@ |
| 26 | 26 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 27 | 27 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 28 | 28 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 29 | import { readToolOutputImage } from "@t3tools/shared/toolOutput"; | |
| 30 | import * as Predicate from "effect/Predicate"; | |
| 29 | 31 | import { |
| 30 | 32 | defaultInstanceIdForDriver, |
| 31 | 33 | ProviderDriverKind, |
| ... | ... | @@ -247,6 +249,104 @@ function contentText(content: unknown): string { |
| 247 | 249 | .join(""); |
| 248 | 250 | } |
| 249 | 251 | |
| 252 | const MAX_STRUCTURED_TOOL_OUTPUT_BYTES = 64 * 1024; | |
| 253 | ||
| 254 | /** Preserve complete structured JSON only when its size and traversal remain bounded. */ | |
| 255 | function isBoundedStructuredToolValue(value: unknown): boolean { | |
| 256 | let bytes = MAX_STRUCTURED_TOOL_OUTPUT_BYTES; | |
| 257 | let nodes = 4_096; | |
| 258 | const visit = (entry: unknown, depth: number): boolean => { | |
| 259 | if (depth > 32 || --nodes < 0 || bytes < 0) return false; | |
| 260 | if (Array.isArray(entry)) { | |
| 261 | bytes -= 2 + entry.length; | |
| 262 | return entry.every((item) => visit(item, depth + 1)); | |
| 263 | } | |
| 264 | if (Predicate.isObject(entry)) { | |
| 265 | bytes -= 2; | |
| 266 | for (const key in entry) { | |
| 267 | if (!Object.hasOwn(entry, key)) continue; | |
| 268 | if (key.length > bytes) return false; | |
| 269 | bytes -= Buffer.byteLength(JSON.stringify(key), "utf8") + 2; | |
| 270 | if (!visit(entry[key], depth + 1)) return false; | |
| 271 | } | |
| 272 | return bytes >= 0; | |
| 273 | } | |
| 274 | if (typeof entry === "string" && entry.length > bytes) return false; | |
| 275 | bytes -= Buffer.byteLength(JSON.stringify(entry) ?? "null", "utf8"); | |
| 276 | return bytes >= 0; | |
| 277 | }; | |
| 278 | return visit(value, 0); | |
| 279 | } | |
| 280 | ||
| 281 | /** Keep model content and distinct typed values; server ingestion owns image byte policy. */ | |
| 282 | function piToolOutput(result: unknown, toolName: string): unknown { | |
| 283 | const rawContent = recordField(result, "content"); | |
| 284 | const content = Array.isArray(rawContent) | |
| 285 | ? rawContent | |
| 286 | : typeof rawContent === "string" | |
| 287 | ? [{ type: "text", text: rawContent }] | |
| 288 | : []; | |
| 289 | let structured = recordField(result, "structuredContent"); | |
| 290 | // Pi MCP tools return the full CallToolResult to scripts. The model content | |
| 291 | // is already captured, so store only its distinct structured payload. | |
| 292 | const details = recordField(result, "details"); | |
| 293 | const isMcpResult = | |
| 294 | toolName.startsWith("mcp__") && | |
| 295 | recordString(details, "server") !== undefined && | |
| 296 | recordString(details, "tool") !== undefined && | |
| 297 | Array.isArray(recordField(structured, "content")); | |
| 298 | if (isMcpResult) { | |
| 299 | structured = recordField(structured, "structuredContent"); | |
| 300 | } | |
| 301 | if (typeof structured === "string" && structured === contentText(content)) { | |
| 302 | structured = undefined; | |
| 303 | } | |
| 304 | // Native read repeats its image and first text note as structured output. | |
| 305 | // Drop only that exact mirror; an extension's extra metadata stays distinct. | |
| 306 | if (toolName === "read" && Predicate.isObject(structured)) { | |
| 307 | const image = readToolOutputImage(structured); | |
| 308 | const note = | |
| 309 | recordString( | |
| 310 | content.find((block) => recordField(block, "type") === "text"), | |
| 311 | "text", | |
| 312 | ) ?? ""; | |
| 313 | if ( | |
| 314 | image !== null && | |
| 315 | Object.keys(structured).every((key) => ["type", "data", "mimeType", "note"].includes(key)) && | |
| 316 | recordString(structured, "note") === note && | |
| 317 | content.some((block) => { | |
| 318 | const match = readToolOutputImage(block); | |
| 319 | return match !== null && match.mimeType === image.mimeType && match.data === image.data; | |
| 320 | }) | |
| 321 | ) { | |
| 322 | structured = undefined; | |
| 323 | } | |
| 324 | } | |
| 325 | if (structured !== undefined && !isBoundedStructuredToolValue(structured)) { | |
| 326 | return { | |
| 327 | content: [ | |
| 328 | ...content, | |
| 329 | { | |
| 330 | type: "text", | |
| 331 | text: "Structured output omitted because it exceeds the stored result limit.", | |
| 332 | }, | |
| 333 | ], | |
| 334 | }; | |
| 335 | } | |
| 336 | if ( | |
| 337 | !isMcpResult && | |
| 338 | structured === undefined && | |
| 339 | content.every((block) => recordField(block, "type") === "text") | |
| 340 | ) { | |
| 341 | const text = contentText(content); | |
| 342 | return text.length > 0 ? text : undefined; | |
| 343 | } | |
| 344 | return { | |
| 345 | content, | |
| 346 | ...(structured === undefined ? {} : { structuredContent: structured }), | |
| 347 | }; | |
| 348 | } | |
| 349 | ||
| 250 | 350 | function providerRef( |
| 251 | 351 | nativeId: string, |
| 252 | 352 | strength: "strong" | "weak" = "strong", |
| ... | ... | @@ -1047,6 +1147,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1047 | 1147 | return; |
| 1048 | 1148 | } |
| 1049 | 1149 | } |
| 1150 | const output = piToolOutput(resultRecord, toolName); | |
| 1050 | 1151 | yield* emit({ |
| 1051 | 1152 | type: "turn_item.updated", |
| 1052 | 1153 | driver: PI_PROVIDER, |
| ... | ... | @@ -1057,7 +1158,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 1057 | 1158 | ...mcpToolPresentation({ toolName }), |
| 1058 | 1159 | toolName, |
| 1059 | 1160 | input: args ?? {}, |
| 1060 | ...(outputText.length > 0 ? { output: outputText } : {}), | |
| 1161 | ...(output === undefined ? {} : { output }), | |
| 1061 | 1162 | }, |
| 1062 | 1163 | }); |
| 1063 | 1164 | if (toolName === "subagent") { |
packages/provider-pi/src/server/mcpBridge.testkit.ts created+140| ... | ... | @@ -0,0 +1,140 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Executes the shipped Pi extension at its native JavaScript boundary. | |
| 2 | import * as NodeModule from "node:module"; | |
| 3 | import * as NodeVM from "node:vm"; | |
| 4 | import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; | |
| 5 | ||
| 6 | interface RegisteredTool { | |
| 7 | readonly name: string; | |
| 8 | readonly description: string; | |
| 9 | readonly parameters: unknown; | |
| 10 | readonly outputSchema?: unknown; | |
| 11 | readonly exposure?: string; | |
| 12 | readonly promptSnippet?: string; | |
| 13 | readonly promptGuidelines?: ReadonlyArray<string>; | |
| 14 | readonly execute: ( | |
| 15 | id: string, | |
| 16 | args: Record<string, unknown>, | |
| 17 | signal?: AbortSignal, | |
| 18 | ) => Promise<{ | |
| 19 | readonly content: ReadonlyArray<{ | |
| 20 | readonly type: string; | |
| 21 | readonly text?: string; | |
| 22 | readonly data?: string; | |
| 23 | readonly mimeType?: string; | |
| 24 | }>; | |
| 25 | readonly structuredContent?: unknown; | |
| 26 | readonly isError?: boolean; | |
| 27 | }>; | |
| 28 | } | |
| 29 | ||
| 30 | type AgentStartHook = ( | |
| 31 | event: { systemPrompt: string }, | |
| 32 | ctx: { ui: { notify: (message: string, severity: string) => void } }, | |
| 33 | ) => Promise<{ systemPrompt: string }>; | |
| 34 | ||
| 35 | export async function loadMcpBridge( | |
| 36 | options: { | |
| 37 | readonly modern?: boolean; | |
| 38 | readonly result?: unknown; | |
| 39 | readonly toolSearchAvailable?: boolean; | |
| 40 | readonly toolSearchDisabled?: boolean; | |
| 41 | readonly allowsTool?: (name: string) => boolean; | |
| 42 | } = {}, | |
| 43 | ) { | |
| 44 | const handlers = new Map<string, AgentStartHook>(); | |
| 45 | const tools: RegisteredTool[] = []; | |
| 46 | const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; | |
| 47 | let activeTools = ["read"]; | |
| 48 | const transports: Array<{ | |
| 49 | readonly url: string; | |
| 50 | readonly authorization: string; | |
| 51 | readonly signal: AbortSignal | undefined; | |
| 52 | }> = []; | |
| 53 | const servers: Array<{ readonly name: string; readonly config: Record<string, unknown> }> = []; | |
| 54 | const catalog = [ | |
| 55 | { name: "orchestrator_capabilities", description: "Discover available providers and models." }, | |
| 56 | { name: "delegate_task", description: "Delegate work to another agent." }, | |
| 57 | { name: "task_status", description: "Check delegated work." }, | |
| 58 | { name: "preview_snapshot", description: "Inspect the collaborative browser." }, | |
| 59 | ].map((tool) => ({ | |
| 60 | ...tool, | |
| 61 | inputSchema: { type: "object", properties: {} }, | |
| 62 | outputSchema: { type: "object", properties: { count: { type: "number" } } }, | |
| 63 | })); | |
| 64 | const source = NodeModule.stripTypeScriptTypes( | |
| 65 | PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( | |
| 66 | "export default async function", | |
| 67 | "async function", | |
| 68 | ), | |
| 69 | ); | |
| 70 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 71 | process: { | |
| 72 | env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, | |
| 73 | }, | |
| 74 | AbortSignal, | |
| 75 | Type: { Unsafe: (schema: unknown) => schema }, | |
| 76 | fetch: async ( | |
| 77 | url: string, | |
| 78 | transport: { body: string; headers: Record<string, string>; signal?: AbortSignal }, | |
| 79 | ) => { | |
| 80 | transport.signal?.throwIfAborted(); | |
| 81 | transports.push({ | |
| 82 | url, | |
| 83 | authorization: transport.headers.authorization!, | |
| 84 | signal: transport.signal, | |
| 85 | }); | |
| 86 | const request = JSON.parse(transport.body) as { | |
| 87 | id: number; | |
| 88 | method: string; | |
| 89 | params?: unknown; | |
| 90 | }; | |
| 91 | requests.push(request); | |
| 92 | const result = | |
| 93 | request.method === "tools/list" | |
| 94 | ? { tools: catalog } | |
| 95 | : request.method === "tools/call" | |
| 96 | ? (options.result ?? { content: [{ type: "text", text: "browser snapshot" }] }) | |
| 97 | : {}; | |
| 98 | return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { | |
| 99 | headers: { "content-type": "application/json" }, | |
| 100 | }); | |
| 101 | }, | |
| 102 | pi: { | |
| 103 | on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), | |
| 104 | registerTool: (tool: RegisteredTool) => { | |
| 105 | if (options.allowsTool && !options.allowsTool(tool.name)) return; | |
| 106 | const index = tools.findIndex((current) => current.name === tool.name); | |
| 107 | if (index === -1) tools.push(tool); | |
| 108 | else tools[index] = tool; | |
| 109 | }, | |
| 110 | getActiveTools: () => activeTools, | |
| 111 | setActiveTools: (names: string[]) => { | |
| 112 | activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); | |
| 113 | }, | |
| 114 | getAllTools: () => | |
| 115 | options.toolSearchAvailable && | |
| 116 | !options.toolSearchDisabled && | |
| 117 | (options.allowsTool?.("tool_search") ?? true) | |
| 118 | ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] | |
| 119 | : [], | |
| 120 | ...(options.modern | |
| 121 | ? { | |
| 122 | registerMcpServer: (name: string, config: Record<string, unknown>) => | |
| 123 | servers.push({ name, config }), | |
| 124 | unregisterMcpServer: () => servers.splice(0), | |
| 125 | } | |
| 126 | : {}), | |
| 127 | }, | |
| 128 | }); | |
| 129 | return { | |
| 130 | handlers, | |
| 131 | tools, | |
| 132 | requests, | |
| 133 | servers, | |
| 134 | transports, | |
| 135 | getActiveTools: () => activeTools, | |
| 136 | restoreActiveTools: (names: string[]) => { | |
| 137 | activeTools = names; | |
| 138 | }, | |
| 139 | }; | |
| 140 | } |
packages/provider-pi/src/server/mcpExtensionSource.test.ts+60-115| ... | ... | @@ -4,9 +4,10 @@ import * as NodePath from "node:path"; |
| 4 | 4 | import * as NodeOS from "node:os"; |
| 5 | 5 | import * as NodeModule from "node:module"; |
| 6 | 6 | import * as NodeVM from "node:vm"; |
| 7 | import { assert, describe, it } from "@effect/vitest"; | |
| 7 | import { assert, describe, expect, it } from "@effect/vitest"; | |
| 8 | 8 | |
| 9 | 9 | import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; |
| 10 | import { loadMcpBridge } from "./mcpBridge.testkit.ts"; | |
| 10 | 11 | |
| 11 | 12 | type RequestHook = ( |
| 12 | 13 | event: { payload: unknown }, |
| ... | ... | @@ -47,123 +48,67 @@ async function loadHooks(commands: ReadonlyArray<SkillCommand> = []) { |
| 47 | 48 | return handlers; |
| 48 | 49 | } |
| 49 | 50 | |
| 50 | interface RegisteredTool { | |
| 51 | readonly name: string; | |
| 52 | readonly description: string; | |
| 53 | readonly parameters: unknown; | |
| 54 | readonly exposure?: string; | |
| 55 | readonly promptSnippet?: string; | |
| 56 | readonly promptGuidelines?: ReadonlyArray<string>; | |
| 57 | readonly execute: ( | |
| 58 | id: string, | |
| 59 | args: Record<string, unknown>, | |
| 60 | signal?: AbortSignal, | |
| 61 | ) => Promise<{ | |
| 62 | readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }>; | |
| 63 | }>; | |
| 64 | } | |
| 65 | ||
| 66 | type AgentStartHook = ( | |
| 67 | event: { systemPrompt: string }, | |
| 68 | ctx: { ui: { notify: (message: string, severity: string) => void } }, | |
| 69 | ) => Promise<{ systemPrompt: string }>; | |
| 70 | ||
| 71 | async function loadMcpBridge( | |
| 72 | options: { | |
| 73 | readonly modern?: boolean; | |
| 74 | readonly toolSearchAvailable?: boolean; | |
| 75 | readonly toolSearchDisabled?: boolean; | |
| 76 | readonly allowsTool?: (name: string) => boolean; | |
| 77 | } = {}, | |
| 78 | ) { | |
| 79 | const handlers = new Map<string, AgentStartHook>(); | |
| 80 | const tools: RegisteredTool[] = []; | |
| 81 | const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; | |
| 82 | let activeTools = ["read"]; | |
| 83 | const transports: Array<{ | |
| 84 | readonly url: string; | |
| 85 | readonly authorization: string; | |
| 86 | readonly signal: AbortSignal | undefined; | |
| 87 | }> = []; | |
| 88 | const servers: Array<{ readonly name: string; readonly config: Record<string, unknown> }> = []; | |
| 89 | const catalog = [ | |
| 90 | { name: "orchestrator_capabilities", description: "Discover available providers and models." }, | |
| 91 | { name: "delegate_task", description: "Delegate work to another agent." }, | |
| 92 | { name: "task_status", description: "Check delegated work." }, | |
| 93 | { name: "preview_snapshot", description: "Inspect the collaborative browser." }, | |
| 94 | ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } })); | |
| 95 | const source = NodeModule.stripTypeScriptTypes( | |
| 96 | PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( | |
| 97 | "export default async function", | |
| 98 | "async function", | |
| 99 | ), | |
| 100 | ); | |
| 101 | await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { | |
| 102 | process: { | |
| 103 | env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, | |
| 104 | }, | |
| 105 | AbortSignal, | |
| 106 | Type: { Unsafe: (schema: unknown) => schema }, | |
| 107 | fetch: async ( | |
| 108 | url: string, | |
| 109 | options: { body: string; headers: Record<string, string>; signal?: AbortSignal }, | |
| 110 | ) => { | |
| 111 | transports.push({ | |
| 112 | url, | |
| 113 | authorization: options.headers.authorization!, | |
| 114 | signal: options.signal, | |
| 115 | }); | |
| 116 | const request = JSON.parse(options.body) as { id: number; method: string; params?: unknown }; | |
| 117 | requests.push(request); | |
| 118 | const result = | |
| 119 | request.method === "tools/list" | |
| 120 | ? { tools: catalog } | |
| 121 | : request.method === "tools/call" | |
| 122 | ? { content: [{ type: "text", text: "browser snapshot" }] } | |
| 123 | : {}; | |
| 124 | return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { | |
| 125 | headers: { "content-type": "application/json" }, | |
| 126 | }); | |
| 127 | }, | |
| 128 | pi: { | |
| 129 | on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), | |
| 130 | registerTool: (tool: RegisteredTool) => { | |
| 131 | if (options.allowsTool && !options.allowsTool(tool.name)) return; | |
| 132 | const index = tools.findIndex((current) => current.name === tool.name); | |
| 133 | if (index === -1) tools.push(tool); | |
| 134 | else tools[index] = tool; | |
| 135 | }, | |
| 136 | getActiveTools: () => activeTools, | |
| 137 | setActiveTools: (names: string[]) => { | |
| 138 | activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); | |
| 51 | describe("Pi MCP tool results", () => { | |
| 52 | it("returns mixed screenshot blocks and typed script output without repeating structured text", async () => { | |
| 53 | const content = [ | |
| 54 | { type: "text", text: "Browser screenshot" }, | |
| 55 | { type: "image", data: "AAAA", mimeType: "image/png", _meta: { private: true } }, | |
| 56 | { type: "text", text: "Screenshot captured" }, | |
| 57 | ] as const; | |
| 58 | const result = { content, structuredContent: { count: 2 }, _meta: { private: true } }; | |
| 59 | const bridge = await loadMcpBridge({ modern: true, result }); | |
| 60 | const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!; | |
| 61 | const output = await tool.execute("snapshot", {}); | |
| 62 | assert.deepEqual(output.content, [ | |
| 63 | content[0], | |
| 64 | { type: "image", data: "AAAA", mimeType: "image/png" }, | |
| 65 | content[2], | |
| 66 | ]); | |
| 67 | assert.deepEqual(output.structuredContent, { content, structuredContent: { count: 2 } }); | |
| 68 | assert.deepEqual(tool.outputSchema, { | |
| 69 | type: "object", | |
| 70 | properties: { | |
| 71 | content: { type: "array", items: { type: "object" } }, | |
| 72 | structuredContent: { type: "object", properties: { count: { type: "number" } } }, | |
| 73 | isError: { type: "boolean" }, | |
| 74 | _meta: { type: "object" }, | |
| 139 | 75 | }, |
| 140 | getAllTools: () => | |
| 141 | options.toolSearchAvailable && | |
| 142 | !options.toolSearchDisabled && | |
| 143 | (options.allowsTool?.("tool_search") ?? true) | |
| 144 | ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] | |
| 145 | : [], | |
| 146 | ...(options.modern | |
| 147 | ? { | |
| 148 | registerMcpServer: (name: string, config: Record<string, unknown>) => | |
| 149 | servers.push({ name, config }), | |
| 150 | unregisterMcpServer: () => servers.splice(0), | |
| 151 | } | |
| 152 | : {}), | |
| 153 | }, | |
| 76 | required: ["content"], | |
| 77 | }); | |
| 154 | 78 | }); |
| 155 | return { | |
| 156 | handlers, | |
| 157 | tools, | |
| 158 | requests, | |
| 159 | servers, | |
| 160 | transports, | |
| 161 | getActiveTools: () => activeTools, | |
| 162 | restoreActiveTools: (names: string[]) => { | |
| 163 | activeTools = names; | |
| 79 | ||
| 80 | it.each([false, true])( | |
| 81 | "preserves image-only results and error status, isError=%s", | |
| 82 | async (isError) => { | |
| 83 | const image = { type: "image", data: "AAAA", mimeType: "image/png" }; | |
| 84 | const result = { content: [image], isError }; | |
| 85 | const bridge = await loadMcpBridge({ result }); | |
| 86 | const output = await bridge.tools[0]!.execute("image", {}); | |
| 87 | assert.deepEqual(output.content[0], image); | |
| 88 | assert.equal(output.content.length, isError ? 2 : 1); | |
| 89 | if (isError) assert.include(output.content[1]?.text ?? "", "returned an error"); | |
| 90 | assert.equal(output.isError, isError ? true : undefined); | |
| 91 | assert.deepEqual(output.structuredContent, result); | |
| 164 | 92 | }, |
| 165 | }; | |
| 166 | } | |
| 93 | ); | |
| 94 | ||
| 95 | it("uses structured output as model text when content is empty", async () => { | |
| 96 | const result = { content: [], structuredContent: { count: 2 } }; | |
| 97 | const bridge = await loadMcpBridge({ result }); | |
| 98 | const output = await bridge.tools[0]!.execute("structured", {}); | |
| 99 | assert.deepEqual(output.content, [{ type: "text", text: '{"count":2}' }]); | |
| 100 | assert.deepEqual(output.structuredContent, result); | |
| 101 | }); | |
| 102 | ||
| 103 | it("propagates cancellation without returning a successful result", async () => { | |
| 104 | const bridge = await loadMcpBridge(); | |
| 105 | const controller = new AbortController(); | |
| 106 | controller.abort(); | |
| 107 | await expect(bridge.tools[0]!.execute("cancelled", {}, controller.signal)).rejects.toThrow( | |
| 108 | "aborted", | |
| 109 | ); | |
| 110 | }); | |
| 111 | }); | |
| 167 | 112 | |
| 168 | 113 | describe("Pi MCP tool exposure", () => { |
| 169 | 114 | it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { |
packages/provider-pi/src/server/mcpExtensionSource.ts+54-17| ... | ... | @@ -38,9 +38,11 @@ const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)}); |
| 38 | 38 | |
| 39 | 39 | type RuntimeMode = "approval-required" | "auto-accept-edits" | "auto" | "full-access"; |
| 40 | 40 | |
| 41 | type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; | |
| 42 | ||
| 41 | 43 | type JsonRpcResponse = { |
| 42 | 44 | readonly id?: number | string; |
| 43 | readonly result?: unknown; | |
| 45 | readonly result?: JsonValue; | |
| 44 | 46 | readonly error?: { readonly message?: string }; |
| 45 | 47 | }; |
| 46 | 48 | |
| ... | ... | @@ -48,6 +50,7 @@ type McpTool = { |
| 48 | 50 | readonly name: string; |
| 49 | 51 | readonly description?: string; |
| 50 | 52 | readonly inputSchema?: Record<string, unknown>; |
| 53 | readonly outputSchema?: Record<string, unknown>; | |
| 51 | 54 | }; |
| 52 | 55 | |
| 53 | 56 | function env(name: string): string | undefined { |
| ... | ... | @@ -96,28 +99,57 @@ function jsonSchemaToTypebox(schema: Record<string, unknown> | undefined) { |
| 96 | 99 | return Type.Object({}, { additionalProperties: true }); |
| 97 | 100 | } |
| 98 | 101 | |
| 99 | function formatMcpContent(result: unknown): string { | |
| 100 | if (result === null || result === undefined) return ""; | |
| 101 | if (typeof result !== "object") return String(result); | |
| 102 | type ModelContent = | |
| 103 | | { type: "text"; text: string } | |
| 104 | | { type: "image"; data: string; mimeType: string }; | |
| 105 | ||
| 106 | function mcpModelContent(result: unknown): ModelContent[] { | |
| 107 | if (result === null || result === undefined) return []; | |
| 108 | if (typeof result !== "object") return [{ type: "text", text: String(result) }]; | |
| 102 | 109 | const record = result as { |
| 103 | readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: string }>; | |
| 110 | readonly content?: ReadonlyArray<{ | |
| 111 | readonly type?: string; | |
| 112 | readonly text?: string; | |
| 113 | readonly data?: string; | |
| 114 | readonly mimeType?: string; | |
| 115 | }>; | |
| 104 | 116 | readonly structuredContent?: unknown; |
| 105 | readonly isError?: boolean; | |
| 106 | 117 | }; |
| 107 | const texts: string[] = []; | |
| 118 | const content: ModelContent[] = []; | |
| 108 | 119 | if (Array.isArray(record.content)) { |
| 109 | 120 | for (const part of record.content) { |
| 110 | if (part?.type === "text" && typeof part.text === "string") texts.push(part.text); | |
| 121 | if (part?.type === "text" && typeof part.text === "string") { | |
| 122 | content.push({ type: "text", text: part.text }); | |
| 123 | } else if (part?.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") { | |
| 124 | content.push({ type: "image", data: part.data, mimeType: part.mimeType }); | |
| 125 | } | |
| 111 | 126 | } |
| 112 | 127 | } |
| 113 | // Most T3 tools mirror structuredContent in a text block. Repeating it would | |
| 114 | // leave T3's own output parsing two JSON documents instead of one. | |
| 115 | if (record.structuredContent !== undefined) { | |
| 116 | const structured = JSON.stringify(record.structuredContent); | |
| 117 | if (!texts.includes(structured)) texts.push(structured); | |
| 128 | // Structured output belongs to scripts. Only use it as model text when | |
| 129 | // the server supplied no model-facing blocks, matching Pi's native MCP tools. | |
| 130 | if (content.length === 0) { | |
| 131 | content.push({ type: "text", text: JSON.stringify(record.structuredContent ?? result) }); | |
| 118 | 132 | } |
| 119 | if (texts.length > 0) return texts.join("\\n"); | |
| 120 | return JSON.stringify(result); | |
| 133 | return content; | |
| 134 | } | |
| 135 | ||
| 136 | function mcpScriptResult(result: JsonValue | undefined) { | |
| 137 | if (typeof result !== "object" || result === null || Array.isArray(result)) return result; | |
| 138 | const { _meta: _ignored, ...scriptResult } = result; | |
| 139 | return scriptResult; | |
| 140 | } | |
| 141 | ||
| 142 | function mcpOutputSchema(tool: McpTool) { | |
| 143 | return jsonSchemaToTypebox({ | |
| 144 | type: "object", | |
| 145 | properties: { | |
| 146 | content: { type: "array", items: { type: "object" } }, | |
| 147 | ...(tool.outputSchema === undefined ? {} : { structuredContent: tool.outputSchema }), | |
| 148 | isError: { type: "boolean" }, | |
| 149 | _meta: { type: "object" }, | |
| 150 | }, | |
| 151 | required: ["content"], | |
| 152 | }); | |
| 121 | 153 | } |
| 122 | 154 | |
| 123 | 155 | function isMcpToolError(result: unknown): boolean { |
| ... | ... | @@ -328,6 +360,7 @@ export default async function t3McpExtension(pi: ExtensionAPI) { |
| 328 | 360 | label: name, |
| 329 | 361 | description: tool.description ?? name, |
| 330 | 362 | parameters: jsonSchemaToTypebox(tool.inputSchema), |
| 363 | ...(supportsExposure ? { outputSchema: mcpOutputSchema(tool) } : {}), | |
| 331 | 364 | ...(supportsExposure ? { exposure } : {}), |
| 332 | 365 | async execute(_toolCallId, params, signal) { |
| 333 | 366 | const result = await client.callTool( |
| ... | ... | @@ -335,9 +368,13 @@ export default async function t3McpExtension(pi: ExtensionAPI) { |
| 335 | 368 | (params ?? {}) as Record<string, unknown>, |
| 336 | 369 | signal, |
| 337 | 370 | ); |
| 338 | const text = formatMcpContent(result); | |
| 371 | const content = mcpModelContent(result); | |
| 372 | if (isMcpToolError(result) && !content.some((part) => part.type === "text" && part.text.length > 0)) { | |
| 373 | content.push({ type: "text", text: "MCP tool t3-code/" + name + " returned an error" }); | |
| 374 | } | |
| 339 | 375 | return { |
| 340 | content: [{ type: "text", text }], | |
| 376 | content, | |
| 377 | structuredContent: mcpScriptResult(result), | |
| 341 | 378 | details: { server: "t3-code", tool: name }, |
| 342 | 379 | ...(isMcpToolError(result) ? { isError: true } : {}), |
| 343 | 380 | }; |
packages/provider-pi/src/server/rpc.ts+9-1| ... | ... | @@ -30,6 +30,10 @@ import * as ChildProcess from "effect/process/ChildProcess"; |
| 30 | 30 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 31 | 31 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 32 | 32 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 33 | import { | |
| 34 | MAX_TOOL_OUTPUT_IMAGES, | |
| 35 | MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, | |
| 36 | } from "@t3tools/shared/toolOutput"; | |
| 33 | 37 | |
| 34 | 38 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; |
| 35 | 39 | |
| ... | ... | @@ -125,7 +129,11 @@ interface PendingPiRequest { |
| 125 | 129 | readonly deferred: Deferred.Deferred<unknown, PiRpcError>; |
| 126 | 130 | } |
| 127 | 131 | |
| 128 | const MAX_PI_RECORD_CHARS = 8 * 1024 * 1024; | |
| 132 | // MCP completion events carry images in both model content and the script | |
| 133 | // CallToolResult. Allow the shared image budget twice, plus bounded JSON/text | |
| 134 | // overhead, before dropping an extension's oversized record. | |
| 135 | const MAX_PI_RECORD_CHARS = | |
| 136 | 2 * MAX_TOOL_OUTPUT_IMAGES * MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH + 8 * 1024 * 1024; | |
| 129 | 137 | |
| 130 | 138 | function makeJsonlFramer() { |
| 131 | 139 | let buffer = ""; |