sigit-code 1.5.8__tar.gz → 1.5.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/sigit-code-release/SKILL.md +1 -0
  2. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/sigit-code-release/SKILL.md +1 -0
  3. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-github.yml +36 -0
  4. {sigit_code-1.5.8 → sigit_code-1.5.10}/CHANGELOG.md +64 -0
  5. {sigit_code-1.5.8 → sigit_code-1.5.10}/Cargo.lock +1 -1
  6. {sigit_code-1.5.8 → sigit_code-1.5.10}/Cargo.toml +1 -1
  7. {sigit_code-1.5.8 → sigit_code-1.5.10}/PKG-INFO +1 -1
  8. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/backend.rs +74 -29
  9. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/chat.rs +3 -7
  10. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/headless.rs +3 -7
  11. sigit_code-1.5.10/src/inline_tool_calls.rs +976 -0
  12. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/main.rs +53 -10
  13. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/provider.rs +8 -0
  14. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/tools.rs +2 -6
  15. {sigit_code-1.5.8 → sigit_code-1.5.10}/tests/acp_permissions.rs +194 -0
  16. sigit_code-1.5.8/src/inline_tool_calls.rs +0 -420
  17. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/AGENTS.md +0 -0
  18. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/agent-client-protocol/SKILL.md +0 -0
  19. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
  20. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/branding/SKILL.md +0 -0
  21. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/run-sigit/SKILL.md +0 -0
  22. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/run-sigit/driver.mjs +0 -0
  23. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/run-sigit/tui-smoke.sh +0 -0
  24. {sigit_code-1.5.8 → sigit_code-1.5.10}/.agents/skills/tool-calling/SKILL.md +0 -0
  25. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/agent-client-protocol/SKILL.md +0 -0
  26. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/ai-assisted-coding/SKILL.md +0 -0
  27. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/branding/SKILL.md +0 -0
  28. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/run-sigit/SKILL.md +0 -0
  29. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/run-sigit/driver.mjs +0 -0
  30. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
  31. {sigit_code-1.5.8 → sigit_code-1.5.10}/.claude/skills/tool-calling/SKILL.md +0 -0
  32. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/ci.yml +0 -0
  33. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-aur.yml +0 -0
  34. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-crates.yml +0 -0
  35. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-homebrew.yml +0 -0
  36. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-npm.yml +0 -0
  37. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-nuget.yml +0 -0
  38. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-pypi.yml +0 -0
  39. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-scoop.yml +0 -0
  40. {sigit_code-1.5.8 → sigit_code-1.5.10}/.github/workflows/release-winget.yml +0 -0
  41. {sigit_code-1.5.8 → sigit_code-1.5.10}/.gitignore +0 -0
  42. {sigit_code-1.5.8 → sigit_code-1.5.10}/.nvmrc +0 -0
  43. {sigit_code-1.5.8 → sigit_code-1.5.10}/AGENTS.md +0 -0
  44. {sigit_code-1.5.8 → sigit_code-1.5.10}/CLAUDE.md +0 -0
  45. {sigit_code-1.5.8 → sigit_code-1.5.10}/LICENSE +0 -0
  46. {sigit_code-1.5.8 → sigit_code-1.5.10}/README.md +0 -0
  47. {sigit_code-1.5.8 → sigit_code-1.5.10}/docs/hooks.md +0 -0
  48. {sigit_code-1.5.8 → sigit_code-1.5.10}/docs/mcp.md +0 -0
  49. {sigit_code-1.5.8 → sigit_code-1.5.10}/examples/settings-with-hooks.toml +0 -0
  50. {sigit_code-1.5.8 → sigit_code-1.5.10}/examples/skills/README.md +0 -0
  51. {sigit_code-1.5.8 → sigit_code-1.5.10}/examples/skills/commit-message/SKILL.md +0 -0
  52. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/README.md.tmpl +0 -0
  53. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/package-compat.json.tmpl +0 -0
  54. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/package-main.json.tmpl +0 -0
  55. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/package.json.tmpl +0 -0
  56. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/scripts/render-main-package.cjs +0 -0
  57. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/scripts/render-platform-package.cjs +0 -0
  58. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/sigit/.gitignore +0 -0
  59. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/sigit/README.md +0 -0
  60. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/sigit/package.json +0 -0
  61. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/sigit/src/index.ts +0 -0
  62. {sigit_code-1.5.8 → sigit_code-1.5.10}/npm/sigit/tsconfig.json +0 -0
  63. {sigit_code-1.5.8 → sigit_code-1.5.10}/nuget/.gitignore +0 -0
  64. {sigit_code-1.5.8 → sigit_code-1.5.10}/nuget/sigit/Program.cs +0 -0
  65. {sigit_code-1.5.8 → sigit_code-1.5.10}/nuget/sigit/README.md +0 -0
  66. {sigit_code-1.5.8 → sigit_code-1.5.10}/nuget/sigit/SiGit.Code.csproj +0 -0
  67. {sigit_code-1.5.8 → sigit_code-1.5.10}/packaging/aur/PKGBUILD.in +0 -0
  68. {sigit_code-1.5.8 → sigit_code-1.5.10}/packaging/nfpm.yaml +0 -0
  69. {sigit_code-1.5.8 → sigit_code-1.5.10}/packaging/winget/getSigit.siGitCode.installer.yaml.in +0 -0
  70. {sigit_code-1.5.8 → sigit_code-1.5.10}/packaging/winget/getSigit.siGitCode.locale.en-US.yaml.in +0 -0
  71. {sigit_code-1.5.8 → sigit_code-1.5.10}/packaging/winget/getSigit.siGitCode.yaml.in +0 -0
  72. {sigit_code-1.5.8 → sigit_code-1.5.10}/pypi/README.md +0 -0
  73. {sigit_code-1.5.8 → sigit_code-1.5.10}/pypi/pyproject.toml +0 -0
  74. {sigit_code-1.5.8 → sigit_code-1.5.10}/pyproject.toml +0 -0
  75. {sigit_code-1.5.8 → sigit_code-1.5.10}/rust-toolchain.toml +0 -0
  76. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/account.rs +0 -0
  77. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/browser_auth.rs +0 -0
  78. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/commands.rs +0 -0
  79. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/credentials.rs +0 -0
  80. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/frontmatter.rs +0 -0
  81. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/hooks.rs +0 -0
  82. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/instructions.rs +0 -0
  83. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/mcp.rs +0 -0
  84. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/models.rs +0 -0
  85. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/permissions.rs +0 -0
  86. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/session_store.rs +0 -0
  87. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/settings.rs +0 -0
  88. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/setup.rs +0 -0
  89. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/skills.rs +0 -0
  90. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/subagents.rs +0 -0
  91. {sigit_code-1.5.8 → sigit_code-1.5.10}/src/workspace.rs +0 -0
  92. {sigit_code-1.5.8 → sigit_code-1.5.10}/tests/acp_endpoint_errors.rs +0 -0
  93. {sigit_code-1.5.8 → sigit_code-1.5.10}/tests/acp_multi_root.rs +0 -0
  94. {sigit_code-1.5.8 → sigit_code-1.5.10}/tests/acp_session_load.rs +0 -0
  95. {sigit_code-1.5.8 → sigit_code-1.5.10}/tests/acp_tool_stdin.rs +0 -0
  96. {sigit_code-1.5.8 → sigit_code-1.5.10}/tests/headless_mode.rs +0 -0
@@ -35,6 +35,7 @@ Use this skill when preparing a release for this repository.
35
35
  - The crate is published to crates.io (`release-crates.yml`) and the Homebrew tap is updated (`release-homebrew.yml`) as part of the tag-driven flow. Per the siGit release flow, Homebrew is auto-triggered — do not dispatch it manually.
36
36
  - `release-github.yml` builds the binaries, attaches them (plus a `.deb`/`.rpm` per Linux target built with nfpm from `packaging/nfpm.yaml`) to the GitHub release, then dispatches `release-homebrew`, `release-scoop`, `release-winget`, and `release-aur`. A dispatch failure in any one of those four is logged as a warning, not a hard failure — an unconfigured channel doesn't block the rest of the release. `release-nuget.yml` and `release-mcp-registry.yml`, by contrast, are tag-triggered directly like the other language-registry workflows, not dispatched from `release-github.yml`.
37
37
  - Every release asset carries a `.sha256` sidecar (not just the macOS Homebrew tarball). Scoop, winget, and the AUR PKGBUILD each consume the raw binaries plus their sidecar checksum, rather than the tarball.
38
+ - The GitHub release body is generated, not hand-written: `release-github.yml`'s release job checks out the tag, awk-extracts the `## <version>` section from `CHANGELOG.md` (a missing section is a hard failure), appends a full-changelog link and a `sigit.si/code` footnote, and publishes it via `body_path: release_notes.md`. The only file a release-prep change needs to touch is `CHANGELOG.md` — never edit release notes in the GitHub UI, because the next tag push would overwrite them.
38
39
  - npm and NuGet publish via OIDC trusted publishing — neither has a stored token in the repo. On npm this is configured per package (each `@getsigit/*` name has a trusted publisher pointing at `release-npm.yml`), so a new package needs the same setup before its first publish will work. OIDC requires npm >= 11.5.1 and Node >= 22.14.0.
39
40
  - The NuGet package (`SiGit.Code`, `dotnet tool install --global SiGit.Code`) bundles all six platform binaries under `native/<os>-<arch>/` behind a managed shim, rather than shipping one package per platform like npm/PyPI. Watch the nuget.org 250 MB package-size limit if it ever grows.
40
41
  - The baked-in official MCP server is separately listed in the public MCP Registry as `si.sigit/sigit` (`release-mcp-registry.yml`, driven by `server.json` at the repo root). It's a remote Streamable-HTTP listing, so it's verified by a DNS TXT record on the `sigit.si` domain rather than the GitHub-OIDC scheme used for package listings.
@@ -35,6 +35,7 @@ Use this skill when preparing a release for this repository.
35
35
  - The crate is published to crates.io (`release-crates.yml`) and the Homebrew tap is updated (`release-homebrew.yml`) as part of the tag-driven flow. Per the siGit release flow, Homebrew is auto-triggered — do not dispatch it manually.
36
36
  - `release-github.yml` builds the binaries, attaches them (plus a `.deb`/`.rpm` per Linux target built with nfpm from `packaging/nfpm.yaml`) to the GitHub release, then dispatches `release-homebrew`, `release-scoop`, `release-winget`, and `release-aur`. A dispatch failure in any one of those four is logged as a warning, not a hard failure — an unconfigured channel doesn't block the rest of the release. `release-nuget.yml` and `release-mcp-registry.yml`, by contrast, are tag-triggered directly like the other language-registry workflows, not dispatched from `release-github.yml`.
37
37
  - Every release asset carries a `.sha256` sidecar (not just the macOS Homebrew tarball). Scoop, winget, and the AUR PKGBUILD each consume the raw binaries plus their sidecar checksum, rather than the tarball.
38
+ - The GitHub release body is generated, not hand-written: `release-github.yml`'s release job checks out the tag, awk-extracts the `## <version>` section from `CHANGELOG.md` (a missing section is a hard failure), appends a full-changelog link and a `sigit.si/code` footnote, and publishes it via `body_path: release_notes.md`. The only file a release-prep change needs to touch is `CHANGELOG.md` — never edit release notes in the GitHub UI, because the next tag push would overwrite them.
38
39
  - npm and NuGet publish via OIDC trusted publishing — neither has a stored token in the repo. On npm this is configured per package (each `@getsigit/*` name has a trusted publisher pointing at `release-npm.yml`), so a new package needs the same setup before its first publish will work. OIDC requires npm >= 11.5.1 and Node >= 22.14.0.
39
40
  - The NuGet package (`SiGit.Code`, `dotnet tool install --global SiGit.Code`) bundles all six platform binaries under `native/<os>-<arch>/` behind a managed shim, rather than shipping one package per platform like npm/PyPI. Watch the nuget.org 250 MB package-size limit if it ever grows.
40
41
  - The baked-in official MCP server is separately listed in the public MCP Registry as `si.sigit/sigit` (`release-mcp-registry.yml`, driven by `server.json` at the repo root). It's a remote Streamable-HTTP listing, so it's verified by a DNS TXT record on the `sigit.si` domain rather than the GitHub-OIDC scheme used for package listings.
@@ -240,6 +240,41 @@ jobs:
240
240
  echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
241
241
  fi
242
242
 
243
+ - name: Checkout tag
244
+ # The release job otherwise only downloads artifacts; the changelog
245
+ # has to be read from the tree at the tag being released.
246
+ uses: actions/checkout@v6
247
+ with:
248
+ ref: ${{ steps.tag.outputs.tag }}
249
+
250
+ - name: Compose release notes
251
+ # The release page carries the section of CHANGELOG.md for this
252
+ # version, not a bare "Full changelog" default. A missing section is
253
+ # a hard failure: publishing a release whose notes don't match its
254
+ # changelog is worse than stopping the job.
255
+ shell: bash
256
+ run: |
257
+ version="${{ steps.tag.outputs.tag }}"
258
+ version="${version#v}"
259
+ section="$(awk -v ver="${version}" '
260
+ $0 == "## " ver { found = 1; next }
261
+ found && /^## / { exit }
262
+ found { print }
263
+ ' CHANGELOG.md)"
264
+ if [ -z "${section//[$' \t\n']/}" ]; then
265
+ echo "No ## ${version} section found in CHANGELOG.md" >&2
266
+ exit 1
267
+ fi
268
+ {
269
+ printf '%s\n' "${section}"
270
+ printf '\n---\n\n'
271
+ printf 'Full changelog: https://github.com/%s/blob/%s/CHANGELOG.md\n' \
272
+ "${GITHUB_REPOSITORY}" "${{ steps.tag.outputs.tag }}"
273
+ printf '\n---\n\n'
274
+ printf 'About siGit Code: [sigit.si/code](https://sigit.si/code)\n'
275
+ } > release_notes.md
276
+ cat release_notes.md
277
+
243
278
  - name: Download binary artifacts
244
279
  uses: actions/download-artifact@v4
245
280
  with:
@@ -258,6 +293,7 @@ jobs:
258
293
  uses: softprops/action-gh-release@v2
259
294
  with:
260
295
  tag_name: ${{ steps.tag.outputs.tag }}
