jeo-code 0.5.12 → 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/CHANGELOG.md +7 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/engine.ts +8 -0
- package/src/agent/loop.ts +2 -0
- package/src/agent/tool-schemas.ts +132 -0
- package/src/agent/tools.ts +8 -2
- package/src/ai/model-manager.ts +1 -0
- package/src/ai/providers/anthropic.ts +60 -3
- package/src/ai/providers/antigravity.ts +31 -1
- package/src/ai/providers/openai-responses.ts +55 -0
- package/src/ai/providers/openai.ts +46 -3
- package/src/ai/types.ts +19 -0
- package/src/commands/launch.ts +18 -4
- package/src/skills/catalog.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.5.13] - 2026-06-15
|
|
10
|
+
_Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Workflow skills listed in the `/` menu didn't run: a bare `/name` only resolved when the skill's SKILL.md happened to self-reference that slash token (so `/ralplan` worked by luck while `/deep-interview` and `/ultragoal` returned "Unknown command"). `parseSkillInvocation` now resolves a plain `/word` against skill NAMES (exact, then unique prefix) — the same entrypoint as `$name` and `/skill:name` (gjc parity) — so `/deep-interview`, `/ralplan`, `/team`, `/ultragoal` (and any loaded skill) dispatch from the slash menu. Dotted (`/speckit.plan`) and nested (`/a/b`) tokens keep their alias/file-path resolution untouched, and built-in commands still take precedence.
|
|
14
|
+
- The four bundled workflows are now always listed in the `/` menu as `/deep-interview`, `/ralplan`, `/team`, `/ultragoal`, even when their SKILL.md declares no slash alias, so they are discoverable as well as runnable.
|
|
15
|
+
|
|
9
16
|
## [0.5.12] - 2026-06-15
|
|
10
17
|
_Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card._
|
|
11
18
|
|
package/README.ja.md
CHANGED
|
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
150
150
|
## 変更履歴 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
153
154
|
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
154
155
|
- **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
|
|
155
156
|
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
156
157
|
- **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
|
|
157
|
-
- **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
150
150
|
## 변경 이력 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
153
154
|
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
154
155
|
- **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
|
|
155
156
|
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
156
157
|
- **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
|
|
157
|
-
- **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
150
150
|
## Changelog
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
153
154
|
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
154
155
|
- **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
|
|
155
156
|
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
156
157
|
- **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
|
|
157
|
-
- **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
150
150
|
## 更新日志 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
153
154
|
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
154
155
|
- **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
|
|
155
156
|
- **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
|
|
156
157
|
- **[0.5.9]** (2026-06-15) — Bounded per-frame wrap for the live thinking/tool-output blocks — re-render cost no longer grows with stream length.
|
|
157
|
-
- **[0.5.8]** (2026-06-15) — Native Opik observability for the turn loop (opt-in `JEO_OPIK`, pure-TS no-op when unset) + autopilot convergence tracking.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -11,6 +11,7 @@ import * as fs from "node:fs/promises";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import type { Message } from "./loop";
|
|
13
13
|
import { extractJsonObject } from "./json";
|
|
14
|
+
import { nativeToolSchemasFor } from "./tool-schemas";
|
|
14
15
|
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
|
|
15
16
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
16
17
|
import { friendlyProviderError, isContextOverflowError, isRefusalError } from "../util/provider-error";
|
|
@@ -32,6 +33,7 @@ async function invokeCallLlm(history: Message[], options: {
|
|
|
32
33
|
onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
|
|
33
34
|
onToken?: (delta: string) => void;
|
|
34
35
|
onReasoning?: (delta: string) => void;
|
|
36
|
+
tools?: import("../ai/types").NativeToolSchema[];
|
|
35
37
|
}): Promise<string> {
|
|
36
38
|
const mod = await import("./loop");
|
|
37
39
|
return mod.callLlm(history, options);
|
|
@@ -429,6 +431,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
429
431
|
try {
|
|
430
432
|
responseText = await invokeCallLlm(history, {
|
|
431
433
|
jsonMode: true,
|
|
434
|
+
// NATIVE tool-calling: declare the ACTIVE toolset (read-only subagents
|
|
435
|
+
// expose only their non-mutating tools). Capable adapters (anthropic …)
|
|
436
|
+
// use these and re-serialize the structured call to canonical JSON; the
|
|
437
|
+
// antigravity/ollama fallback ignores them. Only on the main step — never
|
|
438
|
+
// the prose wrap-up call below.
|
|
439
|
+
tools: nativeToolSchemasFor(Object.keys(tools)),
|
|
432
440
|
model: opts.model,
|
|
433
441
|
maxTokens: opts.maxTokens,
|
|
434
442
|
signal: opts.signal,
|
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
|
+
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -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;
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 === "
|
|
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.
|
|
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
|
|
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/commands/launch.ts
CHANGED
|
@@ -1192,14 +1192,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1192
1192
|
|
|
1193
1193
|
const workflowSkills = workflowSkillsForPrompt(resolvedSkills);
|
|
1194
1194
|
const resolvedSkillNames = resolvedSkills.map(s => s.name);
|
|
1195
|
-
|
|
1196
|
-
|
|
1195
|
+
// Bundled workflows are first-class `/name` commands (deep-interview/ralplan/team/
|
|
1196
|
+
// ultragoal), surfaced in the `/` menu even when their SKILL.md self-references no
|
|
1197
|
+
// slash token — `parseSkillInvocation` dispatches `/name` by skill name. Aliases the
|
|
1198
|
+
// SKILL.md does declare are listed too (deduped, case-insensitive).
|
|
1199
|
+
const WORKFLOW_SLASH_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"];
|
|
1200
|
+
const skillSlashDetails: SlashCommandInfo[] = resolvedSkills.flatMap(skill => {
|
|
1201
|
+
const aliases = skillSlashAliases(skill);
|
|
1202
|
+
const nameSlash = WORKFLOW_SLASH_NAMES.includes(skill.name) ? [`/${skill.name}`] : [];
|
|
1203
|
+
const seen = new Set<string>();
|
|
1204
|
+
const commands = [...nameSlash, ...aliases].filter(a => {
|
|
1205
|
+
const k = a.toLowerCase();
|
|
1206
|
+
if (seen.has(k)) return false;
|
|
1207
|
+
seen.add(k);
|
|
1208
|
+
return true;
|
|
1209
|
+
});
|
|
1210
|
+
return commands.map(alias => ({
|
|
1197
1211
|
command: alias,
|
|
1198
1212
|
usage: `${alias} [intent]`,
|
|
1199
1213
|
description: `Run ${skill.name} skill${skill.summary ? ` — ${skill.summary}` : ""}`,
|
|
1200
1214
|
group: "skills" as const,
|
|
1201
|
-
}))
|
|
1202
|
-
);
|
|
1215
|
+
}));
|
|
1216
|
+
});
|
|
1203
1217
|
|
|
1204
1218
|
const protocol = buildToolProtocol(allowedTools);
|
|
1205
1219
|
const preamble = flags.systemPrompt ?? "You are the jeo, an interactive coding agent.\nAccomplish the user's request by calling tools and verifying your work.";
|
package/src/skills/catalog.ts
CHANGED
|
@@ -546,6 +546,14 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
|
|
|
546
546
|
}
|
|
547
547
|
}
|
|
548
548
|
let skill = getSkillBySlash(skills, command);
|
|
549
|
+
// `/team`, `/deep-interview`, `/ultragoal`, … — a bare slash + skill NAME (or unique
|
|
550
|
+
// prefix) is the SAME entrypoint as `$name` and `/skill:name`. Only when getSkillBySlash
|
|
551
|
+
// found no alias and the token is a plain `/word` (no nested `/path` and no `.` so
|
|
552
|
+
// `/speckit.plan` aliases and `./file` paths keep their own resolution). This is what
|
|
553
|
+
// makes the bundled workflows actually run from the `/` menu, not just `/ralplan`.
|
|
554
|
+
if (!skill && command.length > 1 && command.startsWith("/") && !command.includes(".") && command.indexOf("/", 1) === -1) {
|
|
555
|
+
skill = getSkillFrom(skills, command.slice(1)) ?? uniquePrefixSkill(skills, command.slice(1));
|
|
556
|
+
}
|
|
549
557
|
if (!skill) {
|
|
550
558
|
if (command.startsWith("/") || command.startsWith(".") || command.includes("/")) {
|
|
551
559
|
const resolved = tryResolveSkillFromFilePath(command);
|