@bastani/atomic 0.8.31-alpha.3 → 0.8.31-alpha.5

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/builtin/cursor/CHANGELOG.md +1 -1
  3. package/dist/builtin/cursor/package.json +2 -2
  4. package/dist/builtin/intercom/package.json +1 -1
  5. package/dist/builtin/mcp/CHANGELOG.md +5 -0
  6. package/dist/builtin/mcp/direct-tools.ts +4 -2
  7. package/dist/builtin/mcp/package.json +1 -1
  8. package/dist/builtin/mcp/proxy-modes.ts +4 -2
  9. package/dist/builtin/mcp/utils.ts +25 -0
  10. package/dist/builtin/subagents/package.json +1 -1
  11. package/dist/builtin/web-access/package.json +1 -1
  12. package/dist/builtin/workflows/CHANGELOG.md +9 -0
  13. package/dist/builtin/workflows/builtin/ralph-review-gate.ts +89 -0
  14. package/dist/builtin/workflows/builtin/ralph.ts +16 -51
  15. package/dist/builtin/workflows/package.json +1 -1
  16. package/dist/builtin/workflows/src/extension/dispatcher.ts +3 -0
  17. package/dist/builtin/workflows/src/extension/index.ts +15 -0
  18. package/dist/builtin/workflows/src/extension/runtime.ts +7 -0
  19. package/dist/builtin/workflows/src/runs/foreground/executor.ts +103 -7
  20. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +133 -10
  21. package/dist/builtin/workflows/src/shared/persistence-restore.ts +2 -0
  22. package/dist/core/agent-session.d.ts +25 -0
  23. package/dist/core/agent-session.d.ts.map +1 -1
  24. package/dist/core/agent-session.js +124 -8
  25. package/dist/core/agent-session.js.map +1 -1
  26. package/dist/core/auth-guidance.d.ts +12 -0
  27. package/dist/core/auth-guidance.d.ts.map +1 -1
  28. package/dist/core/auth-guidance.js +24 -0
  29. package/dist/core/auth-guidance.js.map +1 -1
  30. package/dist/core/auth-storage.d.ts +42 -0
  31. package/dist/core/auth-storage.d.ts.map +1 -1
  32. package/dist/core/auth-storage.js +71 -10
  33. package/dist/core/auth-storage.js.map +1 -1
  34. package/dist/core/copilot-gemini-payload-sanitizer.d.ts +72 -0
  35. package/dist/core/copilot-gemini-payload-sanitizer.d.ts.map +1 -0
  36. package/dist/core/copilot-gemini-payload-sanitizer.js +296 -0
  37. package/dist/core/copilot-gemini-payload-sanitizer.js.map +1 -0
  38. package/dist/core/copilot-gemini-reasoning.d.ts +118 -0
  39. package/dist/core/copilot-gemini-reasoning.d.ts.map +1 -0
  40. package/dist/core/copilot-gemini-reasoning.js +260 -0
  41. package/dist/core/copilot-gemini-reasoning.js.map +1 -0
  42. package/dist/core/copilot-gemini-tool-arguments.d.ts +42 -0
  43. package/dist/core/copilot-gemini-tool-arguments.d.ts.map +1 -0
  44. package/dist/core/copilot-gemini-tool-arguments.js +179 -0
  45. package/dist/core/copilot-gemini-tool-arguments.js.map +1 -0
  46. package/dist/core/flattened-tool-arguments.d.ts +41 -0
  47. package/dist/core/flattened-tool-arguments.d.ts.map +1 -0
  48. package/dist/core/flattened-tool-arguments.js +136 -0
  49. package/dist/core/flattened-tool-arguments.js.map +1 -0
  50. package/dist/core/http-dispatcher.d.ts.map +1 -1
  51. package/dist/core/http-dispatcher.js +5 -0
  52. package/dist/core/http-dispatcher.js.map +1 -1
  53. package/dist/core/sdk.d.ts.map +1 -1
  54. package/dist/core/sdk.js +38 -8
  55. package/dist/core/sdk.js.map +1 -1
  56. package/dist/core/session-manager.d.ts +1 -1
  57. package/dist/core/session-manager.d.ts.map +1 -1
  58. package/dist/core/session-manager.js.map +1 -1
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1 -0
  62. package/dist/index.js.map +1 -1
  63. package/docs/providers.md +1 -0
  64. package/docs/sessions.md +4 -0
  65. package/docs/workflows.md +7 -1
  66. package/examples/extensions/gondolin/package-lock.json +183 -183
  67. package/package.json +2 -2
@@ -0,0 +1,260 @@
1
+ import { isCopilotGeminiModel } from "./copilot-gemini-payload-sanitizer.js";
2
+ /** OpenRouter-style encrypted reasoning detail the pi-ai client round-trips. */
3
+ const REASONING_ENCRYPTED_TYPE = "reasoning.encrypted";
4
+ function isPlainObject(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ /**
8
+ * Inject `reasoning_details` into a parsed CAPI Gemini streaming chunk so the
9
+ * pi-ai OpenAI-completions parser captures the Gemini thought signature.
10
+ *
11
+ * For each `choices[].delta` that carries a non-empty `reasoning_opaque` string
12
+ * and a `tool_calls[]` entry with an `id`, adds a single
13
+ * `reasoning_details: [{ type: "reasoning.encrypted", id, data: <opaque> }]`
14
+ * entry keyed by that tool-call id. Returns whether the chunk was mutated.
15
+ *
16
+ * No-op when the delta already has `reasoning_details`, has no id-bearing tool
17
+ * call (e.g. argument-continuation deltas or pure-text thought chunks), or has
18
+ * no `reasoning_opaque`.
19
+ */
20
+ export function injectCopilotGeminiReasoningDetails(chunk) {
21
+ if (!isPlainObject(chunk))
22
+ return false;
23
+ const choices = chunk.choices;
24
+ if (!Array.isArray(choices))
25
+ return false;
26
+ let mutated = false;
27
+ for (const choice of choices) {
28
+ if (!isPlainObject(choice))
29
+ continue;
30
+ const delta = choice.delta;
31
+ if (!isPlainObject(delta))
32
+ continue;
33
+ const opaque = delta.reasoning_opaque;
34
+ if (typeof opaque !== "string" || opaque.length === 0)
35
+ continue;
36
+ // Already carries the encrypted detail (don't double-inject / clobber).
37
+ if (Array.isArray(delta.reasoning_details) && delta.reasoning_details.length > 0) {
38
+ continue;
39
+ }
40
+ const toolCalls = delta.tool_calls;
41
+ if (!Array.isArray(toolCalls))
42
+ continue;
43
+ const idBearing = toolCalls.find((call) => isPlainObject(call) && typeof call.id === "string" && call.id.length > 0);
44
+ if (!idBearing)
45
+ continue;
46
+ delta.reasoning_details = [
47
+ { type: REASONING_ENCRYPTED_TYPE, id: idBearing.id, data: opaque },
48
+ ];
49
+ mutated = true;
50
+ }
51
+ return mutated;
52
+ }
53
+ /**
54
+ * Rewrite the JSON payload of a single SSE `data:` line. Returns the original
55
+ * string unchanged when it is not a Gemini chunk that needs a thought signature
56
+ * bridged, or when parsing fails (fail-open: never corrupt the stream).
57
+ */
58
+ export function rewriteCopilotGeminiSseData(dataPayload) {
59
+ // Cheap gate: only chunks that actually carry a thought signature are touched.
60
+ if (!dataPayload.includes("reasoning_opaque"))
61
+ return dataPayload;
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(dataPayload);
65
+ }
66
+ catch {
67
+ return dataPayload;
68
+ }
69
+ if (!injectCopilotGeminiReasoningDetails(parsed))
70
+ return dataPayload;
71
+ return JSON.stringify(parsed);
72
+ }
73
+ /** Rewrite one SSE line, preserving a trailing carriage return when present. */
74
+ function rewriteSseLine(line) {
75
+ const hasCr = line.endsWith("\r");
76
+ const core = hasCr ? line.slice(0, -1) : line;
77
+ if (!core.startsWith("data:"))
78
+ return line;
79
+ const payload = core.slice("data:".length).trimStart();
80
+ if (payload.length === 0 || payload === "[DONE]")
81
+ return line;
82
+ const rewritten = rewriteCopilotGeminiSseData(payload);
83
+ if (rewritten === payload)
84
+ return line;
85
+ const rebuilt = `data: ${rewritten}`;
86
+ return hasCr ? `${rebuilt}\r` : rebuilt;
87
+ }
88
+ /**
89
+ * Wrap a CAPI Gemini SSE byte stream so `reasoning_opaque` is bridged into
90
+ * `reasoning_details`. Buffers across chunk boundaries and rewrites whole lines
91
+ * only; bytes that are not affected pass through unchanged.
92
+ *
93
+ * Implemented as a `ReadableStream` over the source reader (rather than a
94
+ * `TransformStream` piped via `pipeThrough`) so the transform pulls lazily and
95
+ * propagates cancellation to the upstream body.
96
+ */
97
+ export function createCopilotGeminiSseStream(source) {
98
+ const reader = source.getReader();
99
+ const decoder = new TextDecoder();
100
+ const encoder = new TextEncoder();
101
+ let buffer = "";
102
+ return new ReadableStream({
103
+ async pull(controller) {
104
+ // Loop until we emit at least one chunk or close, so a read that yields no
105
+ // complete line still makes progress without relying on the runtime to
106
+ // re-invoke pull after a no-enqueue return.
107
+ for (;;) {
108
+ const { done, value } = await reader.read();
109
+ if (done) {
110
+ buffer += decoder.decode();
111
+ if (buffer.length > 0) {
112
+ controller.enqueue(encoder.encode(rewriteSseLine(buffer)));
113
+ buffer = "";
114
+ }
115
+ controller.close();
116
+ return;
117
+ }
118
+ buffer += decoder.decode(value, { stream: true });
119
+ let newlineIndex = buffer.indexOf("\n");
120
+ let out = "";
121
+ while (newlineIndex !== -1) {
122
+ const line = buffer.slice(0, newlineIndex);
123
+ buffer = buffer.slice(newlineIndex + 1);
124
+ out += `${rewriteSseLine(line)}\n`;
125
+ newlineIndex = buffer.indexOf("\n");
126
+ }
127
+ if (out.length > 0) {
128
+ controller.enqueue(encoder.encode(out));
129
+ return;
130
+ }
131
+ }
132
+ },
133
+ cancel(reason) {
134
+ return reader.cancel(reason);
135
+ },
136
+ });
137
+ }
138
+ /**
139
+ * Convert the `reasoning_details` the pi-ai client re-emits on replayed
140
+ * assistant messages back into the single `reasoning_opaque` field CAPI reads.
141
+ *
142
+ * For GitHub Copilot Gemini payloads, each assistant message that carries a
143
+ * `reasoning_details` entry of `type: "reasoning.encrypted"` has its `data`
144
+ * (the original CAPI thought-signature blob) promoted to `reasoning_opaque`,
145
+ * and the now-redundant `reasoning_details` removed. No-op for every other
146
+ * provider/model and for payloads without such messages.
147
+ */
148
+ export function restoreCopilotGeminiReasoningOpaque(payload, model) {
149
+ if (!isCopilotGeminiModel(model))
150
+ return payload;
151
+ if (!isPlainObject(payload))
152
+ return payload;
153
+ const payloadObject = payload;
154
+ const messages = payloadObject.messages;
155
+ if (!Array.isArray(messages))
156
+ return payload;
157
+ let mutated = false;
158
+ const nextMessages = messages.map((message) => {
159
+ if (!isPlainObject(message) || message.role !== "assistant")
160
+ return message;
161
+ const details = message.reasoning_details;
162
+ if (!Array.isArray(details) || details.length === 0)
163
+ return message;
164
+ const encrypted = details.find((detail) => isPlainObject(detail) &&
165
+ detail.type === REASONING_ENCRYPTED_TYPE &&
166
+ typeof detail.data === "string" &&
167
+ detail.data.length > 0);
168
+ if (!encrypted)
169
+ return message;
170
+ mutated = true;
171
+ const { reasoning_details: _omitted, ...rest } = message;
172
+ return { ...rest, reasoning_opaque: encrypted.data };
173
+ });
174
+ if (!mutated)
175
+ return payload;
176
+ return { ...payloadObject, messages: nextMessages };
177
+ }
178
+ /** Whether the URL targets Copilot's CAPI gateway (`*.githubcopilot.com`). */
179
+ function isCopilotApiHost(url) {
180
+ try {
181
+ const host = new URL(url).hostname.toLowerCase();
182
+ // Exact host or a real subdomain only — never a look-alike suffix such as
183
+ // `notgithubcopilot.com` (CodeQL: incomplete URL substring sanitization).
184
+ return host === "githubcopilot.com" || host.endsWith(".githubcopilot.com");
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ /** Resolve the request URL string from a `fetch` input argument. */
191
+ function resolveRequestUrl(input) {
192
+ if (typeof input === "string")
193
+ return input;
194
+ if (input instanceof URL)
195
+ return input.href;
196
+ if (typeof input === "object" && input !== null && "url" in input) {
197
+ const url = input.url;
198
+ if (typeof url === "string")
199
+ return url;
200
+ }
201
+ return undefined;
202
+ }
203
+ /**
204
+ * Rewrite a streaming CAPI Gemini response so its SSE body bridges
205
+ * `reasoning_opaque` into `reasoning_details`. Returns the original response
206
+ * untouched for non-Copilot hosts, non-event-stream responses, or bodyless
207
+ * responses, keeping the blast radius to streaming CAPI Gemini turns only.
208
+ */
209
+ export function maybeRewriteCopilotGeminiResponse(url, response) {
210
+ if (!url || !isCopilotApiHost(url))
211
+ return response;
212
+ const contentType = response.headers.get("content-type") ?? "";
213
+ if (!contentType.includes("text/event-stream"))
214
+ return response;
215
+ if (!response.body)
216
+ return response;
217
+ const transformed = createCopilotGeminiSseStream(response.body);
218
+ return new Response(transformed, {
219
+ status: response.status,
220
+ statusText: response.statusText,
221
+ headers: response.headers,
222
+ });
223
+ }
224
+ let originalFetch;
225
+ /**
226
+ * Install a `globalThis.fetch` wrapper that rewrites CAPI Gemini SSE responses
227
+ * to bridge `reasoning_opaque` into `reasoning_details` (see
228
+ * {@link createCopilotGeminiSseStream}). Idempotent.
229
+ *
230
+ * The OpenAI SDK used by the `openai-completions` provider resolves
231
+ * `globalThis.fetch` at client-construction time, and a new client is built per
232
+ * request, so wrapping the global before the first request is reliably picked
233
+ * up. Non-Copilot hosts and non-event-stream responses are returned untouched,
234
+ * keeping the blast radius to streaming CAPI Gemini turns only.
235
+ */
236
+ export function installCopilotGeminiReasoningInterceptor() {
237
+ if (originalFetch)
238
+ return;
239
+ if (typeof globalThis.fetch !== "function")
240
+ return;
241
+ const base = globalThis.fetch;
242
+ originalFetch = base;
243
+ const boundFetch = base.bind(globalThis);
244
+ const wrapped = (async (input, init) => {
245
+ const response = await boundFetch(input, init);
246
+ try {
247
+ return maybeRewriteCopilotGeminiResponse(resolveRequestUrl(input), response);
248
+ }
249
+ catch {
250
+ return response;
251
+ }
252
+ });
253
+ // Preserve `fetch.preconnect` so the wrapper remains a drop-in replacement.
254
+ const preconnect = base.preconnect;
255
+ if (typeof preconnect === "function") {
256
+ wrapped.preconnect = preconnect.bind(globalThis);
257
+ }
258
+ globalThis.fetch = wrapped;
259
+ }
260
+ //# sourceMappingURL=copilot-gemini-reasoning.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copilot-gemini-reasoning.js","sourceRoot":"","sources":["../../src/core/copilot-gemini-reasoning.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Api, Model } from \"@earendil-works/pi-ai\";\nimport { isCopilotGeminiModel } from \"./copilot-gemini-payload-sanitizer.ts\";\n\n/**\n * Round-trips GitHub Copilot Gemini \"thought signatures\" so multi-turn tool use\n * does not silently die after the first tool call.\n *\n * Why this exists\n * ---------------\n * `github-copilot` Gemini models (e.g. `gemini-3.1-pro-preview`) are served\n * through Copilot's CAPI gateway, which proxies to Google's GenAI API. Gemini\n * is a thinking model: when it emits a function/tool call it also returns an\n * opaque **thought signature** that must be sent back, verbatim, on the next\n * request or Gemini refuses to continue the reasoning chain.\n *\n * CAPI carries that signature in a non-standard OpenAI-completions field named\n * **`reasoning_opaque`** (an encrypted blob) on the assistant message / streamed\n * delta, and on replay it reads the same `reasoning_opaque` back off the\n * assistant message and re-attaches the signature to each Gemini function-call\n * part (keyed by `tool_call.id`). The underlying OpenAI-completions client\n * (`@earendil-works/pi-ai`) does not understand `reasoning_opaque`; it captures\n * thought signatures only from the OpenRouter-style\n * `reasoning_details: [{ type: \"reasoning.encrypted\", id, data }]` shape, which\n * CAPI never emits. So the real Gemini thought signature was being dropped on\n * the way in and never replayed on the way out.\n *\n * With the signature missing, CAPI substitutes the sentinel\n * `skip_thought_signature_validator` on the first replayed function call, and\n * Gemini responds with an empty candidate / `finish_reason: \"stop\"` and zero\n * output tokens — the harness sees a degenerate empty completion, retries with\n * the same signature-less history, and eventually gives up: \"Gemini just stops\n * responding.\"\n *\n * What this does\n * --------------\n * Two gated, self-contained transforms bridge CAPI's `reasoning_opaque` to the\n * `reasoning_details` mechanism the client already round-trips:\n *\n * - **Inbound** ({@link rewriteCopilotGeminiSseData} via\n * {@link createCopilotGeminiSseStream}): rewrites the CAPI Gemini SSE\n * stream so each streamed delta that carries both `reasoning_opaque` and a\n * `tool_calls[].id` gains a\n * `reasoning_details: [{ type: \"reasoning.encrypted\", id, data: <opaque> }]`\n * entry. The client then stores it as the tool call's `thoughtSignature`.\n * CAPI confirms `reasoning_opaque` rides on the same streamed delta as the\n * first (id-bearing) tool-call chunk, so the association is exact.\n * - **Outbound** ({@link restoreCopilotGeminiReasoningOpaque} from the\n * `onPayload` hook): converts the `reasoning_details` the client re-emits on\n * replayed assistant messages back into a single `reasoning_opaque` field on\n * that assistant message, which is the only shape CAPI reads.\n *\n * Both transforms are gated to GitHub Copilot Gemini and are no-ops for every\n * other provider/model (and for Gemini turns that carry no thought signature).\n */\n\ntype JsonObject = { [key: string]: JsonValue };\ntype JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;\n\n/** OpenRouter-style encrypted reasoning detail the pi-ai client round-trips. */\nconst REASONING_ENCRYPTED_TYPE = \"reasoning.encrypted\";\n\nfunction isPlainObject(value: unknown): value is JsonObject {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Inject `reasoning_details` into a parsed CAPI Gemini streaming chunk so the\n * pi-ai OpenAI-completions parser captures the Gemini thought signature.\n *\n * For each `choices[].delta` that carries a non-empty `reasoning_opaque` string\n * and a `tool_calls[]` entry with an `id`, adds a single\n * `reasoning_details: [{ type: \"reasoning.encrypted\", id, data: <opaque> }]`\n * entry keyed by that tool-call id. Returns whether the chunk was mutated.\n *\n * No-op when the delta already has `reasoning_details`, has no id-bearing tool\n * call (e.g. argument-continuation deltas or pure-text thought chunks), or has\n * no `reasoning_opaque`.\n */\nexport function injectCopilotGeminiReasoningDetails(chunk: JsonValue): boolean {\n if (!isPlainObject(chunk)) return false;\n const choices = chunk.choices;\n if (!Array.isArray(choices)) return false;\n\n let mutated = false;\n for (const choice of choices) {\n if (!isPlainObject(choice)) continue;\n const delta = choice.delta;\n if (!isPlainObject(delta)) continue;\n\n const opaque = delta.reasoning_opaque;\n if (typeof opaque !== \"string\" || opaque.length === 0) continue;\n\n // Already carries the encrypted detail (don't double-inject / clobber).\n if (Array.isArray(delta.reasoning_details) && delta.reasoning_details.length > 0) {\n continue;\n }\n\n const toolCalls = delta.tool_calls;\n if (!Array.isArray(toolCalls)) continue;\n const idBearing = toolCalls.find(\n (call): call is JsonObject =>\n isPlainObject(call) && typeof call.id === \"string\" && call.id.length > 0,\n );\n if (!idBearing) continue;\n\n delta.reasoning_details = [\n { type: REASONING_ENCRYPTED_TYPE, id: idBearing.id as string, data: opaque },\n ];\n mutated = true;\n }\n return mutated;\n}\n\n/**\n * Rewrite the JSON payload of a single SSE `data:` line. Returns the original\n * string unchanged when it is not a Gemini chunk that needs a thought signature\n * bridged, or when parsing fails (fail-open: never corrupt the stream).\n */\nexport function rewriteCopilotGeminiSseData(dataPayload: string): string {\n // Cheap gate: only chunks that actually carry a thought signature are touched.\n if (!dataPayload.includes(\"reasoning_opaque\")) return dataPayload;\n let parsed: JsonValue;\n try {\n parsed = JSON.parse(dataPayload) as JsonValue;\n } catch {\n return dataPayload;\n }\n if (!injectCopilotGeminiReasoningDetails(parsed)) return dataPayload;\n return JSON.stringify(parsed);\n}\n\n/** Rewrite one SSE line, preserving a trailing carriage return when present. */\nfunction rewriteSseLine(line: string): string {\n const hasCr = line.endsWith(\"\\r\");\n const core = hasCr ? line.slice(0, -1) : line;\n if (!core.startsWith(\"data:\")) return line;\n const payload = core.slice(\"data:\".length).trimStart();\n if (payload.length === 0 || payload === \"[DONE]\") return line;\n const rewritten = rewriteCopilotGeminiSseData(payload);\n if (rewritten === payload) return line;\n const rebuilt = `data: ${rewritten}`;\n return hasCr ? `${rebuilt}\\r` : rebuilt;\n}\n\n/**\n * Wrap a CAPI Gemini SSE byte stream so `reasoning_opaque` is bridged into\n * `reasoning_details`. Buffers across chunk boundaries and rewrites whole lines\n * only; bytes that are not affected pass through unchanged.\n *\n * Implemented as a `ReadableStream` over the source reader (rather than a\n * `TransformStream` piped via `pipeThrough`) so the transform pulls lazily and\n * propagates cancellation to the upstream body.\n */\nexport function createCopilotGeminiSseStream(\n source: ReadableStream<Uint8Array>,\n): ReadableStream<Uint8Array> {\n const reader = source.getReader();\n const decoder = new TextDecoder();\n const encoder = new TextEncoder();\n let buffer = \"\";\n\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n // Loop until we emit at least one chunk or close, so a read that yields no\n // complete line still makes progress without relying on the runtime to\n // re-invoke pull after a no-enqueue return.\n for (;;) {\n const { done, value } = await reader.read();\n if (done) {\n buffer += decoder.decode();\n if (buffer.length > 0) {\n controller.enqueue(encoder.encode(rewriteSseLine(buffer)));\n buffer = \"\";\n }\n controller.close();\n return;\n }\n buffer += decoder.decode(value, { stream: true });\n let newlineIndex = buffer.indexOf(\"\\n\");\n let out = \"\";\n while (newlineIndex !== -1) {\n const line = buffer.slice(0, newlineIndex);\n buffer = buffer.slice(newlineIndex + 1);\n out += `${rewriteSseLine(line)}\\n`;\n newlineIndex = buffer.indexOf(\"\\n\");\n }\n if (out.length > 0) {\n controller.enqueue(encoder.encode(out));\n return;\n }\n }\n },\n cancel(reason) {\n return reader.cancel(reason);\n },\n });\n}\n\n/**\n * Convert the `reasoning_details` the pi-ai client re-emits on replayed\n * assistant messages back into the single `reasoning_opaque` field CAPI reads.\n *\n * For GitHub Copilot Gemini payloads, each assistant message that carries a\n * `reasoning_details` entry of `type: \"reasoning.encrypted\"` has its `data`\n * (the original CAPI thought-signature blob) promoted to `reasoning_opaque`,\n * and the now-redundant `reasoning_details` removed. No-op for every other\n * provider/model and for payloads without such messages.\n */\nexport function restoreCopilotGeminiReasoningOpaque(\n payload: unknown,\n model: Pick<Model<Api>, \"provider\" | \"api\" | \"id\">,\n): unknown {\n if (!isCopilotGeminiModel(model)) return payload;\n if (!isPlainObject(payload as JsonValue)) return payload;\n const payloadObject = payload as JsonObject;\n const messages = payloadObject.messages;\n if (!Array.isArray(messages)) return payload;\n\n let mutated = false;\n const nextMessages = messages.map((message) => {\n if (!isPlainObject(message) || message.role !== \"assistant\") return message;\n const details = message.reasoning_details;\n if (!Array.isArray(details) || details.length === 0) return message;\n const encrypted = details.find(\n (detail): detail is JsonObject =>\n isPlainObject(detail) &&\n detail.type === REASONING_ENCRYPTED_TYPE &&\n typeof detail.data === \"string\" &&\n detail.data.length > 0,\n );\n if (!encrypted) return message;\n mutated = true;\n const { reasoning_details: _omitted, ...rest } = message;\n return { ...rest, reasoning_opaque: encrypted.data as string };\n });\n\n if (!mutated) return payload;\n return { ...payloadObject, messages: nextMessages };\n}\n\n/** Whether the URL targets Copilot's CAPI gateway (`*.githubcopilot.com`). */\nfunction isCopilotApiHost(url: string): boolean {\n try {\n const host = new URL(url).hostname.toLowerCase();\n // Exact host or a real subdomain only — never a look-alike suffix such as\n // `notgithubcopilot.com` (CodeQL: incomplete URL substring sanitization).\n return host === \"githubcopilot.com\" || host.endsWith(\".githubcopilot.com\");\n } catch {\n return false;\n }\n}\n\n/** Resolve the request URL string from a `fetch` input argument. */\nfunction resolveRequestUrl(input: Parameters<typeof fetch>[0]): string | undefined {\n if (typeof input === \"string\") return input;\n if (input instanceof URL) return input.href;\n if (typeof input === \"object\" && input !== null && \"url\" in input) {\n const url = (input as { url?: unknown }).url;\n if (typeof url === \"string\") return url;\n }\n return undefined;\n}\n\n/**\n * Rewrite a streaming CAPI Gemini response so its SSE body bridges\n * `reasoning_opaque` into `reasoning_details`. Returns the original response\n * untouched for non-Copilot hosts, non-event-stream responses, or bodyless\n * responses, keeping the blast radius to streaming CAPI Gemini turns only.\n */\nexport function maybeRewriteCopilotGeminiResponse(\n url: string | undefined,\n response: Response,\n): Response {\n if (!url || !isCopilotApiHost(url)) return response;\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (!contentType.includes(\"text/event-stream\")) return response;\n if (!response.body) return response;\n const transformed = createCopilotGeminiSseStream(response.body);\n return new Response(transformed, {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n });\n}\n\nlet originalFetch: typeof fetch | undefined;\n\n/**\n * Install a `globalThis.fetch` wrapper that rewrites CAPI Gemini SSE responses\n * to bridge `reasoning_opaque` into `reasoning_details` (see\n * {@link createCopilotGeminiSseStream}). Idempotent.\n *\n * The OpenAI SDK used by the `openai-completions` provider resolves\n * `globalThis.fetch` at client-construction time, and a new client is built per\n * request, so wrapping the global before the first request is reliably picked\n * up. Non-Copilot hosts and non-event-stream responses are returned untouched,\n * keeping the blast radius to streaming CAPI Gemini turns only.\n */\nexport function installCopilotGeminiReasoningInterceptor(): void {\n if (originalFetch) return;\n if (typeof globalThis.fetch !== \"function\") return;\n const base = globalThis.fetch;\n originalFetch = base;\n const boundFetch = base.bind(globalThis);\n\n const wrapped = (async (input, init) => {\n const response = await boundFetch(input, init);\n try {\n return maybeRewriteCopilotGeminiResponse(resolveRequestUrl(input), response);\n } catch {\n return response;\n }\n }) as typeof fetch;\n\n // Preserve `fetch.preconnect` so the wrapper remains a drop-in replacement.\n const preconnect = (base as { preconnect?: unknown }).preconnect;\n if (typeof preconnect === \"function\") {\n (wrapped as { preconnect?: unknown }).preconnect = preconnect.bind(globalThis);\n }\n\n globalThis.fetch = wrapped;\n}\n"]}
@@ -0,0 +1,42 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ /**
3
+ * Reconstruct flattened Gemini tool-call arguments into proper nested
4
+ * arrays/objects. Returns the original reference unchanged when there is nothing
5
+ * to reconstruct. Bracket-indexed keys are always reconstructed; purely dotted
6
+ * keys are reconstructed only when the optional `schema` marks their head
7
+ * segment as an object/array container property. Reconstruction (and its
8
+ * prototype-pollution guard) is delegated to the shared canonical helper.
9
+ */
10
+ export declare function unflattenGeminiToolArguments(args: unknown, schema?: unknown): unknown;
11
+ /**
12
+ * If `model` is a GitHub Copilot Gemini model, normalize flattened tool-call
13
+ * arguments; otherwise return them unchanged. Used to gate
14
+ * {@link unflattenGeminiToolArguments} by model at tool-call time. The optional
15
+ * `schema` is the tool's parameter schema, used to disambiguate dotted keys.
16
+ */
17
+ export declare function normalizeToolArgumentsForModel(args: unknown, model: Pick<Model<Api>, "provider" | "api" | "id"> | undefined, schema?: unknown): unknown;
18
+ /**
19
+ * Reconstruct flattened GitHub Copilot Gemini tool-call arguments on the
20
+ * **outbound replay payload**, so prior assistant tool calls are sent back to
21
+ * CAPI in the nested array/object shape Gemini originally produced.
22
+ *
23
+ * Why this exists
24
+ * ---------------
25
+ * {@link normalizeToolArgumentsForModel} only unflattens at tool *execution*
26
+ * time; the persisted assistant message keeps the raw flattened arguments CAPI
27
+ * delivered (for example `{ "edits[0].newText": "..." }`). When that message is
28
+ * replayed on the next turn, CAPI parses those literal keys straight into the
29
+ * Gemini `FunctionCall.Args`, producing a function call that does not match the
30
+ * tool's declared schema (nor the structure Gemini signed). Gemini then ends
31
+ * the turn with `MALFORMED_FUNCTION_CALL` / `UNEXPECTED_TOOL_CALL` / `OTHER`,
32
+ * which CAPI surfaces as a bare `finish_reason: "error"` — so multi-turn tool
33
+ * use dies one turn after any array/object tool call (such as `edit`).
34
+ *
35
+ * This rewrites each replayed assistant `tool_calls[].function.arguments` JSON
36
+ * into the reconstructed nested shape (reusing {@link unflattenGeminiToolArguments}
37
+ * with the tool's own parameter schema, looked up from the payload's `tools`),
38
+ * fixing both new and already-persisted sessions. Gated to GitHub Copilot Gemini
39
+ * models, fail-open on non-JSON arguments, and a no-op for well-formed args.
40
+ */
41
+ export declare function normalizeCopilotGeminiReplayToolArguments(payload: unknown, model: Pick<Model<Api>, "provider" | "api" | "id">): unknown;
42
+ //# sourceMappingURL=copilot-gemini-tool-arguments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copilot-gemini-tool-arguments.d.ts","sourceRoot":"","sources":["../../src/core/copilot-gemini-tool-arguments.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AA6FxD;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,CAUrF;AAED;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,OAAO,EACb,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,UAAU,GAAG,KAAK,GAAG,IAAI,CAAC,GAAG,SAAS,EAC9D,MAAM,CAAC,EAAE,OAAO,GACf,OAAO,CAGT;AAoBD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,yCAAyC,CACvD,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,UAAU,GAAG,KAAK,GAAG,IAAI,CAAC,GACjD,OAAO,CA2CT","sourcesContent":["import type { Api, Model } from \"@earendil-works/pi-ai\";\nimport { isCopilotGeminiModel } from \"./copilot-gemini-payload-sanitizer.ts\";\nimport { reconstructFlattenedKeys } from \"./flattened-tool-arguments.ts\";\n\n/**\n * Normalizes GitHub Copilot Gemini tool-call arguments.\n *\n * Why this exists\n * ---------------\n * `github-copilot` Gemini models are served through Copilot's CAPI gateway,\n * which proxies to Google's GenAI API. When a function/tool argument is an\n * array (or a nested object/array), Gemini serializes it on the wire as\n * **flattened, indexed keys** instead of a real JSON array/object. For example\n * a tool called with `{ keywords: [\"a\", \"b\"] }` arrives as:\n *\n * ```json\n * { \"keywords[0]\": \"a\", \"keywords[1]\": \"b\" }\n * ```\n *\n * This was confirmed by capturing the raw CAPI SSE stream: the\n * `tool_calls[].function.arguments` JSON itself contains the `name[index]`\n * keys, so the runtime parses valid-but-wrong JSON. Schema validation then\n * fails (`keywords: must have required properties keywords` and\n * `root: must not have additional properties`) and the model retries forever,\n * because it keeps re-emitting the same flattened shape. This is most visible\n * with the workflow `structured_output` tool but affects any Gemini tool call\n * whose schema contains an array or nested object.\n *\n * What it does\n * ------------\n * Reconstructs flattened keys (`name[i]`, `name[i].sub`, `parent.child`) back\n * into the intended nested arrays/objects, before tool-argument validation\n * runs. Bracket-indexed keys (`name[<digit>]`) are always reconstructed. A\n * purely dotted key (`parent.child`, with no array anywhere) is ambiguous —\n * a legitimate argument key can itself contain a dot — so it is only split when\n * the optional tool `schema` marks its head segment as an object/array\n * container property. The transform is gated to GitHub Copilot Gemini models,\n * so it never touches well-formed arguments from any other provider/model.\n */\n\ntype JsonRecord = Record<string, unknown>;\n\nfunction isPlainObject(value: unknown): value is JsonRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** A flattened key contains a bracket index like `foo[0]`. */\nfunction hasFlattenedKey(keys: string[]): boolean {\n return keys.some((key) => /\\[\\d+\\]/.test(key));\n}\n\n/** A schema node that holds a nested object/array (so dotted keys are real paths). */\nfunction isContainerSchema(schema: unknown): boolean {\n if (!isPlainObject(schema)) return false;\n if (schema.type === \"object\" || schema.type === \"array\") return true;\n if (\"properties\" in schema || \"items\" in schema) return true;\n const union = schema.anyOf ?? schema.oneOf;\n if (Array.isArray(union)) return union.some((branch) => isContainerSchema(branch));\n return false;\n}\n\n/** Top-level property names whose schema is an object/array container. */\nfunction containerPropertyNames(schema: unknown): Set<string> {\n const names = new Set<string>();\n if (!isPlainObject(schema)) return names;\n const properties = schema.properties;\n if (!isPlainObject(properties)) return names;\n for (const [name, sub] of Object.entries(properties)) {\n if (isContainerSchema(sub)) names.add(name);\n }\n return names;\n}\n\n/** Whether `key` is a pure dotted path (`parent.child`) headed by a container prop. */\nfunction isDottedContainerKey(key: string, containers: Set<string>): boolean {\n const dot = key.indexOf(\".\");\n if (dot <= 0) return false;\n return containers.has(key.slice(0, dot));\n}\n\n/**\n * Decide whether a flattened key should be split into nested path segments.\n * Bracket-indexed keys always split. When a bracket key is present anywhere in\n * the payload, dotted keys split too (they are part of the same flattened\n * object). Otherwise a dotted key only splits when the schema marks its head as\n * a container property, which keeps legitimate dot-containing keys intact.\n */\nfunction shouldSplitKey(key: string, hasBracket: boolean, containers: Set<string>): boolean {\n if (/\\[\\d+\\]/.test(key)) return true;\n if (hasBracket) return true;\n return isDottedContainerKey(key, containers);\n}\n\n/**\n * Reconstruct flattened Gemini tool-call arguments into proper nested\n * arrays/objects. Returns the original reference unchanged when there is nothing\n * to reconstruct. Bracket-indexed keys are always reconstructed; purely dotted\n * keys are reconstructed only when the optional `schema` marks their head\n * segment as an object/array container property. Reconstruction (and its\n * prototype-pollution guard) is delegated to the shared canonical helper.\n */\nexport function unflattenGeminiToolArguments(args: unknown, schema?: unknown): unknown {\n if (!isPlainObject(args)) return args;\n const keys = Object.keys(args);\n const hasBracket = hasFlattenedKey(keys);\n const containers = hasBracket ? new Set<string>() : containerPropertyNames(schema);\n const hasDottedContainer =\n !hasBracket && keys.some((key) => isDottedContainerKey(key, containers));\n if (!hasBracket && !hasDottedContainer) return args;\n\n return reconstructFlattenedKeys(args, (key) => shouldSplitKey(key, hasBracket, containers));\n}\n\n/**\n * If `model` is a GitHub Copilot Gemini model, normalize flattened tool-call\n * arguments; otherwise return them unchanged. Used to gate\n * {@link unflattenGeminiToolArguments} by model at tool-call time. The optional\n * `schema` is the tool's parameter schema, used to disambiguate dotted keys.\n */\nexport function normalizeToolArgumentsForModel(\n args: unknown,\n model: Pick<Model<Api>, \"provider\" | \"api\" | \"id\"> | undefined,\n schema?: unknown,\n): unknown {\n if (!model || !isCopilotGeminiModel(model)) return args;\n return unflattenGeminiToolArguments(args, schema);\n}\n\n/** Map each tool name in an OpenAI chat-completions payload to its parameter schema. */\nfunction toolParameterSchemas(tools: unknown): Map<string, unknown> {\n const schemas = new Map<string, unknown>();\n if (!Array.isArray(tools)) return schemas;\n for (const tool of tools) {\n if (!isPlainObject(tool)) continue;\n // OpenAI chat-completions tool shape: { type: \"function\", function: { name, parameters } }.\n const fn = tool.function;\n if (isPlainObject(fn) && typeof fn.name === \"string\") {\n schemas.set(fn.name, fn.parameters);\n continue;\n }\n // Defensive: flat tool shape { name, parameters }.\n if (typeof tool.name === \"string\") schemas.set(tool.name, tool.parameters);\n }\n return schemas;\n}\n\n/**\n * Reconstruct flattened GitHub Copilot Gemini tool-call arguments on the\n * **outbound replay payload**, so prior assistant tool calls are sent back to\n * CAPI in the nested array/object shape Gemini originally produced.\n *\n * Why this exists\n * ---------------\n * {@link normalizeToolArgumentsForModel} only unflattens at tool *execution*\n * time; the persisted assistant message keeps the raw flattened arguments CAPI\n * delivered (for example `{ \"edits[0].newText\": \"...\" }`). When that message is\n * replayed on the next turn, CAPI parses those literal keys straight into the\n * Gemini `FunctionCall.Args`, producing a function call that does not match the\n * tool's declared schema (nor the structure Gemini signed). Gemini then ends\n * the turn with `MALFORMED_FUNCTION_CALL` / `UNEXPECTED_TOOL_CALL` / `OTHER`,\n * which CAPI surfaces as a bare `finish_reason: \"error\"` — so multi-turn tool\n * use dies one turn after any array/object tool call (such as `edit`).\n *\n * This rewrites each replayed assistant `tool_calls[].function.arguments` JSON\n * into the reconstructed nested shape (reusing {@link unflattenGeminiToolArguments}\n * with the tool's own parameter schema, looked up from the payload's `tools`),\n * fixing both new and already-persisted sessions. Gated to GitHub Copilot Gemini\n * models, fail-open on non-JSON arguments, and a no-op for well-formed args.\n */\nexport function normalizeCopilotGeminiReplayToolArguments(\n payload: unknown,\n model: Pick<Model<Api>, \"provider\" | \"api\" | \"id\">,\n): unknown {\n if (!isCopilotGeminiModel(model)) return payload;\n if (!isPlainObject(payload)) return payload;\n const messages = payload.messages;\n if (!Array.isArray(messages)) return payload;\n\n const schemas = toolParameterSchemas(payload.tools);\n let mutated = false;\n\n const nextMessages = messages.map((message) => {\n if (!isPlainObject(message) || message.role !== \"assistant\") return message;\n const toolCalls = message.tool_calls;\n if (!Array.isArray(toolCalls) || toolCalls.length === 0) return message;\n\n let messageMutated = false;\n const nextToolCalls = toolCalls.map((toolCall) => {\n if (!isPlainObject(toolCall)) return toolCall;\n const fn = toolCall.function;\n if (!isPlainObject(fn) || typeof fn.arguments !== \"string\") return toolCall;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(fn.arguments);\n } catch {\n return toolCall; // fail open: never corrupt a replayed argument string\n }\n if (!isPlainObject(parsed)) return toolCall;\n\n const schema = typeof fn.name === \"string\" ? schemas.get(fn.name) : undefined;\n const reconstructed = unflattenGeminiToolArguments(parsed, schema);\n if (reconstructed === parsed) return toolCall;\n\n messageMutated = true;\n return { ...toolCall, function: { ...fn, arguments: JSON.stringify(reconstructed) } };\n });\n\n if (!messageMutated) return message;\n mutated = true;\n return { ...message, tool_calls: nextToolCalls };\n });\n\n if (!mutated) return payload;\n return { ...payload, messages: nextMessages };\n}\n"]}
@@ -0,0 +1,179 @@
1
+ import { isCopilotGeminiModel } from "./copilot-gemini-payload-sanitizer.js";
2
+ import { reconstructFlattenedKeys } from "./flattened-tool-arguments.js";
3
+ function isPlainObject(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ /** A flattened key contains a bracket index like `foo[0]`. */
7
+ function hasFlattenedKey(keys) {
8
+ return keys.some((key) => /\[\d+\]/.test(key));
9
+ }
10
+ /** A schema node that holds a nested object/array (so dotted keys are real paths). */
11
+ function isContainerSchema(schema) {
12
+ if (!isPlainObject(schema))
13
+ return false;
14
+ if (schema.type === "object" || schema.type === "array")
15
+ return true;
16
+ if ("properties" in schema || "items" in schema)
17
+ return true;
18
+ const union = schema.anyOf ?? schema.oneOf;
19
+ if (Array.isArray(union))
20
+ return union.some((branch) => isContainerSchema(branch));
21
+ return false;
22
+ }
23
+ /** Top-level property names whose schema is an object/array container. */
24
+ function containerPropertyNames(schema) {
25
+ const names = new Set();
26
+ if (!isPlainObject(schema))
27
+ return names;
28
+ const properties = schema.properties;
29
+ if (!isPlainObject(properties))
30
+ return names;
31
+ for (const [name, sub] of Object.entries(properties)) {
32
+ if (isContainerSchema(sub))
33
+ names.add(name);
34
+ }
35
+ return names;
36
+ }
37
+ /** Whether `key` is a pure dotted path (`parent.child`) headed by a container prop. */
38
+ function isDottedContainerKey(key, containers) {
39
+ const dot = key.indexOf(".");
40
+ if (dot <= 0)
41
+ return false;
42
+ return containers.has(key.slice(0, dot));
43
+ }
44
+ /**
45
+ * Decide whether a flattened key should be split into nested path segments.
46
+ * Bracket-indexed keys always split. When a bracket key is present anywhere in
47
+ * the payload, dotted keys split too (they are part of the same flattened
48
+ * object). Otherwise a dotted key only splits when the schema marks its head as
49
+ * a container property, which keeps legitimate dot-containing keys intact.
50
+ */
51
+ function shouldSplitKey(key, hasBracket, containers) {
52
+ if (/\[\d+\]/.test(key))
53
+ return true;
54
+ if (hasBracket)
55
+ return true;
56
+ return isDottedContainerKey(key, containers);
57
+ }
58
+ /**
59
+ * Reconstruct flattened Gemini tool-call arguments into proper nested
60
+ * arrays/objects. Returns the original reference unchanged when there is nothing
61
+ * to reconstruct. Bracket-indexed keys are always reconstructed; purely dotted
62
+ * keys are reconstructed only when the optional `schema` marks their head
63
+ * segment as an object/array container property. Reconstruction (and its
64
+ * prototype-pollution guard) is delegated to the shared canonical helper.
65
+ */
66
+ export function unflattenGeminiToolArguments(args, schema) {
67
+ if (!isPlainObject(args))
68
+ return args;
69
+ const keys = Object.keys(args);
70
+ const hasBracket = hasFlattenedKey(keys);
71
+ const containers = hasBracket ? new Set() : containerPropertyNames(schema);
72
+ const hasDottedContainer = !hasBracket && keys.some((key) => isDottedContainerKey(key, containers));
73
+ if (!hasBracket && !hasDottedContainer)
74
+ return args;
75
+ return reconstructFlattenedKeys(args, (key) => shouldSplitKey(key, hasBracket, containers));
76
+ }
77
+ /**
78
+ * If `model` is a GitHub Copilot Gemini model, normalize flattened tool-call
79
+ * arguments; otherwise return them unchanged. Used to gate
80
+ * {@link unflattenGeminiToolArguments} by model at tool-call time. The optional
81
+ * `schema` is the tool's parameter schema, used to disambiguate dotted keys.
82
+ */
83
+ export function normalizeToolArgumentsForModel(args, model, schema) {
84
+ if (!model || !isCopilotGeminiModel(model))
85
+ return args;
86
+ return unflattenGeminiToolArguments(args, schema);
87
+ }
88
+ /** Map each tool name in an OpenAI chat-completions payload to its parameter schema. */
89
+ function toolParameterSchemas(tools) {
90
+ const schemas = new Map();
91
+ if (!Array.isArray(tools))
92
+ return schemas;
93
+ for (const tool of tools) {
94
+ if (!isPlainObject(tool))
95
+ continue;
96
+ // OpenAI chat-completions tool shape: { type: "function", function: { name, parameters } }.
97
+ const fn = tool.function;
98
+ if (isPlainObject(fn) && typeof fn.name === "string") {
99
+ schemas.set(fn.name, fn.parameters);
100
+ continue;
101
+ }
102
+ // Defensive: flat tool shape { name, parameters }.
103
+ if (typeof tool.name === "string")
104
+ schemas.set(tool.name, tool.parameters);
105
+ }
106
+ return schemas;
107
+ }
108
+ /**
109
+ * Reconstruct flattened GitHub Copilot Gemini tool-call arguments on the
110
+ * **outbound replay payload**, so prior assistant tool calls are sent back to
111
+ * CAPI in the nested array/object shape Gemini originally produced.
112
+ *
113
+ * Why this exists
114
+ * ---------------
115
+ * {@link normalizeToolArgumentsForModel} only unflattens at tool *execution*
116
+ * time; the persisted assistant message keeps the raw flattened arguments CAPI
117
+ * delivered (for example `{ "edits[0].newText": "..." }`). When that message is
118
+ * replayed on the next turn, CAPI parses those literal keys straight into the
119
+ * Gemini `FunctionCall.Args`, producing a function call that does not match the
120
+ * tool's declared schema (nor the structure Gemini signed). Gemini then ends
121
+ * the turn with `MALFORMED_FUNCTION_CALL` / `UNEXPECTED_TOOL_CALL` / `OTHER`,
122
+ * which CAPI surfaces as a bare `finish_reason: "error"` — so multi-turn tool
123
+ * use dies one turn after any array/object tool call (such as `edit`).
124
+ *
125
+ * This rewrites each replayed assistant `tool_calls[].function.arguments` JSON
126
+ * into the reconstructed nested shape (reusing {@link unflattenGeminiToolArguments}
127
+ * with the tool's own parameter schema, looked up from the payload's `tools`),
128
+ * fixing both new and already-persisted sessions. Gated to GitHub Copilot Gemini
129
+ * models, fail-open on non-JSON arguments, and a no-op for well-formed args.
130
+ */
131
+ export function normalizeCopilotGeminiReplayToolArguments(payload, model) {
132
+ if (!isCopilotGeminiModel(model))
133
+ return payload;
134
+ if (!isPlainObject(payload))
135
+ return payload;
136
+ const messages = payload.messages;
137
+ if (!Array.isArray(messages))
138
+ return payload;
139
+ const schemas = toolParameterSchemas(payload.tools);
140
+ let mutated = false;
141
+ const nextMessages = messages.map((message) => {
142
+ if (!isPlainObject(message) || message.role !== "assistant")
143
+ return message;
144
+ const toolCalls = message.tool_calls;
145
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0)
146
+ return message;
147
+ let messageMutated = false;
148
+ const nextToolCalls = toolCalls.map((toolCall) => {
149
+ if (!isPlainObject(toolCall))
150
+ return toolCall;
151
+ const fn = toolCall.function;
152
+ if (!isPlainObject(fn) || typeof fn.arguments !== "string")
153
+ return toolCall;
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(fn.arguments);
157
+ }
158
+ catch {
159
+ return toolCall; // fail open: never corrupt a replayed argument string
160
+ }
161
+ if (!isPlainObject(parsed))
162
+ return toolCall;
163
+ const schema = typeof fn.name === "string" ? schemas.get(fn.name) : undefined;
164
+ const reconstructed = unflattenGeminiToolArguments(parsed, schema);
165
+ if (reconstructed === parsed)
166
+ return toolCall;
167
+ messageMutated = true;
168
+ return { ...toolCall, function: { ...fn, arguments: JSON.stringify(reconstructed) } };
169
+ });
170
+ if (!messageMutated)
171
+ return message;
172
+ mutated = true;
173
+ return { ...message, tool_calls: nextToolCalls };
174
+ });
175
+ if (!mutated)
176
+ return payload;
177
+ return { ...payload, messages: nextMessages };
178
+ }
179
+ //# sourceMappingURL=copilot-gemini-tool-arguments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copilot-gemini-tool-arguments.js","sourceRoot":"","sources":["../../src/core/copilot-gemini-tool-arguments.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Api, Model } from \"@earendil-works/pi-ai\";\nimport { isCopilotGeminiModel } from \"./copilot-gemini-payload-sanitizer.ts\";\nimport { reconstructFlattenedKeys } from \"./flattened-tool-arguments.ts\";\n\n/**\n * Normalizes GitHub Copilot Gemini tool-call arguments.\n *\n * Why this exists\n * ---------------\n * `github-copilot` Gemini models are served through Copilot's CAPI gateway,\n * which proxies to Google's GenAI API. When a function/tool argument is an\n * array (or a nested object/array), Gemini serializes it on the wire as\n * **flattened, indexed keys** instead of a real JSON array/object. For example\n * a tool called with `{ keywords: [\"a\", \"b\"] }` arrives as:\n *\n * ```json\n * { \"keywords[0]\": \"a\", \"keywords[1]\": \"b\" }\n * ```\n *\n * This was confirmed by capturing the raw CAPI SSE stream: the\n * `tool_calls[].function.arguments` JSON itself contains the `name[index]`\n * keys, so the runtime parses valid-but-wrong JSON. Schema validation then\n * fails (`keywords: must have required properties keywords` and\n * `root: must not have additional properties`) and the model retries forever,\n * because it keeps re-emitting the same flattened shape. This is most visible\n * with the workflow `structured_output` tool but affects any Gemini tool call\n * whose schema contains an array or nested object.\n *\n * What it does\n * ------------\n * Reconstructs flattened keys (`name[i]`, `name[i].sub`, `parent.child`) back\n * into the intended nested arrays/objects, before tool-argument validation\n * runs. Bracket-indexed keys (`name[<digit>]`) are always reconstructed. A\n * purely dotted key (`parent.child`, with no array anywhere) is ambiguous —\n * a legitimate argument key can itself contain a dot — so it is only split when\n * the optional tool `schema` marks its head segment as an object/array\n * container property. The transform is gated to GitHub Copilot Gemini models,\n * so it never touches well-formed arguments from any other provider/model.\n */\n\ntype JsonRecord = Record<string, unknown>;\n\nfunction isPlainObject(value: unknown): value is JsonRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** A flattened key contains a bracket index like `foo[0]`. */\nfunction hasFlattenedKey(keys: string[]): boolean {\n return keys.some((key) => /\\[\\d+\\]/.test(key));\n}\n\n/** A schema node that holds a nested object/array (so dotted keys are real paths). */\nfunction isContainerSchema(schema: unknown): boolean {\n if (!isPlainObject(schema)) return false;\n if (schema.type === \"object\" || schema.type === \"array\") return true;\n if (\"properties\" in schema || \"items\" in schema) return true;\n const union = schema.anyOf ?? schema.oneOf;\n if (Array.isArray(union)) return union.some((branch) => isContainerSchema(branch));\n return false;\n}\n\n/** Top-level property names whose schema is an object/array container. */\nfunction containerPropertyNames(schema: unknown): Set<string> {\n const names = new Set<string>();\n if (!isPlainObject(schema)) return names;\n const properties = schema.properties;\n if (!isPlainObject(properties)) return names;\n for (const [name, sub] of Object.entries(properties)) {\n if (isContainerSchema(sub)) names.add(name);\n }\n return names;\n}\n\n/** Whether `key` is a pure dotted path (`parent.child`) headed by a container prop. */\nfunction isDottedContainerKey(key: string, containers: Set<string>): boolean {\n const dot = key.indexOf(\".\");\n if (dot <= 0) return false;\n return containers.has(key.slice(0, dot));\n}\n\n/**\n * Decide whether a flattened key should be split into nested path segments.\n * Bracket-indexed keys always split. When a bracket key is present anywhere in\n * the payload, dotted keys split too (they are part of the same flattened\n * object). Otherwise a dotted key only splits when the schema marks its head as\n * a container property, which keeps legitimate dot-containing keys intact.\n */\nfunction shouldSplitKey(key: string, hasBracket: boolean, containers: Set<string>): boolean {\n if (/\\[\\d+\\]/.test(key)) return true;\n if (hasBracket) return true;\n return isDottedContainerKey(key, containers);\n}\n\n/**\n * Reconstruct flattened Gemini tool-call arguments into proper nested\n * arrays/objects. Returns the original reference unchanged when there is nothing\n * to reconstruct. Bracket-indexed keys are always reconstructed; purely dotted\n * keys are reconstructed only when the optional `schema` marks their head\n * segment as an object/array container property. Reconstruction (and its\n * prototype-pollution guard) is delegated to the shared canonical helper.\n */\nexport function unflattenGeminiToolArguments(args: unknown, schema?: unknown): unknown {\n if (!isPlainObject(args)) return args;\n const keys = Object.keys(args);\n const hasBracket = hasFlattenedKey(keys);\n const containers = hasBracket ? new Set<string>() : containerPropertyNames(schema);\n const hasDottedContainer =\n !hasBracket && keys.some((key) => isDottedContainerKey(key, containers));\n if (!hasBracket && !hasDottedContainer) return args;\n\n return reconstructFlattenedKeys(args, (key) => shouldSplitKey(key, hasBracket, containers));\n}\n\n/**\n * If `model` is a GitHub Copilot Gemini model, normalize flattened tool-call\n * arguments; otherwise return them unchanged. Used to gate\n * {@link unflattenGeminiToolArguments} by model at tool-call time. The optional\n * `schema` is the tool's parameter schema, used to disambiguate dotted keys.\n */\nexport function normalizeToolArgumentsForModel(\n args: unknown,\n model: Pick<Model<Api>, \"provider\" | \"api\" | \"id\"> | undefined,\n schema?: unknown,\n): unknown {\n if (!model || !isCopilotGeminiModel(model)) return args;\n return unflattenGeminiToolArguments(args, schema);\n}\n\n/** Map each tool name in an OpenAI chat-completions payload to its parameter schema. */\nfunction toolParameterSchemas(tools: unknown): Map<string, unknown> {\n const schemas = new Map<string, unknown>();\n if (!Array.isArray(tools)) return schemas;\n for (const tool of tools) {\n if (!isPlainObject(tool)) continue;\n // OpenAI chat-completions tool shape: { type: \"function\", function: { name, parameters } }.\n const fn = tool.function;\n if (isPlainObject(fn) && typeof fn.name === \"string\") {\n schemas.set(fn.name, fn.parameters);\n continue;\n }\n // Defensive: flat tool shape { name, parameters }.\n if (typeof tool.name === \"string\") schemas.set(tool.name, tool.parameters);\n }\n return schemas;\n}\n\n/**\n * Reconstruct flattened GitHub Copilot Gemini tool-call arguments on the\n * **outbound replay payload**, so prior assistant tool calls are sent back to\n * CAPI in the nested array/object shape Gemini originally produced.\n *\n * Why this exists\n * ---------------\n * {@link normalizeToolArgumentsForModel} only unflattens at tool *execution*\n * time; the persisted assistant message keeps the raw flattened arguments CAPI\n * delivered (for example `{ \"edits[0].newText\": \"...\" }`). When that message is\n * replayed on the next turn, CAPI parses those literal keys straight into the\n * Gemini `FunctionCall.Args`, producing a function call that does not match the\n * tool's declared schema (nor the structure Gemini signed). Gemini then ends\n * the turn with `MALFORMED_FUNCTION_CALL` / `UNEXPECTED_TOOL_CALL` / `OTHER`,\n * which CAPI surfaces as a bare `finish_reason: \"error\"` — so multi-turn tool\n * use dies one turn after any array/object tool call (such as `edit`).\n *\n * This rewrites each replayed assistant `tool_calls[].function.arguments` JSON\n * into the reconstructed nested shape (reusing {@link unflattenGeminiToolArguments}\n * with the tool's own parameter schema, looked up from the payload's `tools`),\n * fixing both new and already-persisted sessions. Gated to GitHub Copilot Gemini\n * models, fail-open on non-JSON arguments, and a no-op for well-formed args.\n */\nexport function normalizeCopilotGeminiReplayToolArguments(\n payload: unknown,\n model: Pick<Model<Api>, \"provider\" | \"api\" | \"id\">,\n): unknown {\n if (!isCopilotGeminiModel(model)) return payload;\n if (!isPlainObject(payload)) return payload;\n const messages = payload.messages;\n if (!Array.isArray(messages)) return payload;\n\n const schemas = toolParameterSchemas(payload.tools);\n let mutated = false;\n\n const nextMessages = messages.map((message) => {\n if (!isPlainObject(message) || message.role !== \"assistant\") return message;\n const toolCalls = message.tool_calls;\n if (!Array.isArray(toolCalls) || toolCalls.length === 0) return message;\n\n let messageMutated = false;\n const nextToolCalls = toolCalls.map((toolCall) => {\n if (!isPlainObject(toolCall)) return toolCall;\n const fn = toolCall.function;\n if (!isPlainObject(fn) || typeof fn.arguments !== \"string\") return toolCall;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(fn.arguments);\n } catch {\n return toolCall; // fail open: never corrupt a replayed argument string\n }\n if (!isPlainObject(parsed)) return toolCall;\n\n const schema = typeof fn.name === \"string\" ? schemas.get(fn.name) : undefined;\n const reconstructed = unflattenGeminiToolArguments(parsed, schema);\n if (reconstructed === parsed) return toolCall;\n\n messageMutated = true;\n return { ...toolCall, function: { ...fn, arguments: JSON.stringify(reconstructed) } };\n });\n\n if (!messageMutated) return message;\n mutated = true;\n return { ...message, tool_calls: nextToolCalls };\n });\n\n if (!mutated) return payload;\n return { ...payload, messages: nextMessages };\n}\n"]}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Canonical reconstruction of flattened tool-call arguments.
3
+ *
4
+ * Some upstream providers — notably GitHub Copilot Gemini models proxied through
5
+ * Google's GenAI API — serialize array/object function-call arguments as
6
+ * flattened, indexed keys on the wire. For example a tool called with
7
+ * `{ keywords: ["a", "b"] }` arrives as `{ "keywords[0]": "a", "keywords[1]": "b" }`,
8
+ * and `{ files: [{ path }] }` as `{ "files[0].path": "..." }`.
9
+ *
10
+ * This module is the single source of truth for turning those flattened keys
11
+ * back into nested arrays/objects. Both the host runtime's per-tool
12
+ * normalization (gated to Copilot Gemini, schema-aware) and the MCP `callTool`
13
+ * boundary (provider-agnostic, bracket self-gating) delegate here so the two
14
+ * paths cannot drift — in particular so the prototype-pollution guard lives in
15
+ * exactly one place.
16
+ *
17
+ * Security: argument keys cross a trust boundary (model/provider wire → tool /
18
+ * MCP server validation). A key path that walks through `__proto__`,
19
+ * `constructor`, or `prototype` could otherwise reach `Object.prototype` and
20
+ * mutate it process-wide. Any key whose path contains such a segment — at any
21
+ * position, including the final segment and a literal plain key — is dropped.
22
+ */
23
+ /**
24
+ * Parse a flattened key such as `a.b[0].c` into path segments
25
+ * `["a", "b", 0, "c"]`. Returns `undefined` for a plain key with no `.`/`[`, or
26
+ * for a malformed bracket expression (left untouched by the caller).
27
+ */
28
+ export declare function parseFlattenedKeyPath(key: string): Array<string | number> | undefined;
29
+ /**
30
+ * Reconstruct (unflatten) flattened keys into nested arrays/objects — for
31
+ * example `"items[0]"` -> `{ items: [...] }` and `"parent.child"` ->
32
+ * `{ parent: { child: ... } }`. `shouldSplit` decides, per key, whether it is a
33
+ * flattened path (true) or an opaque literal key to be preserved (false);
34
+ * callers apply their own gating/schema logic there.
35
+ *
36
+ * Prototype-pollution safe: a key whose parsed path contains `__proto__`,
37
+ * `constructor`, or `prototype` (at any position) is dropped, as is a literal
38
+ * plain key equal to one of those names.
39
+ */
40
+ export declare function reconstructFlattenedKeys(args: Record<string, unknown>, shouldSplit: (key: string) => boolean): Record<string, unknown>;
41
+ //# sourceMappingURL=flattened-tool-arguments.d.ts.map