jeo-code 0.5.12 → 0.5.14

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.
@@ -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. */
package/src/cli/runner.ts CHANGED
@@ -172,6 +172,15 @@ export const COMMANDS: readonly CommandSpec[] = [
172
172
  return args => m.runUpdateCommand(args);
173
173
  },
174
174
  },
175
+ {
176
+ name: "whats-new",
177
+ summary: "Show the release notes bundled with the installed jeo-code version.",
178
+ usage: "whats-new [--all] [--json]",
179
+ loader: async () => {
180
+ const m = await import("../commands/whats-new");
181
+ return args => m.runWhatsNewCommand(args);
182
+ },
183
+ },
175
184
  {
176
185
  name: "ooo-seed",
177
186
  summary: "Generate an immutable ooo seed from a specification (spec-first automation).",