Skip to content

test(harness): live Ollama + LM Studio coverage, and the tool-call defects it found - #93

Merged
senamakel merged 4 commits into
mainfrom
local-model-tests
Aug 8, 2026
Merged

test(harness): live Ollama + LM Studio coverage, and the tool-call defects it found#93
senamakel merged 4 commits into
mainfrom
local-model-tests

Conversation

@senamakel

Copy link
Copy Markdown
Member

Summary

Adds live test coverage for local model runtimes — Ollama and LM Studio — and
fixes the defects that writing it uncovered.

live_provider_matrix already proves a configured endpoint answers chat,
streaming, and a one-shot tool call. That is necessary but not sufficient for a
local runtime: the ways local servers break are specific to them and mostly
invisible to a single-call probe. Two new files drive the whole loop instead:

  • tests/live_local_models.rs (11 tests) — model discovery, chat, streaming
    deltas, a one-shot tool call, a full tool round trip through
    AgentHarness (model requests the tool, harness runs it, model answers using
    the result), and structured JSON output.
  • tests/live_local_embeddings.rs (9 tests) — dimensional honesty,
    positional integrity of batches, semantic separation, and a real
    index-and-retrieve round trip. e2e_embeddings.rs covers the same plumbing
    against MockEmbeddingModel, which hashes text to a stable vector, so its
    ranking assertion is tautological by construction; these use real vectors and
    queries that share no content words with the target document.

Every assertion runs against every reachable runtime, so one file covers both
and any future OpenAI-compatible local server. Both are opt-in via
LOCAL_MODEL_TESTS=1 and skip a runtime that is not listening — but fail
if the variable is set and nothing is reachable, since every assertion lives
inside a loop over the reachable runtimes and an empty list would otherwise be a
green run that tested nothing.