296
+ body_path: release_notes.md
261
297
  files: release/*
262
298
 
263
299
  # These channels all publish somewhere outside this repo (a tap, a Scoop
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.10
4
+
5
+ ### What changed
6
+
7
+ - **Xcode's context no longer buries slash commands.** Xcode sends the user's
8
+ prompt as the last of several text blocks, with project context in the
9
+ blocks before it, so `/models` and friends sat in the middle of joined text
10
+ and were handed to the model instead of dispatching locally. Slash-command
11
+ parsing now searches the prompt's text blocks from the end, so a standalone
12
+ command in the final user block dispatches no matter what context the client
13
+ prepends
14
+ - **The nova tier is the default cloud engine.** When local inference is off
15
+ and no explicit provider override is set, the cloud tier now defaults to
16
+ `nova` (the `onde-nova` model) instead of the balanced tier, in both the
17
+ interactive and headless paths
18
+
19
+ ### Fixed
20
+
21
+ - **Malformed Chinese-model tool calls are handled end-to-end in ACP.** The
22
+ inline-call recovery that 1.5.9 added for Kimi K3's XTML protocol and GLM's
23
+ mis-tagged blocks only applied to the interactive and headless surfaces;
24
+ the ACP path never ran it, so an editor session still surfaced raw protocol
25
+ text and dropped the calls. Recovery now runs in the ACP prompt loop too,
26
+ covered by integration tests, and the tool names are checked against the
27
+ turn's offered tools before anything executes
28
+ - **Suppressed tool-call log spam is gone.** The guard that logs when a
29
+ structured call is dropped for arriving as forced text fired once per
30
+ streamed argument fragment — one malformed call could log a warning per
31
+ chunk. The check now fires once per turn with the count and names of what
32
+ was dropped, and the non-streaming path reports the same
33
+ - **A history-replay test no longer races on the process cwd.** One ACP test
34
+ built its expected path from `std::env::current_dir()` while other tests
35
+ briefly swap that process-global cwd, so on CI it could read a temp
36
+ directory mid-swap and compare a truncated title against an untruncated
37
+ expectation. It now uses a fixed path it never needed to derive
38
+
39
+ ## 1.5.9
40
+
41
+ ### Fixed
42
+
43
+ - **Kimi K3 tool calls no longer arrive as raw protocol text.** Kimi K3 renders
44
+ a call in Moonshot's XTML protocol — pipe-delimited open/sep/close tokens
45
+ wrapping named blocks — and when the serving stack doesn't parse that back
46
+ into structured tool calls, the whole block surfaced in the editor as literal
47
+ text and the turn ended as though the model had answered in prose. The
48
+ inline-call recovery now speaks that protocol alongside the legacy XML tags:
49
+ tools blocks are parsed into real calls (several per block, argument values
50
+ decoded and typed from the block's own type attribute or, when it is
51
+ missing, the turn's tool schemas), response blocks are unwrapped so their
52
+ text still renders, and think blocks are dropped as private reasoning rather
53
+ than shown. The streaming scanner watches all the opening markers, so a
54
+ block split across chunk boundaries still recovers
55
+ - **GLM's mis-tagged calls recover too.** GLM sometimes precedes the legacy
56
+ block's first argument key with another tool-call opening tag instead of the
57
+ arg-key opening tag the format calls for, and the recovery rejected the
58
+ whole block when it did — the call surfaced as text and whatever it was
59
+ trying to do was dropped. The parser now accepts that alias for the first
60
+ argument only — later arguments stay strict, so text that merely resembles a
61
+ call cannot be recovered as one — in both the whole-blob and streaming
62
+ paths, and the scanner tolerates the malformed marker split across chunk
63
+ boundaries. Recovery in both protocols also checks the call's name against
64
+ the tools actually offered in the turn: a tag naming a tool that was never
65
+ in the turn's spec stays on screen instead of being executed
66
+
3
67
  ## 1.5.8
4
68
 
5
69
  ### What changed
@@ -5778,7 +5778,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5778
5778
 
5779
5779
  [[package]]
5780
5780
  name = "sigit"
5781
- version = "1.5.8"
5781
+ version = "1.5.10"
5782
5782
  dependencies = [
5783
5783
  "agent-client-protocol",
5784
5784
  "anyhow",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sigit"
3
- version = "1.5.8"
3
+ version = "1.5.10"
4
4
  edition = "2024"
5
5
  description = "siGit Code — ACP-compatible AI coding agent. Sí, git."
6
6
  documentation = "https://github.com/getsigit/sigit"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.5.8
3
+ Version: 1.5.10
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -159,12 +159,15 @@ pub trait InferenceBackend: Send + Sync {
159
159
  sink: Option<&TokenSink>,
160
160
  ) -> Result<TurnResult, BackendError>;
161
161
 
162
- /// Continue the turn by returning tool results. `tools` may be `None` on the
163
- /// final round to force a text answer. `sink` streams that text when set.
162
+ /// Continue the turn by returning tool results. `allow_tool_calls` controls
163
+ /// whether `tools` are offered again; the complete catalog remains available
164
+ /// so a disabled round can recognize and suppress tool-shaped model output.
165
+ /// `sink` streams assistant text when set.
164
166
  async fn send_tool_results(
165
167
  &self,
166
168
  results: Vec<ToolResult>,
167
- tools: Option<&[ToolSpec]>,
169
+ tools: &[ToolSpec],
170
+ allow_tool_calls: bool,
168
171
  sink: Option<&TokenSink>,
169
172
  ) -> Result<TurnResult, BackendError>;
170
173
 
@@ -257,7 +260,8 @@ impl InferenceBackend for LocalBackend {
257
260
  async fn send_tool_results(
258
261
  &self,
259
262
  results: Vec<ToolResult>,
260
- tools: Option<&[ToolSpec]>,
263
+ tools: &[ToolSpec],
264
+ allow_tool_calls: bool,
261
265
  sink: Option<&TokenSink>,
262
266
  ) -> Result<TurnResult, BackendError> {
263
267
  let onde_results: Vec<onde::inference::ToolResult> = results
@@ -268,10 +272,10 @@ impl InferenceBackend for LocalBackend {
268
272
  })
269
273
  .collect();
270
274
 
271
- // The final round passes `tools = None` to force a text answer; that's
272
- // the only round onde can stream, since no further tool calls are parsed.
275
+ // A forced-text round is the only round onde can stream, since no
276
+ // further tool calls are parsed.
273
277
  if let Some(sink) = sink
274
- && tools.is_none()
278
+ && !allow_tool_calls
275
279
  {
276
280
  let rx = self
277
281
  .engine
@@ -281,7 +285,7 @@ impl InferenceBackend for LocalBackend {
281
285
  return drain_onde_stream(rx, sink).await;
282
286
  }
283
287
 
284
- let onde_tools = tools.map(to_onde_tools);
288
+ let onde_tools = allow_tool_calls.then(|| to_onde_tools(tools));
285
289
  let result = self
286
290
  .engine
287
291
  .send_tool_results(onde_results, onde_tools.as_deref())
@@ -485,12 +489,14 @@ impl OpenAiBackend {
485
489
  .collect()
486
490
  }
487
491
 
488
- /// POST the current history (plus `tools`) and apply the assistant reply to
489
- /// history, returning the neutral turn result. Streams via SSE when `sink`
490
- /// is set; otherwise reads a single JSON response.
492
+ /// POST the current history and apply the assistant reply to history.
493
+ /// `tools` is always the known catalog, while `allow_tool_calls` determines
494
+ /// whether it is advertised to the model and whether returned calls may run.
495
+ /// Streams via SSE when `sink` is set; otherwise reads a single JSON response.
491
496
  async fn complete(
492
497
  &self,
493
- tools: Option<&[ToolSpec]>,
498
+ tools: &[ToolSpec],
499
+ allow_tool_calls: bool,
494
500
  sink: Option<&TokenSink>,
495
501
  ) -> Result<TurnResult, BackendError> {
496
502
  let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
@@ -501,9 +507,7 @@ impl OpenAiBackend {
501
507
  "messages": *self.history.lock().await,
502
508
  "stream": streaming,
503
509
  });
504
- if let Some(tools) = tools
505
- && !tools.is_empty()
506
- {
510
+ if allow_tool_calls && !tools.is_empty() {
507
511
  body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
508
512
  // OpenAI specifies `auto` as the default when tools are present,
509
513
  // but not every OpenAI-compatible gateway implements that default.
@@ -528,13 +532,11 @@ impl OpenAiBackend {
528
532
  return Err(describe_api_error(status, &body));
529
533
  }
530
534
 
531
- // The specs are needed downstream to type the arguments of any tool
532
- // call the model emitted as text rather than as a structured call.
533
- let tools = tools.unwrap_or(&[]);
534
535
  if let Some(sink) = sink {
535
- self.consume_stream(response, sink, tools).await
536
+ self.consume_stream(response, sink, tools, allow_tool_calls)
537
+ .await
536
538
  } else {
537
- self.consume_json(response, tools).await
539
+ self.consume_json(response, tools, allow_tool_calls).await
538
540
  }
539
541
  }
540
542
 
@@ -543,6 +545,7 @@ impl OpenAiBackend {
543
545
  &self,
544
546
  response: reqwest::Response,
545
547
  tools: &[ToolSpec],
548
+ allow_tool_calls: bool,
546
549
  ) -> Result<TurnResult, BackendError> {
547
550
  let parsed: ChatCompletion = response
548
551
  .json()
@@ -556,7 +559,7 @@ impl OpenAiBackend {
556
559
  .map(|choice| choice.message)
557
560
  .ok_or_else(|| "endpoint returned no choices".to_string())?;
558
561
 
559
- let text = message.content.clone().unwrap_or_default();
562
+ let mut text = message.content.clone().unwrap_or_default();
560
563
  let tool_calls: Vec<ToolCall> = message
561
564
  .tool_calls
562
565
  .iter()
@@ -568,6 +571,31 @@ impl OpenAiBackend {
568
571
  })
569
572
  .collect();
570
573
 
574
+ if !allow_tool_calls {
575
+ let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
576
+ text = cleaned;
577
+ let suppressed = tool_calls.len() + recovered.len();
578
+ if suppressed > 0 {
579
+ let names = tool_calls
580
+ .iter()
581
+ .map(|call| call.name.as_str())
582
+ .chain(recovered.iter().map(|call| call.name.as_str()))
583
+ .collect::<Vec<_>>()
584
+ .join(", ");
585
+ log::warn!(
586
+ "suppressed {suppressed} tool call(s) from a forced-text response: {names}"
587
+ );
588
+ }
589
+ self.history
590
+ .lock()
591
+ .await
592
+ .push(streamed_assistant_history(&text, &[]));
593
+ return Ok(TurnResult {
594
+ text,
595
+ tool_calls: Vec::new(),
596
+ });
597
+ }
598
+
571
599
  // Some models write a tool call out as literal `<tool_call>` text
572
600
  // instead of using the structured field (see `inline_tool_calls`).
573
601
  // Recover it, or the turn ends with the tag rendered as prose and
@@ -617,6 +645,7 @@ impl OpenAiBackend {
617
645
  response: reqwest::Response,
618
646
  sink: &TokenSink,
619
647
  tools: &[ToolSpec],
648
+ allow_tool_calls: bool,
620
649
  ) -> Result<TurnResult, BackendError> {
621
650
  use futures::StreamExt;
622
651
 
@@ -691,10 +720,12 @@ impl OpenAiBackend {
691
720
  }
692
721
  }
693
722
  crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
694
- log::warn!(
695
- "recovered tool call '{}' the model emitted as text instead of a structured call",
696
- call.name
697
- );
723
+ if allow_tool_calls {
724
+ log::warn!(
725
+ "recovered tool call '{}' the model emitted as text instead of a structured call",
726
+ call.name
727
+ );
728
+ }
698
729
  recovered.push(ToolCall {
699
730
  id: format!("call_recovered_{}", recovered.len()),
700
731
  name: call.name,
@@ -755,6 +786,19 @@ impl OpenAiBackend {
755
786
  .collect();
756
787
  tool_calls.extend(recovered);
757
788
 
789
+ if !allow_tool_calls && !tool_calls.is_empty() {
790
+ let names = tool_calls
791
+ .iter()
792
+ .map(|call| call.name.as_str())
793
+ .collect::<Vec<_>>()
794
+ .join(", ");
795
+ log::warn!(
796
+ "suppressed {} tool call(s) from a forced-text response: {names}",
797
+ tool_calls.len()
798
+ );
799
+ tool_calls.clear();
800
+ }
801
+
758
802
  // Record the assistant turn so later tool results have context.
759
803
  self.history
760
804
  .lock()
@@ -813,13 +857,14 @@ impl InferenceBackend for OpenAiBackend {
813
857
  .lock()
814
858
  .await
815
859
  .push(serde_json::json!({ "role": "user", "content": text }));
816
- self.complete(Some(tools), sink).await
860
+ self.complete(tools, true, sink).await
817
861
  }
818
862
 
819
863
  async fn send_tool_results(
820
864
  &self,
821
865
  results: Vec<ToolResult>,
822
- tools: Option<&[ToolSpec]>,
866
+ tools: &[ToolSpec],
867
+ allow_tool_calls: bool,
823
868
  sink: Option<&TokenSink>,
824
869
  ) -> Result<TurnResult, BackendError> {
825
870
  {
@@ -832,7 +877,7 @@ impl InferenceBackend for OpenAiBackend {
832
877
  }));
833
878
  }
834
879
  }
835
- self.complete(tools, sink).await
880
+ self.complete(tools, allow_tool_calls, sink).await
836
881
  }
837
882
 
838
883
  async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
@@ -887,7 +932,7 @@ impl InferenceBackend for OpenAiBackend {
887
932
  }));
888
933
  *self.history.lock().await = request;
889
934
 
890
- let summary = match self.complete(None, None).await {
935
+ let summary = match self.complete(&[], false, None).await {
891
936
  Ok(result) => result.text,
892
937
  Err(error) => {
893
938
  // Roll back the summarization request; the turn never happened.
@@ -3346,12 +3346,8 @@ mod tui {
3346
3346
 
3347
3347
  // on the last round, pass no tools so the model must produce text —
3348
3348
  // that's also the round we can stream on-device.
3349
- let next_tools = if round < MAX_TOOL_ROUNDS {
3350
- Some(tools.as_slice())
3351
- } else {
3352
- None
3353
- };
3354
- let sink = if next_tools.is_none() {
3349
+ let allow_tool_calls = round < MAX_TOOL_ROUNDS;
3350
+ let sink = if !allow_tool_calls {
3355
3351
  streamed = true;
3356
3352
  Some(&delta_tx)
3357
3353
  } else {
@@ -3359,7 +3355,7 @@ mod tui {
3359
3355
  };
3360
3356
 
3361
3357
  match backend
3362
- .send_tool_results(tool_results, next_tools, sink)
3358
+ .send_tool_results(tool_results, &tools, allow_tool_calls, sink)
3363
3359
  .await
3364
3360
  {
3365
3361
  Ok(r) => result = r,
@@ -170,7 +170,7 @@ pub async fn run(config: HeadlessConfig) -> i32 {
170
170
  if settings::local_inference_enabled() {
171
171
  None
172
172
  } else {
173
- provider::cloud_tier_provider("balanced")
173
+ provider::cloud_tier_provider(provider::DEFAULT_CLOUD_TIER)
174
174
  }
175
175
  });
176
176
  let Some(cfg) = provider_cfg else {
@@ -304,18 +304,14 @@ async fn run_prompt(
304
304
  });
305
305
  }
306
306
 
307
- let next_tools = if round < crate::MAX_TOOL_ROUNDS {
308
- Some(tools.as_slice())
309
- } else {
310
- None // last round: force text
311
- };
307
+ let allow_tool_calls = round < crate::MAX_TOOL_ROUNDS;
312
308
 
313
309
  // Whatever this round says starts a new paragraph rather than
314
310
  // continuing the sentence the tool calls interrupted.
315
311
  reply.interrupt();
316
312
 
317
313
  result = drain_to_stdout(
318
- backend.send_tool_results(tool_results, next_tools, sink_opt),
314
+ backend.send_tool_results(tool_results, &tools, allow_tool_calls, sink_opt),
319
315
  &mut sink_rx,
320
316
  &mut reply,
321
317
  )