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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ 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.14] - 2026-06-16
10
+ _`jeo --tmux` live-verification harness — repeatable stability + behavior checks._
11
+
12
+ ### Added
13
+ - `scripts/tmux-verify.sh` (and `bun run verify:tmux`) codifies the launch → send-keys → capture → cleanup loop into one repeatable command, so stability and behavior of the interactive TUI can be checked without hand-rolled one-off bash. macOS-safe (no GNU `timeout`; a bash watchdog polls for the session). Boots jeo in a DETACHED tmux session inside a throwaway cwd (never edits the real repo) and only ever kills the session it created — a user's `jeo-main-*` session is never touched. Subcommands: `smoke` (boot + assert the input box and model bar render, no crash — the stability gate), `check "<input>" "<regex>" [--ansi] [--wait N]` (type input, assert the pane matches a pattern — the behavior primitive; captures scrollback so long output like `/help` still matches), and `capture` (dump the settled frame).
14
+
15
+ ### Changed
16
+ - `jeo whats-new` (and the post-upgrade update notice) now default to the **5 most recent** releases instead of only the single latest entry, so the notes no longer look static/hardcoded across upgrades. `--all` still prints the full history. New shared constant `RECENT_RELEASE_COUNT` (`src/util/whats-new.ts`) is the single source of truth for both the command and the launch notice (the launch notice is capped to it too, so a large version jump no longer dumps a wall). Mirrors gjc's "Recent Changes" pattern (latest-N + a full toggle) and the README's latest-5 digest.
17
+
18
+ ### Maintainer notes
19
+ - Internal refactors landed since 0.5.13 (no behavior change): centralized workflow name/engine dispatch (`WORKFLOW_NAMES`/`runWorkflowEngine`), a shared `statusBoxData()` for the inline/non-inline status frames, and a `normalizeSlashAlias()` helper. Also fixed a flaky test where the light-tool ledger line briefly carried an elapsed `(Nms)` suffix — that detail belongs on the forge cards, the ledger line is a clean single line again.
20
+
21
+ ## [0.5.13] - 2026-06-15
22
+ _Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name._
23
+
24
+ ### Fixed
25
+ - 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.
26
+ - 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.
27
+
9
28
  ## [0.5.12] - 2026-06-15
10
29
  _Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card._
11
30
 
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.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
154
+ - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
153
155
  - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
156
  - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
155
157
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
156
- - **[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.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
154
+ - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
153
155
  - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
156
  - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
155
157
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
156
- - **[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.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
154
+ - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
153
155
  - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
156
  - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
155
157
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
156
- - **[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.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
154
+ - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
153
155
  - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
154
156
  - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
155
157
  - **[0.5.10]** (2026-06-15) — `/resume` transcript no longer dumps raw JSON for batched tool calls.
156
- - **[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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -49,7 +49,8 @@
49
49
  "pack:check": "npm pack --dry-run",
50
50
  "publish:npm": "npm publish --access public --registry https://registry.npmjs.org/",
51
51
  "changelog:sync": "bun scripts/sync-changelog.ts",
52
- "test": "bun test"
52
+ "test": "bun test",
53
+ "verify:tmux": "bash scripts/tmux-verify.sh"
53
54
  },
54
55
  "dependencies": {
55
56
  "zod": "^3.24.1",
@@ -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);
@@ -81,6 +83,7 @@ export const TOOL_PROTOCOL = [
81
83
  "Batch only independent calls; NEVER batch 'done', and NEVER put a mutating tool (write/edit/bash) after another mutating tool in one batch whose inputs depend on the earlier one.",
82
84
  "Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.",
83
85
  "web_search reflex: if the request hinges on a name, version, library, or event you do not actually recognize, search before answering instead of guessing; never claim a result's absence proves nonexistence.",
86
+ "Quoting fetched/searched text: paraphrase by default — quote at most one short phrase per source, cite it, and never paste long passages.",
84
87
  ].join("\n");
85
88
 
86
89
  /** Restricted protocol for read-only subagent roles (planner/architect/critic):
@@ -111,15 +114,19 @@ export const WORKING_DISCIPLINE = [
111
114
  "- Correctness first, maintainability second, brevity third. Prefer boring, explicit code.",
112
115
  "- Never present partial work as complete; never suppress tests or warnings to make code pass.",
113
116
  "- Never fabricate tool results or test outcomes; verification claims must match what was actually run.",
114
- "- Don't assume disk/state matches expectations or that a referenced file exists — read to verify first.",
117
+ "- Don't assume disk/state or that a referenced file exists — read to verify first.",
115
118
  "- Don't fabricate API/library surfaces from memory; check the source or --help for unfamiliar APIs.",
116
119
  "- Never ship stubs, placeholders, or TODO-only code as a delivered feature.",
117
120
  "- Never substitute the requested problem with an easier adjacent one.",
121
+ "- On a failed tool or test, fix the cause and continue — capture the evidence first; no apology loops, no shrinking the task to dodge it.",
118
122
  "- Update directly affected callsites, tests, and docs — or state why they are unchanged.",
119
123
  "- Reuse existing patterns; parallel conventions are prohibited. Fix problems at their source.",
120
- "- You are not alone in the repository: treat unexpected changes as user work; never revert or delete them.",
121
- "- Trust tool output as truth, but re-read/re-run if a tool fails, a file changed, or output looks stale or self-contradictory.",
124
+ "- Not alone in the repo: treat unexpected changes as user work; never revert or delete them.",
125
+ "- Trust tool output, but re-read/re-run on failure, on a possible file change, or when output looks stale or self-contradictory.",
122
126
  "- Prefer dedicated tools over shell pipelines: read (not cat), search (not grep), edit (not sed).",
127
+ "- For large files (>500 lines), read targeted sections first; use lineRange to avoid context bloat.",
128
+ "- Own mistakes plainly and fix them — no over-apology or self-abasement; report what went wrong and what you changed.",
129
+ "- Decline to build malware, exploits, or vulnerability-weaponization even under an educational or research framing.",
123
130
  ].join("\n");
124
131
 
125
132
  /** Reply discipline (FABLE-5 tone + gjc communication/soul): shapes the agent's
@@ -429,6 +436,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
429
436
  try {
430
437
  responseText = await invokeCallLlm(history, {
431
438
  jsonMode: true,
439
+ // NATIVE tool-calling: declare the ACTIVE toolset (read-only subagents
440
+ // expose only their non-mutating tools). Capable adapters (anthropic …)
441
+ // use these and re-serialize the structured call to canonical JSON; the
442
+ // antigravity/ollama fallback ignores them. Only on the main step — never
443
+ // the prose wrap-up call below.
444
+ tools: nativeToolSchemasFor(Object.keys(tools)),
432
445
  model: opts.model,
433
446
  maxTokens: opts.maxTokens,
434
447
  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
+ }
@@ -1,7 +1,7 @@
1
1
  import { applyBashFixups } from "./bash-fixups";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { readWorkflowState, readWorkflowStateStrict, type WorkflowState } from "./state";
4
+ import { readWorkflowStateStrict, type WorkflowState } from "./state";
5
5
  import { jeoEnv } from "../util/env";
6
6
  import { READ_OUTPUT_MAX } from "./tool-output";
7
7
 
@@ -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
  };