jeo-code 0.5.10 → 0.5.13

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.
package/src/agent/loop.ts CHANGED
@@ -21,6 +21,8 @@ export interface ChatOptions {
21
21
  onToken?: (delta: string) => void;
22
22
  /** Streaming sink for native reasoning/thinking deltas (drives the dimmed live view). */
23
23
  onReasoning?: (delta: string) => void;
24
+ /** NATIVE tool-calling function declarations (forwarded to capable adapters). */
25
+ tools?: import("../ai/types").NativeToolSchema[];
24
26
  }
25
27
 
26
28
  const manager = createModelManager();
@@ -0,0 +1,132 @@
1
+ import type { NativeToolSchema } from "../ai/types";
2
+
3
+ /**
4
+ * Native function-calling schemas for jeo's tools, keyed by canonical tool name.
5
+ *
6
+ * The `properties` keys MUST match the argument names the DEFAULT_TOOLS handlers read
7
+ * (engine.ts) EXACTLY — a renamed parameter would land in a key the handler ignores and
8
+ * silently no-op the call. The model fills an API-validated schema, so this registry is
9
+ * the single source of truth for argument names on the native path.
10
+ */
11
+ const STRING = { type: "string" } as const;
12
+
13
+ const SCHEMAS: Record<string, NativeToolSchema> = {
14
+ read: {
15
+ name: "read",
16
+ description: "Read a file. Optional lineRange ('a-b','a-','a','a+n','a-b,c-d'); raw=true skips line-number prefixes.",
17
+ parameters: {
18
+ type: "object",
19
+ properties: { filePath: STRING, lineRange: STRING, raw: { type: "boolean" } },
20
+ required: ["filePath"],
21
+ },
22
+ },
23
+ write: {
24
+ name: "write",
25
+ description: "Create or overwrite a file with the given content.",
26
+ parameters: { type: "object", properties: { filePath: STRING, content: STRING }, required: ["filePath", "content"] },
27
+ },
28
+ edit: {
29
+ name: "edit",
30
+ description: "Apply a line-anchored edit block to a file (≔A..B replace, ≔A+ insert after, ≔$ append).",
31
+ parameters: { type: "object", properties: { filePath: STRING, editBlock: STRING }, required: ["filePath", "editBlock"] },
32
+ },
33
+ bash: {
34
+ name: "bash",
35
+ description: "Run a shell command. Optional timeoutMs, cwd (subdir), env (extra vars).",
36
+ parameters: {
37
+ type: "object",
38
+ properties: { command: STRING, timeoutMs: { type: "number" }, cwd: STRING, env: { type: "object" } },
39
+ required: ["command"],
40
+ },
41
+ },
42
+ find: {
43
+ name: "find",
44
+ description: "Find files by glob pattern.",
45
+ parameters: { type: "object", properties: { globPattern: STRING }, required: ["globPattern"] },
46
+ },
47
+ search: {
48
+ name: "search",
49
+ description: "Search file contents by regex (grep). Optional globPattern, ignoreCase, context, maxMatches.",
50
+ parameters: {
51
+ type: "object",
52
+ properties: {
53
+ pattern: STRING,
54
+ globPattern: STRING,
55
+ ignoreCase: { type: "boolean" },
56
+ context: { type: "number" },
57
+ maxMatches: { type: "number" },
58
+ },
59
+ required: ["pattern"],
60
+ },
61
+ },
62
+ ls: {
63
+ name: "ls",
64
+ description: "List a directory's entries (directories first).",
65
+ parameters: { type: "object", properties: { dirPath: STRING }, required: ["dirPath"] },
66
+ },
67
+ mkdir: {
68
+ name: "mkdir",
69
+ description: "Create a directory (parents included; idempotent).",
70
+ parameters: { type: "object", properties: { dirPath: STRING }, required: ["dirPath"] },
71
+ },
72
+ delete: {
73
+ name: "delete",
74
+ description: "Remove a file, or a directory when recursive=true.",
75
+ parameters: { type: "object", properties: { path: STRING, recursive: { type: "boolean" } }, required: ["path"] },
76
+ },
77
+ web_search: {
78
+ name: "web_search",
79
+ description: "Search the web (synthesized answer + sources + citations). Optional recency, limit.",
80
+ parameters: { type: "object", properties: { query: STRING, recency: STRING, limit: { type: "number" } }, required: ["query"] },
81
+ },
82
+ done: {
83
+ name: "done",
84
+ description: "Call when the task is fully implemented AND verified. The reason is shown to the user as your message.",
85
+ parameters: { type: "object", properties: { reason: STRING }, required: [] },
86
+ },
87
+ };
88
+
89
+ /**
90
+ * Build the native tool-schema list for the ACTIVE toolset. Pass the real tool names the
91
+ * turn is allowed to use (Object.keys of the engine's toolset); `done` is always appended
92
+ * so the model can signal completion natively. Read-only subagents therefore expose only
93
+ * their non-mutating tools — never write/edit/bash — on the native channel.
94
+ */
95
+ export function nativeToolSchemasFor(toolNames: Iterable<string>): NativeToolSchema[] {
96
+ const out: NativeToolSchema[] = [];
97
+ const seen = new Set<string>();
98
+ for (const name of toolNames) {
99
+ const schema = SCHEMAS[name];
100
+ if (schema && !seen.has(name)) {
101
+ out.push(schema);
102
+ seen.add(name);
103
+ }
104
+ }
105
+ if (!seen.has("done")) out.push(SCHEMAS.done!);
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Re-serialize parsed native tool calls into the engine's canonical JSON string. Coalesces
111
+ * a batched `done` to a single envelope (the engine rejects done-in-batch). Returns null
112
+ * when there are no calls. Shared by capable provider adapters (antigravity/openai/…).
113
+ */
114
+ export function serializeToolCalls(calls: { tool: string; arguments: Record<string, unknown> }[]): string | null {
115
+ // Gemini (antigravity) intermittently namespaces native functions under `default_api`
116
+ // (e.g. functionCall.name = "default_api.done" / "default_api:done") when handed raw
117
+ // functionDeclarations, which the engine then rejects as an unknown tool. Strip that
118
+ // namespace back to the bare tool name so the call dispatches normally.
119
+ const valid = calls
120
+ .map(c => ({ ...c, tool: normalizeNativeToolName(c.tool) }))
121
+ .filter(c => c.tool);
122
+ if (valid.length === 0) return null;
123
+ const done = valid.find(c => c.tool === "done");
124
+ if (done) return JSON.stringify(done);
125
+ if (valid.length === 1) return JSON.stringify(valid[0]);
126
+ return JSON.stringify({ tools: valid });
127
+ }
128
+
129
+ /** Strip the Gemini `default_api.` / `default_api:` namespace prefix from a tool name. */
130
+ export function normalizeNativeToolName(name: string): string {
131
+ return (name ?? "").replace(/^default_api\s*[.:]\s*/, "").trim();
132
+ }
@@ -787,9 +787,15 @@ export async function searchTool(
787
787
  try {
788
788
  const flags = ignoreCase ? "-rnIi" : "-rnI";
789
789
  const gi = await readGitignore(cwd);
790
+ // A gitignore glob like `.*` (or a bare `*`/`**`) is meant to skip dotfiles, but as a
791
+ // grep --exclude/--exclude-dir it matches the `./`-prefixed traversal paths and silently
792
+ // excludes EVERY file on BSD grep (the field bug: search returned "No matches found" for
793
+ // text that existed). Drop these all-matching globs — IGNORED_DIRS still covers the key
794
+ // dotdirs (.git/.jeo/.next/.cache), and find() is unaffected (it matches via -name).
795
+ const safeGlob = (g: string) => !/^\.?\*+$/.test(g);
790
796
  const excludes = [
791
- ...[...IGNORED_DIRS, ...gi.dirs].map(d => `--exclude-dir=${d}`),
792
- ...gi.fileGlobs.map(f => `--exclude=${f}`),
797
+ ...[...IGNORED_DIRS, ...gi.dirs.filter(safeGlob)].map(d => `--exclude-dir=${d}`),
798
+ ...gi.fileGlobs.filter(safeGlob).map(f => `--exclude=${f}`),
793
799
  ];
794
800
  const n = (v: unknown): number | undefined =>
795
801
  typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
@@ -306,6 +306,7 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
306
306
  signal: options.signal,
307
307
  reasoningEffort: options.reasoningEffort ?? thinkingToReasoningEffort(config.thinkingLevel),
308
308
  onReasoning: options.onReasoning,
309
+ tools: options.tools,
309
310
  };
310
311
  // Caller-supplied retry sink rides on the config-derived retry budget so the
311
312
  // engine/TUI can surface "rate limited — retrying in Ns" instead of a silent wait.
@@ -115,6 +115,13 @@ export function anthropicPayload(
115
115
  };
116
116
  if (credential.kind === "oauth") payload.metadata = { user_id: createClaudeCloakingUserId() };
117
117
  if (includeTemperature && options.temperature !== undefined) payload.temperature = options.temperature;
118
+ if (options.tools?.length) {
119
+ // NATIVE tool-calling: declare jeo's tools as Anthropic functions. tool_choice
120
+ // "auto" keeps prose-salvage reachable and lets the model call `done` (declared as
121
+ // a tool) — never "required", which would kill the plain-text final-answer path.
122
+ payload.tools = options.tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters }));
123
+ payload.tool_choice = { type: "auto" };
124
+ }
118
125
  if (stream) payload.stream = true;
119
126
  const system = anthropicSystemBlocks(systemPrompt, model, credential, payload);
120
127
  if (system) payload.system = system;
@@ -190,12 +197,36 @@ function emptyCompletionError(stopReason: string | undefined): Error {
190
197
  : "";
191
198
  return new Error(`Anthropic returned no content${stopReason ? ` (stop_reason=${stopReason})` : ""}${hint}.`);
192
199
  }
200
+
201
+ /**
202
+ * Re-serialize Anthropic native `tool_use` content block(s) into the engine's canonical
203
+ * JSON string — the linchpin of the adapter-internal-serialization design: the engine,
204
+ * anti-spin guards, and done-gate keep consuming the SAME {"tool":...}/{"tools":[...]}
205
+ * shape they parse from the JSON-in-prose path. A batched `done` is coalesced to a single
206
+ * done envelope (the engine rejects done-in-batch). Returns null when there is no tool_use.
207
+ */
208
+ function serializeAnthropicToolUse(
209
+ content: { type: string; name?: string; input?: unknown }[],
210
+ ): string | null {
211
+ const calls = content
212
+ .filter(c => c.type === "tool_use" && typeof c.name === "string")
213
+ .map(c => ({ tool: c.name as string, arguments: (c.input ?? {}) as Record<string, unknown> }));
214
+ if (calls.length === 0) return null;
215
+ const done = calls.find(c => c.tool === "done");
216
+ if (done) return JSON.stringify(done);
217
+ if (calls.length === 1) return JSON.stringify(calls[0]);
218
+ return JSON.stringify({ tools: calls });
219
+ }
193
220
  export const anthropicAdapter: ProviderAdapter = {
194
221
  name: "anthropic",
222
+ supportsNativeTools: true,
195
223
  async call(messages, options, credential) {
196
224
  const response = await postAnthropic(messages, options, credential, false);
197
- const result = (await response.json()) as { content: { type: string; text: string }[]; stop_reason?: string; usage?: AnthropicUsage };
225
+ const result = (await response.json()) as { content: { type: string; text?: string; name?: string; input?: unknown }[]; stop_reason?: string; usage?: AnthropicUsage };
198
226
  if (result.usage) options.onUsage?.({ inputTokens: totalInputTokens(result.usage), outputTokens: result.usage.output_tokens });
227
+ // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
228
+ const toolCall = serializeAnthropicToolUse(result.content);
229
+ if (toolCall) return toolCall;
199
230
  const text = result.content.find(c => c.type === "text")?.text ?? "";
200
231
  if (!text) throw emptyCompletionError(result.stop_reason);
201
232
  return text;
@@ -206,10 +237,16 @@ export const anthropicAdapter: ProviderAdapter = {
206
237
  let cachedInput: number | undefined;
207
238
  let yieldedAny = false;
208
239
  let stopReason: string | undefined;
240
+ // Native tool_use streams as content_block_start (name) + input_json_delta fragments,
241
+ // never as text_delta — accumulate per block index, then re-serialize to canonical
242
+ // JSON and yield it once at the end (concatenation still equals call()).
243
+ const toolBlocks = new Map<number, { name: string; json: string }>();
209
244
  for await (const data of readSse(response.body)) {
210
245
  let evt: {
211
246
  type?: string;
212
- delta?: { type?: string; text?: string; stop_reason?: string };
247
+ index?: number;
248
+ content_block?: { type?: string; name?: string };
249
+ delta?: { type?: string; text?: string; partial_json?: string; stop_reason?: string };
213
250
  message?: { usage?: AnthropicUsage };
214
251
  usage?: { output_tokens?: number };
215
252
  };
@@ -218,7 +255,12 @@ export const anthropicAdapter: ProviderAdapter = {
218
255
  } catch {
219
256
  continue;
220
257
  }
221
- if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta" && evt.delta.text) {
258
+ if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use" && typeof evt.index === "number") {
259
+ toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", json: "" });
260
+ } else if (evt.type === "content_block_delta" && evt.delta?.type === "input_json_delta" && typeof evt.index === "number") {
261
+ const b = toolBlocks.get(evt.index);
262
+ if (b) b.json += evt.delta.partial_json ?? "";
263
+ } else if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta" && evt.delta.text) {
222
264
  yieldedAny = true;
223
265
  yield evt.delta.text;
224
266
  } else if (evt.type === "message_start" && evt.message?.usage) {
@@ -231,6 +273,21 @@ export const anthropicAdapter: ProviderAdapter = {
231
273
  if (evt.usage) options.onUsage?.({ inputTokens: cachedInput, outputTokens: evt.usage.output_tokens });
232
274
  }
233
275
  }
276
+ if (toolBlocks.size > 0) {
277
+ const calls = [...toolBlocks.values()]
278
+ .map(b => {
279
+ let args: Record<string, unknown> = {};
280
+ try { args = b.json ? JSON.parse(b.json) : {}; } catch { args = {}; }
281
+ return { tool: b.name, arguments: args };
282
+ })
283
+ .filter(c => c.tool);
284
+ if (calls.length > 0) {
285
+ const done = calls.find(c => c.tool === "done");
286
+ const envelope = done ? JSON.stringify(done) : calls.length === 1 ? JSON.stringify(calls[0]) : JSON.stringify({ tools: calls });
287
+ yieldedAny = true;
288
+ yield envelope;
289
+ }
290
+ }
234
291
  if (!yieldedAny) throw emptyCompletionError(stopReason);
235
292
  },
236
293
  };
@@ -3,6 +3,7 @@ import type { Credential } from "../../auth";
3
3
  import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
5
  import { providerHttpError } from "./errors";
6
+ import { serializeToolCalls } from "../../agent/tool-schemas";
6
7
 
7
8
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
8
9
  const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
@@ -136,6 +137,12 @@ export function antigravityRequest(messages: Message[], options: CallOptions, cr
136
137
  };
137
138
  if (systemPrompt) request.systemInstruction = { role: "user", parts: [{ text: systemPrompt }] };
138
139
  if (Object.keys(generationConfig).length > 0) request.generationConfig = generationConfig;
140
+ if (options.tools?.length) {
141
+ // NATIVE tool-calling: Gemini functionDeclarations through the CCA proxy. AUTO mode
142
+ // keeps prose answers + the `done` tool both reachable.
143
+ request.tools = [{ functionDeclarations: options.tools.map(t => ({ name: t.name, description: t.description, parameters: t.parameters })) }];
144
+ request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
145
+ }
139
146
 
140
147
  const body = JSON.stringify({
141
148
  project,
@@ -160,7 +167,7 @@ export function antigravityRequest(messages: Message[], options: CallOptions, cr
160
167
  type CcaUsage = { promptTokenCount?: number; candidatesTokenCount?: number; thoughtsTokenCount?: number };
161
168
  interface CcaChunk {
162
169
  response?: {
163
- candidates?: { content?: { parts?: { text?: string; thought?: boolean }[] }; finishReason?: string }[];
170
+ candidates?: { content?: { parts?: { text?: string; thought?: boolean; functionCall?: { name?: string; args?: Record<string, unknown> } }[] }; finishReason?: string }[];
164
171
  usageMetadata?: CcaUsage;
165
172
  };
166
173
  }
@@ -174,6 +181,18 @@ function thoughtOf(chunk: CcaChunk): string {
174
181
  return chunk.response?.candidates?.[0]?.content?.parts?.filter(p => p.thought).map(p => p.text ?? "").join("") ?? "";
175
182
  }
176
183
 
184
+ /** Native Gemini functionCall parts (Cloud Code Assist) → {tool, arguments}. */
185
+ function functionCallsOf(chunk: CcaChunk): { tool: string; arguments: Record<string, unknown> }[] {
186
+ const parts = chunk.response?.candidates?.[0]?.content?.parts ?? [];
187
+ const out: { tool: string; arguments: Record<string, unknown> }[] = [];
188
+ for (const p of parts) {
189
+ if (p.functionCall && typeof p.functionCall.name === "string") {
190
+ out.push({ tool: p.functionCall.name, arguments: (p.functionCall.args ?? {}) as Record<string, unknown> });
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
177
196
  async function fetchAntigravity(messages: Message[], options: CallOptions, credential: Credential): Promise<Response> {
178
197
  // Resolve the project id up front: stored credential → env → lazy
179
198
  // loadCodeAssist/onboardUser discovery (persisted for future sessions).
@@ -191,20 +210,26 @@ async function fetchAntigravity(messages: Message[], options: CallOptions, crede
191
210
 
192
211
  export const antigravityAdapter: ProviderAdapter = {
193
212
  name: "antigravity",
213
+ supportsNativeTools: true,
194
214
  async call(messages, options, credential) {
195
215
  const response = await fetchAntigravity(messages, options, credential);
196
216
  if (!response.body) return "";
197
217
  let out = "";
198
218
  let usage: CcaUsage | undefined;
219
+ const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
199
220
  for await (const data of readSse(response.body)) {
200
221
  let chunk: CcaChunk;
201
222
  try { chunk = JSON.parse(data); } catch { continue; }
202
223
  const thought = thoughtOf(chunk);
203
224
  if (thought) options.onReasoning?.(thought);
204
225
  out += textOf(chunk);
226
+ fnCalls.push(...functionCallsOf(chunk));
205
227
  if (chunk.response?.usageMetadata) usage = chunk.response.usageMetadata;
206
228
  }
207
229
  if (usage) options.onUsage?.({ inputTokens: usage.promptTokenCount, outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0) });
230
+ // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
231
+ const envelope = serializeToolCalls(fnCalls);
232
+ if (envelope) return envelope;
208
233
  if (!out) throw new Error("Antigravity Cloud Code Assist returned an empty response.");
209
234
  return out;
210
235
  },
@@ -213,6 +238,7 @@ export const antigravityAdapter: ProviderAdapter = {
213
238
  if (!response.body) return;
214
239
  let yielded = false;
215
240
  let usage: CcaUsage | undefined;
241
+ const fnCalls: { tool: string; arguments: Record<string, unknown> }[] = [];
216
242
  for await (const data of readSse(response.body)) {
217
243
  let chunk: CcaChunk;
218
244
  try { chunk = JSON.parse(data); } catch { continue; }
@@ -220,9 +246,13 @@ export const antigravityAdapter: ProviderAdapter = {
220
246
  if (thought) options.onReasoning?.(thought);
221
247
  const delta = textOf(chunk);
222
248
  if (delta) { yielded = true; yield delta; }
249
+ fnCalls.push(...functionCallsOf(chunk));
223
250
  if (chunk.response?.usageMetadata) usage = chunk.response.usageMetadata;
224
251
  }
225
252
  if (usage) options.onUsage?.({ inputTokens: usage.promptTokenCount, outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0) });
253
+ // Native tool calls have no text deltas — yield the re-serialized envelope once at end.
254
+ const envelope = serializeToolCalls(fnCalls);
255
+ if (envelope) { yielded = true; yield envelope; }
226
256
  if (!yielded) throw new Error("Antigravity Cloud Code Assist returned an empty response.");
227
257
  },
228
258
  };
@@ -14,6 +14,7 @@ import type { Credential } from "../../auth";
14
14
  import type { CallOptions, Message } from "../types";
15
15
  import { readSse } from "../sse";
16
16
  import { providerHttpError } from "./errors";
17
+ import { serializeToolCalls } from "../../agent/tool-schemas";
17
18
 
18
19
  export const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
19
20
 
@@ -63,6 +64,11 @@ export function codexResponsesRequest(
63
64
  stream: true, // the Codex backend only streams
64
65
  store: false,
65
66
  };
67
+ if (options.tools?.length) {
68
+ // Responses API function tools (flat shape). tool_choice "auto" keeps prose + `done`.
69
+ payload.tools = options.tools.map(t => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false }));
70
+ payload.tool_choice = "auto";
71
+ }
66
72
  // Map thinkingLevel → reasoning effort for Codex reasoning models (gjc parity).
67
73
  // Drop out-of-enum values instead of forwarding them — the backend 400s on unknown efforts.
68
74
  if (options.reasoningEffort && VALID_REASONING_EFFORTS.has(options.reasoningEffort)) {
@@ -87,6 +93,10 @@ export interface ResponsesEvent {
87
93
  /** `response.incomplete` cause (e.g. max_output_tokens) — surfaced when the
88
94
  * whole response produced no text (round-5 #1). */
89
95
  incompleteReason?: string;
96
+ /** NATIVE function_call output items (accumulated by the caller across SSE events). */
97
+ toolCallName?: string;
98
+ toolCallArgsDelta?: string;
99
+ toolCallIndex?: number;
90
100
  }
91
101
 
92
102
  /** Parse one Responses SSE `data:` payload into a delta / usage / error. */
@@ -94,6 +104,8 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
94
104
  let o: {
95
105
  type?: string;
96
106
  delta?: unknown;
107
+ item?: { type?: string; name?: string };
108
+ output_index?: number;
97
109
  response?: {
98
110
  usage?: { input_tokens?: number; output_tokens?: number };
99
111
  error?: { message?: string };
@@ -106,6 +118,12 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
106
118
  } catch {
107
119
  return {};
108
120
  }
121
+ if (o.type === "response.output_item.added" && o.item?.type === "function_call") {
122
+ return { toolCallName: o.item.name, toolCallIndex: o.output_index };
123
+ }
124
+ if (o.type === "response.function_call_arguments.delta" && typeof o.delta === "string") {
125
+ return { toolCallArgsDelta: o.delta, toolCallIndex: o.output_index };
126
+ }
109
127
  if (o.type === "response.output_text.delta" && typeof o.delta === "string") return { delta: o.delta };
110
128
  // `response.incomplete` (max_output_tokens / content filter) also carries usage — don't drop it.
111
129
  if ((o.type === "response.completed" || o.type === "response.incomplete") && o.response?.usage) {
@@ -120,6 +138,33 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
120
138
  return {};
121
139
  }
122
140
 
141
+ /** Accumulate Responses function_call name + streamed argument fragments by output index. */
142
+ function accumulateResponsesToolCall(acc: Map<number, { name: string; args: string }>, ev: ResponsesEvent): void {
143
+ if (ev.toolCallName !== undefined) {
144
+ const i = ev.toolCallIndex ?? 0;
145
+ const b = acc.get(i) ?? { name: "", args: "" };
146
+ b.name = ev.toolCallName;
147
+ acc.set(i, b);
148
+ }
149
+ if (ev.toolCallArgsDelta) {
150
+ const i = ev.toolCallIndex ?? 0;
151
+ const b = acc.get(i) ?? { name: "", args: "" };
152
+ b.args += ev.toolCallArgsDelta;
153
+ acc.set(i, b);
154
+ }
155
+ }
156
+
157
+ /** Re-serialize accumulated Responses function calls into the engine's canonical JSON. */
158
+ function serializeResponsesToolCalls(acc: Map<number, { name: string; args: string }>): string | null {
159
+ if (acc.size === 0) return null;
160
+ const calls = [...acc.values()].map(b => {
161
+ let args: Record<string, unknown> = {};
162
+ try { args = b.args ? JSON.parse(b.args) : {}; } catch { args = {}; }
163
+ return { tool: b.name, arguments: args };
164
+ });
165
+ return serializeToolCalls(calls);
166
+ }
167
+
123
168
  /** Round-5 #1: no-text completions surface their cause instead of returning "". */
124
169
  function emptyCompletionError(reason: string | undefined): Error {
125
170
  const hint = reason === "max_output_tokens"
@@ -136,13 +181,18 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
136
181
  if (!response.body) return "";
137
182
  let out = "";
138
183
  let incompleteReason: string | undefined;
184
+ const toolAcc = new Map<number, { name: string; args: string }>();
139
185
  for await (const data of readSse(response.body)) {
140
186
  const ev = parseResponsesEvent(data);
141
187
  if (ev.delta) out += ev.delta;
188
+ accumulateResponsesToolCall(toolAcc, ev);
142
189
  if (ev.usage) options.onUsage?.(ev.usage);
143
190
  if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
144
191
  if (ev.error) throw new Error(`OpenAI Codex response failed: ${ev.error}`);
145
192
  }
193
+ // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
194
+ const envelope = serializeResponsesToolCalls(toolAcc);
195
+ if (envelope) return envelope;
146
196
  if (!out) throw emptyCompletionError(incompleteReason);
147
197
  return out;
148
198
  }
@@ -159,15 +209,20 @@ export async function* codexResponsesStream(
159
209
  if (!response.body) return;
160
210
  let yieldedAny = false;
161
211
  let incompleteReason: string | undefined;
212
+ const toolAcc = new Map<number, { name: string; args: string }>();
162
213
  for await (const data of readSse(response.body)) {
163
214
  const ev = parseResponsesEvent(data);
164
215
  if (ev.delta) {
165
216
  yieldedAny = true;
166
217
  yield ev.delta;
167
218
  }
219
+ accumulateResponsesToolCall(toolAcc, ev);
168
220
  if (ev.usage) options.onUsage?.(ev.usage);
169
221
  if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
170
222
  if (ev.error) throw new Error(`OpenAI Codex response failed: ${ev.error}`);
171
223
  }
224
+ // Native tool calls have no output_text deltas — yield the re-serialized envelope once.
225
+ const envelope = serializeResponsesToolCalls(toolAcc);
226
+ if (envelope) { yieldedAny = true; yield envelope; }
172
227
  if (!yieldedAny) throw emptyCompletionError(incompleteReason);
173
228
  }
@@ -3,6 +3,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
3
3
  import { readSse } from "../sse";
4
4
  import { providerHttpError } from "./errors";
5
5
  import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
6
+ import { serializeToolCalls } from "../../agent/tool-schemas";
6
7
 
7
8
  export function openaiRequest(messages: Message[], options: CallOptions, credential: Credential, stream: boolean): { url: string; headers: Record<string, string>; body: string } {
8
9
  const model = options.model.startsWith("openai/") ? options.model.slice(7) : options.model;
@@ -39,7 +40,11 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
39
40
  payload.stream = true;
40
41
  payload.stream_options = { include_usage: true };
41
42
  }
42
- if (options.jsonMode) payload.response_format = { type: "json_object" };
43
+ if (options.tools?.length) {
44
+ payload.tools = options.tools.map(t => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
45
+ payload.tool_choice = "auto";
46
+ }
47
+ if (options.jsonMode && !options.tools?.length) payload.response_format = { type: "json_object" };
43
48
  const base = (options.baseUrl ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/$/, "");
44
49
  return {
45
50
  url: `${base}/chat/completions`,
@@ -59,14 +64,18 @@ function emptyCompletionError(finishReason: string | undefined): Error {
59
64
 
60
65
  export const openaiAdapter: ProviderAdapter = {
61
66
  name: "openai",
67
+ supportsNativeTools: true,
62
68
  async call(messages, options, credential) {
63
69
  // ChatGPT/Codex OAuth can't use /chat/completions — route to the Codex Responses backend.
64
70
  if (credential.kind === "oauth") return codexResponsesCall(messages, options, credential);
65
71
  const { url, headers, body } = openaiRequest(messages, options, credential, false);
66
72
  const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
67
73
  if (!response.ok) throw await providerHttpError("OpenAI", response);
68
- const result = (await response.json()) as { choices: { message: { content: string }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
74
+ const result = (await response.json()) as { choices: { message: { content?: string; tool_calls?: { function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
69
75
  if (result.usage) options.onUsage?.({ inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens });
76
+ // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
77
+ const envelope = serializeToolCalls(parseOpenaiToolCalls(result.choices[0]?.message?.tool_calls));
78
+ if (envelope) return envelope;
70
79
  const text = result.choices[0]?.message?.content ?? "";
71
80
  if (!text) throw emptyCompletionError(result.choices[0]?.finish_reason);
72
81
  return text;
@@ -93,8 +102,9 @@ export const openaiAdapter: ProviderAdapter = {
93
102
  if (!response.body) return;
94
103
  let yieldedAny = false;
95
104
  let finishReason: string | undefined;
105
+ const toolAcc = new Map<number, { name: string; args: string }>();
96
106
  for await (const data of readSse(response.body)) {
97
- let chunk: { choices?: { delta?: { content?: string }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
107
+ let chunk: { choices?: { delta?: { content?: string; tool_calls?: { index?: number; function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
98
108
  try {
99
109
  chunk = JSON.parse(data);
100
110
  } catch {
@@ -105,13 +115,46 @@ export const openaiAdapter: ProviderAdapter = {
105
115
  yieldedAny = true;
106
116
  yield delta;
107
117
  }
118
+ const tcs = chunk.choices?.[0]?.delta?.tool_calls;
119
+ if (tcs) {
120
+ for (const tc of tcs) {
121
+ const idx = tc.index ?? 0;
122
+ const b = toolAcc.get(idx) ?? { name: "", args: "" };
123
+ if (tc.function?.name) b.name = tc.function.name;
124
+ if (tc.function?.arguments) b.args += tc.function.arguments;
125
+ toolAcc.set(idx, b);
126
+ }
127
+ }
108
128
  if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
109
129
  if (chunk.usage) options.onUsage?.({ inputTokens: chunk.usage.prompt_tokens, outputTokens: chunk.usage.completion_tokens });
110
130
  }
131
+ // Native tool calls stream as tool_calls argument fragments — re-serialize once at end.
132
+ if (toolAcc.size > 0) {
133
+ const calls = [...toolAcc.values()].map(b => {
134
+ let args: Record<string, unknown> = {};
135
+ try { args = b.args ? JSON.parse(b.args) : {}; } catch { args = {}; }
136
+ return { tool: b.name, arguments: args };
137
+ });
138
+ const envelope = serializeToolCalls(calls);
139
+ if (envelope) { yieldedAny = true; yield envelope; }
140
+ }
111
141
  if (!yieldedAny) throw emptyCompletionError(finishReason);
112
142
  },
113
143
  };
114
144
 
145
+ function parseOpenaiToolCalls(toolCalls: { function?: { name?: string; arguments?: string } }[] | undefined): { tool: string; arguments: Record<string, unknown> }[] {
146
+ if (!toolCalls?.length) return [];
147
+ const out: { tool: string; arguments: Record<string, unknown> }[] = [];
148
+ for (const tc of toolCalls) {
149
+ const name = tc.function?.name;
150
+ if (!name) continue;
151
+ let args: Record<string, unknown> = {};
152
+ try { args = tc.function?.arguments ? JSON.parse(tc.function.arguments) : {}; } catch { args = {}; }
153
+ out.push({ tool: name, arguments: args });
154
+ }
155
+ return out;
156
+ }
157
+
115
158
  function bearerFor(credential: Credential): string {
116
159
  if (credential.kind === "oauth") return credential.token;
117
160
  if (credential.kind === "api_key") return credential.token;
package/src/ai/types.ts CHANGED
@@ -26,6 +26,16 @@ export interface Usage {
26
26
  durationMs?: number;
27
27
  }
28
28
 
29
+ /** Provider-neutral function/tool schema for NATIVE tool-calling. Capable adapters
30
+ * (anthropic/openai/gemini) map this onto their wire format (Anthropic input_schema,
31
+ * OpenAI function.parameters, Gemini functionDeclarations); fallback adapters
32
+ * (antigravity/ollama) ignore it and keep the JSON-in-prose protocol. */
33
+ export interface NativeToolSchema {
34
+ name: string;
35
+ description: string;
36
+ parameters: { type: "object"; properties: Record<string, unknown>; required?: string[] };
37
+ }
38
+
29
39
  export interface CallOptions {
30
40
  model: string;
31
41
  systemPrompt?: string;
@@ -47,10 +57,19 @@ export interface CallOptions {
47
57
  * answer text). Surfaced as a transient dimmed view; absent for models that emit no
48
58
  * thought text. */
49
59
  onReasoning?: (delta: string) => void;
60
+ /** NATIVE tool-calling: function declarations the model may call. Present only on the
61
+ * main agent step (never the prose wrap-up). Adapters with `supportsNativeTools` send
62
+ * these on the wire and re-serialize the structured tool call back into the engine's
63
+ * canonical {"tool":...}/{"tools":[...]} string; others ignore it. */
64
+ tools?: NativeToolSchema[];
50
65
  }
51
66
 
52
67
  export interface ProviderAdapter {
53
68
  readonly name: ProviderName;
69
+ /** True when this adapter implements native function-calling (re-serialized to the
70
+ * canonical JSON string). When false/absent, `CallOptions.tools` is ignored and the
71
+ * model drives tools via the JSON-in-prose protocol. */
72
+ readonly supportsNativeTools?: boolean;
54
73
  /** Local providers ignore the credential argument; cloud adapters require it. */
55
74
  call(messages: Message[], options: CallOptions, credential: Credential): Promise<string>;
56
75
  /** Optional token streaming. Yields text deltas; concatenation equals the `call()` result. */