Four defects found by running it

  1. Tool calls emitted as text were silently dropped. Roughly one response in
    twelve, llama3.2:3b under tool_choice: "required" puts the call in
    content rather than the wire's tool_calls array, with no <tool_call>
    markup and often malformed JSON
    ({"name":"get_weather","parameters':{'city':"Paris"}}). The loop saw an
    assistant message with no tool calls, treated it as the final answer, and
    returned JSON-looking prose to the user while the tool never ran.
    apply_prompt_tool_calls now recovers it, requiring the entire message
    content to parse as one object naming a tool so prose that merely quotes JSON
    is never swallowed. Mismatched and single-quoted keys are repaired;
    single-quoted values deliberately are not, because an apostrophe in a value
    is ordinary English.

  2. Arguments buried in an envelope failed validation. Captured shapes:
    {"type":"object","properties":{"city":"Paris"}} (schema echo),
    {"arguments":{…}}, {"param":{…}}. normalize_tool_arguments now unwraps
    one envelope level — but only when the outer object is already
    schema-invalid, the tool does not itself declare an argument of that name,
    and the unwrapped value validates. Failing any of those, the original
    arguments survive so the model still sees a precise error.

  3. LM Studio had no preset. It was reachable only by bare base URL, which
    resolves to ProviderKind::Compatible and therefore misses the local-runtime
    path entirely: no Authorization suppression, no /v1 base-URL
    normalisation, and none of the request-shape degradations llama.cpp-backed
    servers need. Adds ProviderKind::LmStudio and routes both local kinds
    through one local_runtime_default_root seam. Also: the provider matrix
    skipped local runtimes unless given a placeholder API key, so a correctly
    configured local server was excluded by a variable that could never be filled
    in meaningfully.

  4. OllamaEmbeddingModel::try_new(url, model, 0) does not mean "discover"
    it means "use the default" (1024), so nomic-embed-text at 768 fails every
    call on dimension validation. Documented alongside the equivalent trap on the
    OpenAI-compatible adapter, which validates against
    text-embedding-3-small's 1536 by default.

Three findings left for maintainer judgement, not changed

  • RunPolicy::invalid_args defaults to Fail — the first schema-invalid
    tool call kills the whole run, and it also disables the argument recovery in
    (2), which only runs under the recovering policy. Defensible for a frontier
    model; for a 3B it makes the loop unusable. Local hosts must opt into
    NormalizeThenReturnToolError. Pinned in a test so a future change to the
    default is deliberate rather than silent.
  • The two embedding adapters disagree on blank input. Ollama returns an
    empty vector per blank and never dials; the OpenAI-compatible adapter rejects
    the batch. Both are position-safe — neither drops a blank and shifts every
    later vector onto the wrong id — but they are not interchangeable. Both
    behaviours are pinned rather than one being picked.
  • ToolChoice::Required is best-effort on local models. Measured 11/12 on
    llama3.2:3b. The tool tests re-roll up to four times and print when they do,
    so the model's non-determinism is visible rather than hidden, and a runtime
    that genuinely cannot emit a tool call still fails all four.

API Or Behavior Changes

  • New public variant ProviderKind::LmStudio. Additive; it is the only
    preset with no default model, because the served id is whatever GGUF the
    operator loaded. every_built_in_preset_name_resolves documents that
    exemption explicitly.
  • ProviderKind::infer accepts lmstudio: / lm_studio: / lm-studio:.
  • Tool-call recovery is now broader: a call emitted as bare text, or with
    arguments one envelope level down, is recovered where it previously was not.
    Both paths are gated so a well-formed call can never be rewritten — the
    envelope unwrap additionally requires that the unwrapped value validates.
  • relaxed_json::recover_relaxed_object widened from pub(super) to
    pub(crate) so the prompt parser can reuse the repair. Still crate-private.
  • No change to hosted-provider behaviour.

Tests

Run from the crate root:

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo build --all-targets
  • cargo build --all-targets --all-features
  • cargo test — 92 test binaries, 0 failures
  • cargo test --all-features — 92 test binaries, 0 failures

Live runs against real servers on an RTX 5060 (8 GB):

LOCAL_MODEL_TESTS=1 cargo test --test live_local_models --test live_local_embeddings -- --nocapture
  • Ollama (llama3.2:3b, nomic-embed-text) — 6/6 consecutive clean runs of
    the model suite after the fixes above.
  • Embeddings — 9/9 green against both runtimes.
  • LM Studio (qwen/qwen3-4b, nomic-embed-text-v1.5) — green, but see the
    caveat below.

Caveat on the LM Studio runs

LM Studio's headless daemon (llmster 0.0.20-1) intermittently fails an
otherwise-valid request with HTTP 400 {"error":"Engine protocol predict request failed: fetch failed"} — its own internal engine RPC, not a request the adapter
built wrongly. It hits a different test each time and survives a full daemon
restart, so it is an LM Studio bug rather than something this branch introduces;
the same tests pass on adjacent runs against the same server. Roughly half the
suite runs on this box were clean. The tests deliberately do not retry
around it: swallowing a provider-level error would hide a real outage, which is
the opposite of what this suite is for.

Also worth knowing for anyone reproducing: an 8 GB card cannot hold both
runtimes at once. When it is oversubscribed Ollama silently drops to partial CPU
offload (25%/75% CPU/GPU) and calls go from ~1 s to over 5 min, which looks
exactly like a hang. Run the two runtimes separately, or expect that.

Documentation

  • New: docs/modules/harness/local-models.md
    — presets, every failure mode above with the captured wire shapes, the
    InvalidArgsPolicy guidance, the reasoning-model token-budget trap, the two
    embedding adapters and their blank-input divergence, and how to run the suites.
  • Linked from docs/modules/harness/README.md.
  • providers.env.example — LM Studio now documented as a preset, and the stale
    "set any non-blank value" advice for keyless local runtimes corrected.

senamakel and others added 4 commits August 8, 2026 15:03
Co-authored-by: Medulla <medulla@tinyhumans.ai>
Co-authored-by: Medulla <medulla@tinyhumans.ai>
Co-authored-by: Medulla <medulla@tinyhumans.ai>
Co-authored-by: Medulla <medulla@tinyhumans.ai>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e08d8ec1-0afb-4e0e-a9ff-136c57815de6

📥 Commits

Reviewing files that changed from the base of the PR and between 6655b6e and 3cc45b6.

📒 Files selected for processing (14)
  • docs/modules/harness/README.md
  • docs/modules/harness/local-models.md
  • providers.env.example
  • src/harness/agent_loop/test.rs
  • src/harness/agent_loop/tools.rs
  • src/harness/providers/openai/mod.rs
  • src/harness/providers/openai/relaxed_json.rs
  • src/harness/providers/openai/transport.rs
  • src/harness/providers/types.rs
  • src/harness/tool/prompt.rs
  • src/harness/tool/prompt_test.rs
  • tests/live_local_embeddings.rs
  • tests/live_local_models.rs
  • tests/live_provider_matrix.rs

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3cc45b6f1b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +489 to +491
if let Some(call) = parse_bare_tool_call(&text) {
calls.push(call);
response.message.tool_calls.extend(calls);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify bare call names against the offered tools

When tools are offered and the model returns a legitimate JSON answer such as {"name":"Alice","age":30}, this branch treats the name field alone as proof of a tool call, removes the answer text, and sends an unknown Alice call through the agent loop. This is especially likely when combining tools with a JSON response format; bare-call recovery needs to confirm that the name matches an offered tool (and preferably that an argument-envelope key is present) before consuming the response.

Useful? React with 👍 / 👎.

Comment on lines +492 to +496
// The object was the whole visible text, so nothing survives as
// prose — but a reasoning model's `Thinking` block must, hence
// `replace_text_blocks` with empty text rather than clearing the
// content outright.
response.message.content = replace_text_blocks(response.message.content, String::new());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress recovered bare calls from streaming deltas

For a streaming response containing a bare tool-call object, clean_stream_item forwards every JSON MessageDelta because its scrubber only recognizes tagged calls, and this terminal-only branch then removes the text from Completed. Consequently a consumer rendering incremental deltas displays the raw tool-call JSON even though the final response says it was consumed as a tool call. The streaming path must buffer or otherwise retract bare-object candidates so its visible deltas remain consistent with the recovered completion.

Useful? React with 👍 / 👎.

@senamakel
senamakel merged commit 7107186 into main Aug 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant